Labb 4: Tetris
Syfte
Denna labb, som precis som de tidigare görs enskilt, har tre syften:
Att du ska skriva ett komplett, fungerande program. Tidigare uppgifter var jämförelsevis små och enkla. Här bygger vi istället upp något konkret och sammanhängande under en längre tid. Vi gör detta i form av ett spel: Tetris.
Att du ska testa GUI-programmering i Java. Tetris ger flera möjligheter att utforska GUI-programmering.
Att utforska fler OO-begrepp och modelleringsfrågor. För att leda dig åt rätt håll när du omsätter teori i praktik är inledningen i "tutorial"-form. Mot slutet får du gradvis mer och mer frihet – och färre detaljerade ledtrådar.
Förberedelser
Om du inte har spelat Tetris tidigare: Läs på lite om det och testa någon variant. Beskrivningen förutsätter att du vet hur Tetris fungerar.
Instruktionerna förutsätter att du har varit på samtliga föreläsningar om objektorientering (grunderna, programmeringsbegreppen, ärvning och hierarkier, samt ytterligare OO-begrepp). Detta ska vi enligt schemat gå genom under de första två veckorna.
Info: Varför just Tetris?
En fördel med Tetris är att det inte är så krävande i fråga om t.ex. animering, kollisionsdetektering och andra knepigheter som inte har så mycket med objektorientering att göra. Vi kan därför fokusera mer på OO.
Info: Att bryta ner ett projekt i delar
Så hur programmerar man ett lite större sammanhängande projekt såsom ett Tetris-spel? Ett bra tips är att bryta ner det i steg eller milstolpar som kan implementeras i tur och ordning – helst på ett sätt som gör att man kan testa varje steg för sig och känna att man faktiskt har åstadkommit något och kommit vidare. Hela labben är uppbyggd på detta sätt.
Info: Bra att tänka på
-
Arbeta på egen tid! Denna labb är betydligt större och vi räknar med att många behöver betydligt mer tid än det som schemalagts.
-
Använd gärna referenskortet för IDEA när du programmerar. Många funktioner kan vara mycket användbara här och i projektet.
-
Läs genom varje deluppgift (t.ex. hela uppgift 2.1) innan du utför den.
-
En viss mängd dokumentation av era lösningar krävs för denna labb. Kommentera!
-
Du får använda andra labbmiljöer från och med denna labb – men koden måste lämnas in i IDEA-format och kontrolleras med IDEAs kodinspektioner!
4.0. Inledning
Bakgrund 4.0.1: Skapa projekt och paket
Om du vill sätter du nu upp ett nytt IDEA-projekt för Tetris-labben. Det går också bra att fortsätta i det gamla projektet, så länge du separerar de gamla och nya klasserna från varandra genom att lägga dem i olika (under)paket.
Att göra 4.0.1: Skapa projekt och paket
Skapa eventuellt ett nytt projekt enligt instruktionerna i labb 1. Kom ihåg att ändra More Settings / Project format till ".ipr (file based)"!
Skapa ett paket för Tetris, med lämpligt namn. Se tidigare instruktioner vid behov. Följ samma namngivningsregler för paket.
4.1. Spelplan och "kvadrater"
Syfte: Modellering av spelplan!
Det första steget är att skapa lämpliga datatyper för spelets
nuvarande tillstånd – speciellt en typ för själva
spelplanen, som vi kan kalla Board
. Vi vill
speciellt fokusera på hur man tänker när man modellerar.
Därför går vi genom steg för steg hur vi har tänkt för att komma
fram till de föreslagna datatyperna.
Info: Tetris-terminologi!
Tetris har en spelplan där man steg för steg lägger till tetrisblock som "ramlar ner" uppifrån. Varje tetrisblock är tekniskt sett en tetromino, ett specialfall av en polyomino (tetra=4): Det består av fyra sammanhängande kvadrater i en viss färg och ett visst mönster, där totalt 7 olika mönster är möjliga. Nedan syns ett exempel från Wikipedia. Standardnamnen på dessa block är I, J, L, O, S, T och Z.

Info: Separera information från visning
När vi skapar datatyperna för spelplaner och brickor ska vi tänka på en princip bakom designmönstret Model-View-Controller (MVC), som vi kommer att gå genom på föreläsningarna:
Det är bra att separera den grundläggande informationen om någonting, modellen, från hur den visas, vilket hanteras av vyn, och hur den kontrolleras via ett användargränssnitt, controllern. Detta hänger ihop med Single Responsibility Principle.
Mer specifikt vill vi se till att spelplanen,
vår Board
-klass, enbart innehåller information om
vilka block och kvadrater som ligger var inom spelplanens rutnät
– inte om hur detta ska visas på skärmen (färger, exakta
GUI-koordinater, och så vidare). Senare kan vi välja att
"visualisera" modellen i textformat, i 2D-format, med 3D-grafik,
eller på många andra sätt.
Hade vi blandat ihop allt i samma klass skulle det bli en sammanblandning av för mycket funktionalitet. Dessutom vore det jobbigt att lägga till nya visningssätt, och det skulle vara mycket svårare att t.ex. göra om spelet till ett nätverksspel.
Bakgrund 4.1.1: Kvadrater
Nu ska vi börja tänka genom alternativ för modellering. Läs genom hela rutan innan du börjar genomföra detta.
Hur representerar vi spelplanens nuvarande "utseende"? Vi
kunde tänka oss att spelplanen (Board
) skulle lagra
en lista av de hela tetrisblock som har ramlat ner samt x- och
y-positioner för dessa block på planen. Men när ett tetrisblock
väl har "fallit på plats" kan vissa av dess kvadrater så småningom
försvinna medan andra finns kvar (att fulla rader försvinner är ju
själva poängen med spelet). Detta vore det rätt krångligt att
representera med en lista över tetrisblock eftersom vi skulle
behöva hålla reda på vilka delar av varje tetrisblock som
fortfarande finns kvar.
Istället kan vi modellera på följande sätt: För varje position
(x,y) på spelplanen, är den tom, och om inte, vilken
typ av tetromino tillhör kvadraten på den positionen? Varje
gång ett block faller på plats läggs det då in information om fyra
nya kvadrater på lämpliga koordinater
i Board
-objektet. Vi behöver inte hålla reda på
vilket tetrisblock varje enskild kvadrat kom från, och vi behöver
inte ens separata objekt för varje kvadrat -- bara separata objekt
för varje kvadrattyp.
Hur representerar vi då en "typ av kvadrat"? Vi vill kunna representera exakt 8 olika värden: "här finns ingen kvadrat", "här finns en kvadrat från ett I-block", "här finns en kvadrat från ett T-block", och så vidare. Java stödjer enum-klasser, som är gjorda just för situationen när en klass har ett fast antal värden. ("Enum" kommer från "enumeration", dvs. "uppräkning". Vi vill räkna upp de 8 värden som finns, och sedan ska man inte kunna skapa nya.)
Vi ska därför skapa en enum-typ med namn SquareType
.
Eftersom vi troligen inte har hunnit fram till enums i
föreläsningarna än, ger vi lite extra hjälp nedan.
En
SquareType
ska bara indikera vilken typ av tetromino
som har resulterat i en viss kvadrat på skärmen. Den ska däremot
inte ha några metoder för att faktiskt rita upp en kvadrat
till exempel i en viss färg. Den bestämmer inte heller vilken färg
kvadraten ska ha, eller om kvadraten ska visualiseras "platt" eller
i pseudo-3D som i exemplet ovan (eller med en riktig 3D-motor där
man kan rotera spelplanen), och så vidare. SquareType
är ju enbart en del av modellen.
En vy kommer vi att skapa senare, och då är vi till och med fria att visualisera i textformat om vi vill, genom att konvertera varje enumkonstant (som det inte finns särskilt många av) som ett specialtecken som "#" eller "%". Inom schack skulle "pjäsmodellen" inte heller innehålla information om hur en kung skulle se ut på skärmen, men det kan senare visualiseras i text eller med bilder.
Att göra 4.1.1: Kvadrater
Skapa en enum-klass genom att högerklicka på Tetris-paketet i IDEA, välja New | Java Class och sätta "Kind" till Enum. Ge klassen namnet
SquareType
.Lägg in följande inom klamrarna:
EMPTY, I, O, T, S, Z, J, L
Klassen ser alltså ut så här:
public enum SquareType { EMPTY, I, O, T, S, Z, J, L }
Detta betyder helt enkelt att typen
SquareType
har värdenaSquareType.EMPTY
,SquareType.I
,SquareType.O
, och så vidare. Eventuellt klagar IDEA på att namnen är för korta. Detta kan du ignorera.Här finns mer information om enum-typer om du skulle vara nyfiken på mera detaljer innan föreläsningen om datatyper.
Använda arrayer
Vi kommer strax att börja använda arrayer, som kan ses som en sorts listor med fixerad längd. Om du kommer hit innan vi har gått genom hur detta fungerar kan du se följande kortfattade exempel, eller titta närmare i Java Tutorial, en av våra kursböcker.
Första raden skapar en array med plats för 10 element. Andra raden används för att komma åt det fjärde elementet, som har index 3 (index börjar på 0). Tredje raden visar hur man ändrar ett element.
SquareType[] array = new SquareType[10];
SquareType pos3 = array[3];
array[3] = SquareType.EMPTY;
Men nu är det en tvådimensionell array, ett "rutnät", som vi behöver för spelplanen. Första raden nedan skapar en 2-dimensionell array. Andra raden används för att komma åt en rad i arrayen, tredje raden visar hur man kommer åt en cell, och fjärde raden visar hur man ändrar en cell.
SquareType[][] array = new SquareType[rows][columns];
SquareType[] rowZero = array[0];
SquareType row5col9 = array[5][9];
array[5][9] = SquareType.EMPTY;
Bakgrund 4.1.2: Spelplan
Nu behöver vi en spelplansklass, Board
, som
innehåller information om vilken typ av kvadrat som för tillfället
finns på varje position på spelplanen.
Ett Board
t.ex. innehålla en
tvådimensionell array av lämplig storlek, där varje element
är en SquareType
. Att arrayer har ett fixerat antal
element fungerar utmärkt i Tetris, eftersom spelplanens storlek
inte ändras under spelets gång.
Board
behöver också ha en konstruktor som tar en
bredd och höjd på spelplanen som argument och skapar en lämplig
tvådimensionell array. Detta verkar vara det enda vi behöver just
nu, men fler fält och metoder kommer säkert att tillkomma när vi
kommer längre med spelet.
Att göra 4.1.2: Spelplan
Skapa klassen
Board
i Tetris-paketet.Ge klassen fältet
private SquareType[][] squares
. Detta deklarerar (en pekare till) en tvådimensionell array av SquareType-värden.Ge klassen en konstruktor som tar parametrarna
width
ochheight
. Låt konstruktorn sättasquares
tillnew SquareType[height][width]
. Detta skapar den faktiska arrayen som fältet ska peka på.-
Alla positioner i arrayen är nu
null
, dvs. vi har en array av pekare som inte pekar på några verkliga objekt. Se till att konstruktorn fyller alla positioner i arrayen (alla kolumner i alla rader) med "tomma kvadraten" istället. -
Gör en
main
-metod som helt enkelt skapar ett Board med valfri storlek. Testkör detta för att se att programmet inte kraschar, t.ex. genom att man försöker skriva utanför arrayens gränser.
Info: Gömma data
Varför skulle den tvådimensionella arrayen vara privat
i Board
? För att vi vill att spelplansklassen ska
ha full koll över hur planen manipuleras – andra klasser
får anropa metoder i planen för att be den göra något, men de
får inte gå in och ändra direkt i arrayen. Man ska inte heller
skapa en metod som returnerar hela arrayen, eftersom det låter
utomstående manipulera den direkt.
Bakgrund 4.1.3: Ramar
Längre fram kommer vi att låta tetrominos flytta runt på spelplanen. Då kommer vi att behöva hantera kollisioner med existerande tetrominos på planen, så att vi inte flyttar något genom något annat.
Vi kommer också att behöva kolla om användaren försöker flytta
en tetromino utanför spelplanens gränser (vänster/höger/botten).
Hur gör vi detta? Ett sätt är att använda olikheter som räknar
med tetrominons storlek och brädets storlek. Då måste vi även
tänka på exakt hur tetrominon är roterad just nu, vilket
komplicerar saken. Ett annat sätt är att utnyttja
kollisionskoden, som ju ändå måste finnas. För att göra detta
skapar vi ett speciellt enum-värde OUTSIDE
som vi
sedan lägger som en "ram" runt spelplanen. Ett försök att
flytta en tetromino utanför planen blir då helt enkelt en
"krock".
Att göra 4.1.3: Ramar
-
Lägg till värdet
OUTSIDE
iSquareType
(i den kommaseparerade listan av värden). -
Se till att
Board
-konstruktorn lägger in en "ram" av värdetOUTSIDE
i arrayens "utkanter". -
Testkör igen.
4.2. Utskrift av spelplan
Syfte 4.2.1: Visualisera snabbt
Vi vill gärna kunna visa spelplanen för att se om vi har gjort
rätt. Istället för att gå direkt på grafiken kommer vi i ett
första steg att köra textbaserat. Dels är detta enklare och gör
att vi snabbt kan komma vidare, dels kan det vara användbart för
debuggning av programmet, och dels är det ett steg i att
demonstrera användningen av flera vyer i
Model-View-Controller-mönstret och användningen av strängar i
Java.
Bakgrund 4.2.1: Vy-klass, förberedelser
Vy-klassen som ska skapas behöver kunna få
reda på hur stort ett Board
är och vilken typ av kvadrat (om någon)
som finns på en viss position. Detta ska den så klart inte få reda
på genom att själv titta på fälten i Board
(de ska ju vara privata).
Istället krävs nya metoder i Board
. De metoderna ska bland annat
returnera information om en specifik position – returnerar du
hela arrayen kan den ju ändras utifrån, och du låser också fast dig
för hårt vid arrayrepresentationen.
Att göra 4.2.1: Vy-klass, förberedelser
-
Skapa getters för width
och height
i Board
samt en metod för att hämta en SquareType
ur en cell i squares
.
Bakgrund 4.2.2: Strängmanipulation
Javas strängar är oföränderliga: Man kan inte ändra på dem när de väl har skapats.
För att inkrementellt bygga upp en lång sträng använder man lämpligen
klassen StringBuilder
och dess append()
-metoder. För att lägga till en
radbrytning efter varje rad används "\n
" (backslash n). När
strängen är klar tas den fram som String
-objekt med
toString()
. Här är ett exempel:
// Create new StringBuilder.
StringBuilder builder = new StringBuilder();
// Loop and append values.
for (int i = 0; i < 5; i++) {
builder.append("abc ");
}
// Convert to string.
String result = builder.toString();
// Print result.
System.out.println(result);
Givet en
SquareType
behöver vi veta vilken symbol den ska illustreras med.
Man kunde tänka sig att detta skulle läggas in direkt i själva
SquareType
-objektet men då har vi blandat ihop modellen
(SquareType
) med ett specifikt sätt att visa modellen
på (text). Istället får vi låta vyn känna till
vilka SquareType
s som finns och ha en hjälpmetod som
returnerar symbolen motsvarande en viss SquareType
.
Att göra 4.2.2: Strängmanipulation
-
Skapa klassen BoardToTextConverter
, med
metoden convertToText(Board)
som
returnerar en strängrepresentation av ett modellobjekt – den
skriver alltså inte ut på skärmen själv utan returnerar en sträng
som anroparen kan skriva ut. Tomma rutor ska då representeras med
t.ex. mellanslag eller bindestreck, och olika typer på kvadrater
(SquareType
s) ska representeras som textsymboler, t.ex. "#
",
"%
" och "-
".
Du
behöver en nästlad loop som itererar över rader och kolumner i
lämplig ordning. Inuti loopen kan man använda
en switch
-sats med ett case
för varje
SquareType
-värde, något som är möjligt på grund av
att SquareType
är en enum. Det vill säga, om elementet
på en viss position är SquareType.EMPTY
lägger du till
ett mellanslag, om elementet är SquareType.I
lägger du
till en annan symbol, osv.
Glöm inte radbrytningarna "\n"
mellan raderna.
Metoden kan vara static
eftersom den inte behöver
tillgång till tillståndet i en
specifik BoardToTextConverter
.
-
Skriv en testklass, BoardTest
, som skapar en tom
spelplan, konverterar den till en sträng med hjälp
av BoardToTextConverter
-klassen, och skriver ut resultatet. När
det är klart har du åstadkommit ett testbart ramverk och kan se att
du har gjort konkreta framsteg!
4.3. Slumpning av spelplan
Bakgrund 4.3.1: Test
För vår testning vill vi också ha ett sätt att
slumpa fram en spelplan. Detta kan ligga i en separat hjälpklass
eller läggas in i klassen Board
.
Att göra 4.3.1: Test
-
Skapa en metod som kan användas för att generera en spelplan
som har fast storlek men slumpmässigt innehåll.
Klassen java.util.Random
och
metoden nextInt(n)
kan användas för att hitta ett
pseudoslumptal mellan 0
och n-1
.
Med hjälp av klassmetoden (statiska metoden)
SquareType.values()
får vi fram en array som
innehåller alla värden i enum-typen SquareType
,
vilket sedan kan indexeras med slumptalet.
-
Ändra klassen BoardTest
så att den
slumpar fram och skriver ut 10 spelplaner. Testkör.
4.4. Tetrisblock / tetrominoes
Bakgrund 4.4.1: Polyominos
Efter spelplanen kommer själva Tetrisblocken. Det finns totalt 7
varianter i grundspelet, och vi kan tänkas vilja utöka detta på
olika sätt i framtiden – genom nya former (utökning till
pentominoes med fem kvadrater per block, penta=5) eller andra varianter
(block som exploderar). Frågan är då hur man ska implementera block
på bästa sätt för att tillåta sådana utökningar.
-
En generell abstrakt klass + en konkret subklass per blocktyp
(L, T, ...)? Det är så klart en möjlighet, men om vi tänker
lite närmare på spelet inser vi att blocken egentligen inte
beter sig olika. Därför skulle vi egentligen inte ha så mycket
nytta av de olika subklasserna – vi skulle till exempel
inte behöva "overrida" metoder från den generella klassen för
att utnyttja subtypspolymorfism, och alla klasser skulle behöva
samma fält för att lagra information. Det enda som skulle
skilja sig mellan klasserna är formen på varje block, och detta
är data, inte beteende.
Viktig regel: Skapa bara nya klasser om de verkligen har
olika beteende som ger större skillnad i koden. Skapa inte
nya klasser när det räcker med att några värden skiljer sig.
-
En enda klass? Det verkar vara ett bättre val. Och om
vi sedan vill implementera exploderande block kan vi göra det
med en enda ny subklass, snarare än sju olika
subklasser ("exploderande L", "exploderande O", osv).
Vi skapar därför en enda blockklass, som vi kan
kalla Poly
(kort för Polyomino).
Vi behöver ett sätt för varje Poly
-objekt att tala om
hur det ser ut. Detta har inte att göra med visualisering (pixlar
på skärmen) utan är en del av den fundamentala modellen för ett
block (till exempel avgör det hur långt ett block kan falla).
Därför ska det definitivt finnas med i
Poly
-klassen. Eftersom vi ska placera ut
Poly
-objekt på vårt Board
kan vi med
fördel representera denna på samma sätt. Vi väljer därför en
tvådimensionell array för vår Poly
.
Tanken är att klassen Poly
ska kunna användas för
vilken typ av block som helst, inklusive t.ex. block med 3 eller 5
kvadrater. Därför vill vi inte direkt i Poly
hårdkoda
kunskapen om exakt vilka blocktyper som finns. I stället låter vi
konstruktorn ta in en tvådimensionell array av kvadrater som
parameter. Den exakta storleken på arrayen bestäms då av den som
anropar konstruktorn.
Att göra 4.4.1: Polyominos
-
Implementera klassen Poly
enligt beskrivningen
ovan.
Klassens konstruktor ska alltså ta in en array som beskriver
konfigurationen hos en godtycklig polyomino. Sådana arrayer
kommer sedan att skickas in av TetrominoMaker
i
nästa uppgift – det är där de 7 specifika blocktyperna
definieras.
Bakgrund 4.4.2: TetrominoMaker
Vi behöver något sätt att skapa de sju sorternas Poly
.
För att uppnå detta kan vi skapa en separat
klass, TetrominoMaker
. Klassen kan t.ex. ha dessa
metoder:
public int getNumberOfTypes() { ... }
public Poly getPoly(int n) { ... }
En anropare kan då skapa en TetrominoMaker
och
anropa getNumberOfTypes()
för att fråga om hur många
blocktyper som den kan ge oss (just nu 7).
Anroparen kan sedan slumpa fram ett tal mellan 0
och getNumberOfTypes()-1
och anropa
objektets getPoly()
med detta som argument.
Metoden getPoly()
returnerar då ett
nytt Poly
-objekt av den givna typen.
getPoly()
gör detta genom att skapa en 2D-array av
lämplig storlek, fylla den med kvadrater motsvarande det begärda
blocket, skapa en ny Poly
för denna array, och
returnera det nya objektet.
Vilken storlek ska då arrayen ha? För att underlätta för
senare rotation av blocken är det en fördel att använda en
storlek på 2x2, 3x3 eller 4x4, beroende på vilken tetromino som den
innehåller. Arrayerna kan då skapas enligt vänstra kolumnen i
följande bild (där de övriga kolumnerna visar hur blocken ser ut när
de roteras, vilket vi kommer till senare). Blocket O ("kvadraten")
ser ut att ligga i en array med storlek 4x3, men för att förenkla det
för oss själva kan vi helt enkelt lägga det i storleken 2x2 istället.
Bilden kommer
från en beskrivning av det
standardiserade rotationssystemet.
Att göra 4.4.2: TetrominoMaker
-
Implementera klassen TetrominoMaker
enligt
beskrivningen ovan.
Tänk på att inte skapa för långa metoder –
om getPoly()
blir för lång när du lägger in
skapandet av alla blocktyper i den metoden kan du bryta ut
delar till sju olika hjälpmetoder istället.
Info: Fabriker och designmönster
En TetrominoMaker
är en fabrik (factory): Ett
objekt som kan skapa andra objekt och returnera dem till anroparen.
Man kan också bredda detta begrepp och kalla getPoly()
för en fabriksmetod (factory method).
Oftast ser man inte detta som ett objektorienterat
designmönster, även om det finns objektorienterade designmönster med
liknande namn, t.ex. factory method pattern
och abstract factory pattern. Dessa mönster bygger
istället på en specifik och mer komplicerad relation mellan olika
objekt och klasser. Att ha ett objekt som kan returnera andra
objekt räcker alltså inte för att man ska kunna säga att man
använder ett objektorienterat designmönster. (Eller omvänt: Det
behövs som tur är inget objektorienterat designmönster för att man
ska kunna göra något så enkelt som att returnera ett nytt objekt
från en metod!)
Bakgrund 4.4.3: Tetrisblock på spelplanen
Vår spelplan kan representera de
block som redan har fallit ner. Detta representeras som en array av
SquareType
s, vilket ger oss all information vi behöver för att rita
upp det som är kvar av de block som fallit ner. Nu behöver den
också kunna representera ett block som är på väg att falla
ner, så att all information om spelets nuvarande tillstånd sparas på
ett och samma ställe. Hur åstadkommer vi det?
Alternativ 1 är att de fyra kvadraterna
(SquareType
) från det fallande blocket läggs in i samma
tvådimensionella array som används för att lagra de block som redan
har fallit ner. Det har en fördel: Eftersom allt som ska ritas ut
ligger lagrat på samma sätt som förut, behöver vi inte behöver ändra
uppritningsfunktionen (BoardToTextConverter
). Å andra
sidan har vi en stor nackdel: När det fallande blocket flyttar sig
ett steg (eller roteras) måste vi komma ihåg var vi hade lagt in det,
ta bort dess SquareType
s från dessa positioner
i arrayen och lägga till dem igen på nya positioner. Detta kan bli
lite omständigt.
Alternativ 2 är att den tvådimensionella arrayen
i Board
bara lagrar de block som redan har fallit
färdigt. Det block som fortfarande faller får Board
hålla reda på genom att ha ett fält som pekar på en
fallande Poly
(eller null
om inget block
faller just nu) och två fält som anger dess x- och y-koordinater.
Då blir det lätt att flytta det fallande blocket genom att helt
enkelt ändra dess koordinater, utan att överhuvudtaget peta
i Board
:s tvådimensionella array. Det underlättar
också framtida utökningar där ett block inte nödvändigtvis faller
och roterar med ett "hopp" från ett läge till ett annat utan
animeras jämnt och mjukt. Å andra sidan behöver vi ändra
uppritningsfunktionen så den tar hänsyn till både de block som
fallit färdigt och det fallande blocket.
I den här labben ska det andra alternativet användas. Det
kräver nya fält och metoder i Board
samt utökningar
i BoardToTextConverter
.
Just nu (utan mjuka animeringar) gäller alltså för varje position
(x,y) som ska ritas ut (läggas till i textsträngen): (1) Om
positionen täcks av en kvadrat i ett fallande block gäller
denna SquareType
. (2) Annars gäller
den SquareType
som anges av Board
.
Att göra 4.4.3: Tetrisblock på spelplanen
-
Lägg till ett fält Poly falling
i Board
. Där ska du lagra en pekare till "den
poly som just nu håller på att ramla ner", eller null om ingen
poly faller just nu. Du behöver också ha koll på tetrominons
nuvarande position (x,y), så lägg till fält för detta. Lägg
slutligen till getters så att BoardToTextConverter
kan
få ut den fallande tetrominon och dess koordinater.
-
Ändra i BoardToTextConverter
så att även den
fallande tetrominon "ritas" ut (i textsträngen).
Nu finns det positioner (x,y) där det både finns en
ruta från spelplanen och en ruta från den fallande tetrominon.
I dessa fall är det alltid rutan från den fallande tetrominon,
inte rutan från spelplanen, som ska "skrivas ut" (läggas till
i strängen). Det vill säga, den fallande tetrominon ska
aldrig "döljas bakom" de rutor som redan ligger i spelplanen,
utan ska alltid synas. (När vi har spelmekaniken på plats
kommer den här typen av överlapp inte att kunna ske!)
4.5. Ett enkelt GUI
Bakgrund 4.5.1: Textgrafik
Det är nu dags att gå över från rent textformat till ett grafiskt
gränssnitt.
Om du inte är van vid GUI-programmering med Swing i Java: Vänta
till du har varit på GUI-föreläsningen.
För att förenkla steget in till ett GUI-program kommer du att börja
med att skriva ett grafiskt gränssnitt som använder en grafisk
textkomponent, en JTextArea
, för att visa den gamla
textrepresentationen av en "spelplan". Det verkar kanske som ett
lite underligt sätt, men det gör dels att du kan börja med GUIt
redan innan du lär dig hur man skapar egna komponenter, dels att du
får ett enklare "mellansteg" så att du inte behöver göra om hela
användargränssnittet på en gång. Det gör det också möjligt att göra
en väldigt enkel animering av t.ex. nedfallande block genom att du i
JTextArea
byter ut texten i varje steg, istället för att
som med System.out.println()
skriva ut varje ny
spelplan under den förra.
Att göra 4.5.1: Textgrafik
Skapa en nya klassen TetrisFrame
och gör den
till en subklass till Javas fönsterklass JFrame
.
På något sätt ska TetrisFrame
få tillgång till
ett Board
-objekt. Man kunde tänka sig
att TetrisFrame
själv skulle skapa
ett Board
, men det är mer flexibelt om man istället
skickar in ett Board
som parameter till
konstruktorn. Detta följer principen att varje klass ska ha en
väl avdelad uppgift att utföra och ska inte göra något som
ligger utanför den uppgiften! TetrisFrame
s uppgift
är att visa ett spelplan och låta oss interagera med det, inte
nödvändigtvis att skapa det.
När du skapar en subklass måste du alltid tänka på vilka
parametrar som du måste – eller vill – skicka med
till superklassens konstruktor. JFrame
har i och
för sig en konstruktor som saknar argument, så det går
att låta bli att göra något explicit anrop till superklassens
konstruktor från TetrisFrame
s konstruktor, men då
får vi ingen fönstertitel. Det är bättre att anropa
den JFrame
-konstruktor som tar emot en fönstertitel
som parameter. Konstruktorn till subklassen
TetrisFrame
behöver då ange den parametern genom
att först av allt anropa
t.ex. super("MyWindowTitle")
. Se mer information
om detta i föreläsningsbildernas diskussion om konstruktorer i
subklasser.
I konstruktorn till TetrisFrame
(eller i
metoder som anropas från konstruktorn) ska du sedan bygga upp
ett lämpligt användargränssnitt. Den viktigaste delen just nu
är den JTextArea
som ska användas till att visa
själva "spelplanen". Ange
i konstruktorn
till JTextArea
hur många kolumner och rader som
ska visas – detta avgör vilken preferred size
textarean ska ha. Antalet kolumner och rader får du reda på
genom att fråga det Board
som konstruktorn har fått
som parameter, inte genom att hårdkoda!
Hur hittar man vilka konstruktorer som finns? I IDEA, skriv
"new JTextArea(
" och tryck Ctrl-P. Då får du se
alla varianter på parametrar. Välj en av dem och tryck Ctrl-Q
så får du dokumentation för parametrarna.
Redan när en TetrisFrame
skapas måste textarean
ges sitt första innehåll. Här förutsätter vi att du har skrivit
en BoardToTextConverter
med en metod som returnerar en
sträng, inte en metod som skriver ut en spelplan direkt till
t.ex. System.out
. Då kan du använda den metoden för att få en
sträng motsvarande ett Board
, och sedan använda
textareans metod setText()
för att sätta rätt text.
Att uppdatera textarean när spelplanen ändras blir en senare
uppgift.
I övrigt behövs en del kod i konstruktorn för att definiera
layouten (inklusive att sätta fönstrets layouthanterare), se
till att fönstret visas, osv. Om du kommer hit innan
GUI-föreläsningen kan vi ge följande tips, utöver läroböckerna:
Layouten för ett fönster hanteras av
en layouthanterare, inte genom att man anger
koordinater för komponenter. En lämplig start kan vara att
fönstret sätter sin layouthanterare
med this.setLayout(new BorderLayout())
.
Fönstret kan sedan lägga till textarean
med this.add(textarea, BorderLayout.CENTER)
.
Detta placerar textarean "i mitten" i fönstret. Eftersom vi
inte har lagt till några andra komponenter i fönstret kommer
textarean att ta upp hela utrymmet i fönstret.
För att ge fönstret en storlek och göra det synligt:
frame.pack(); frame.setVisible(true);
Testklassen behöver ändras så att den skapar
ett Board
och öppnar
ett TetrisFrame
-fönster. När testet körs ska
slutresultatet vara att du ser en lagom
stor TetrisFrame
med en framslumpad spelplan (inte
10 spelplaner, som tidigare). Just nu finns ingen
händelsehantering (event handling), så du får använda
"stoppknappen" i utvecklingsmiljön för att stänga av
testprogrammet.
4.6. Timer för spelloop
Bakgrund 4.6.1: Timer
Så småningom behöver vi ha något sätt att driva spelet framåt, att
få block att falla ner i lagom takt och så vidare.
Ett inte alltför ovanligt misstag är att man lägger in en loop som
"stegar fram" ett steg, gör en paus av konstant längd, "stegar fram"
nästa steg, och så vidare. Problemet är att när pausen är
av konstant längd kommer tiden från starten av ett steg till starten
av nästa att variera, beroende på hur lång tid
själva steget tar, vilket är olika beroende på dator och
CPU-belastning på datorn. Vi får göra på något annat sätt.
Som tur är finns en klass som
heter javax.swing.Timer
som vi kan använda för att få ett "steg" i spelet att köras
regelbundet. Blanda inte ihop den med andra klasser som heter
Timer, t.ex. java.util.Timer
! Här ser vi en
anledning till att man lägger klasser i olika paket.
I den här uppgiften ska vi bara göra en enkel utforskning av
Timer
. Senare, när vi har gått vidare till en
fullständig grafisk komponent, kommer vi att titta närmare på
lämplig modellering av spelet som helhet och var spelloopen
egentligen ska placeras.
Det vi behöver veta kan vi då lära oss av följande exempel och
beskrivningen under.
final Action doOneStep = new AbstractAction() {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Timer clockTimer = new Timer(500, doOneStep);
clockTimer.setCoalesce(true);
clockTimer.start();
...
clockTimer.stop();
En Timer
ser alltså till att Swings egna händelsehanteringstråd
anropar en viss handling ("action") med jämna mellanrum. I detta fall är det
500 millisekunder mellan anropen, och det som anropas är den
handling som vi kallade doOneStep
. Genom att anropa
setCoalesce(true)
ser vi till att anropen inte "köas
upp" om ett visst anrop till doOneStep()
skulle ta för
lång tid. Vi kan starta timern när spelet börjar och stoppa den när
spelet är över.
I övrigt kan
även clockTimer.setLogTimers()
vara intressant för felsökning. Se även Javadoc-dokumentationen som
vi länkar till ovan!
Att göra 4.6.1: Timers
-
Skapa någonstans en Timer
som kör en handling med
regelbundna mellanrum, en gång i sekunden. Handlingen ska
slumpa om spelplanen (inte byta ut Board
-objektet utan ändra i
det!) på motsvarande sätt som du gjort tidigare, och visa
resultatet i textarean som du skapade i en tidigare uppgift.
Handlingen behöver alltså på något sätt få tillgång till både
spelplan och textarea. Slutresultatet ska helt enkelt bli att
du ser en enkel "animering" på skärmen medan programmet körs.
Du behöver inte kunna stoppa timern.
4.7. Kodinspektion!
Bakgrund 4.7.1: Kodinspektion
Det är viktigt att man granskar sin egen kod då och då, för att
eventuella problem inte ska bli för långlivade. Ju tidigare man kan
förbättra koden, desto längre tid har man att utnyttja de
förbättringarna. Det gäller ännu mer i den här kursen, när ni
själva håller på att lära er objektorientering och Java.
Att göra 4.7.1: Kodinspektion
-
Om du normalt använder en annan miljö: Importera ditt projekt
till IDEA. Det behöver du ändå kunna göra inför inlämningen.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100%
korrekt. Varningen ska alltså oftast tolkas som "har du tänkt
på det här?" snarare än "du har fel!". Så småningom måste
sådana kommenteras på plats i koden, med god motivering till
varför varningen var "ogiltig" i detta fall. Vissa varningar
kanske du vill kommentera redan nu. Se
även kvalitetskriterierna.
Det kan även finnas varningar som kommer från att du helt
enkelt inte är klar än, t.ex. varningar för kod som inte
används. De varningarna kan du så klart ignorera i det här
läget.
Saknar du inspektionsprofilen? Om du kör på universitetets
datorer, och installerade IDEAs konfigurationsfiler korrekt,
borde profilen finnas där. Felanmäl till oss! Om du kör
hemma behöver du ladda ner konfigurationsfilerna.
Se slutet
av kvalitetskriterierna.
4.8. Mer GUI-programmering: Menyer!
Bakgrund 4.8.1: Menyer
Innan vi går vidare med själva spelmekaniken är det dags att
vidareutveckla resten av det grafiska gränssnittet för spelet en
liten aning.
Att göra 4.8.1: Menyer
-
Lägg till menyer i spelet. Information om hur man gör detta
finns i föreläsningsanteckningarna. Se minst till att det finns
en meny med valet "Avsluta".
Implementera en händelsehanterare –
en ActionListener
eller en Action
,
enligt vad vi har diskuterat under föreläsningarna. Se på detta
sätt till att valet "avsluta" gör att en dialogruta visas med
hjälp av JOptionPane
, där man får bekräfta att man
vill sluta. Vid bekräftelse ska spelet avslutas med hjälp
av System.exit(0)
, som "stänger av" programmet med
felkod 0 = "inget fel".
4.9. En grafisk spelplan
Bakgrund 4.9.1: Grafik del 1 – komponenten
Det är nu dags att gå över från en textbaserad visning till en helt
grafisk.
Om du inte är van vid grafikprogrammering med Graphics2D i
Java: Vänta till du har varit på grafik-föreläsningen.
Att göra 4.9.1: Grafik del 1 – komponenten
Skapa en TetrisComponent
-klass som är subklass
till JComponent
.
Låt TetrisComponent
ha en pekare till det Board
som den visar.
Den behöver alltså ett fält som pekar på ett Board
, och den
behöver en konstruktor som tar ett Board
som parameter.
Implementera metoden getPreferredSize()
så att den
returnerar den storlek du helst vill ha för den grafiska
visaren. Enheten är pixlar. Om du vill anpassa
komponentstorleken till skärmstorleken har vi gått genom på
föreläsningarna hur man får fram skärmupplösningen. Tänk bara
på att TetrisComponent
-komponenten inte kan vara riktigt så
stor, eftersom menyer, knappar, ramar och annat också tar plats.
Grafik
Snygg grafik är inget vi premierar i den här labben.
Experimentera gärna, men spendera inte alltför mycket tid på det.
Enfärgade kvadrater med 1 pixels mellanrum duger gott när syftet är
att lära sig objektorientering!
Bakgrund 4.9.2: Grafik del 2 – utritningen
En JComponent
behöver kunna rita upp sig själv när som
helst, när Swings bakgrundstråd anropar den. Vi skall därför implementera metoden paintComponent()
så att den
ritar upp spelplanen som det ser ut just nu. Om metoden blir
stor kanske den behöver delas upp i delmetoder / hjälpmetoder
för att förbättra läsbarheten.
Tänk på att paintComponent()
behöver rita upp både bakgrunden,
de kvadrater som har ramlat ner på spelplanen, och det block (om
något) som håller på att ramla ner.
Fundera på hur paintComponent()
ska veta vilken färg
(java.awt.Color
) den ska använda för varje SquareType
.
Ett
sätt är att använda en switch-sats med en gren för varje
SquareType
.
-
Ett annat sätt är att den har en
EnumMap<SquareType,java.awt.Color>
som
lagrar mappningen.
En EnumMap
är en mappning, en uppslagningstabell liknande det som
kallas dictionaries i Python – i detta fall
med en SquareType
som nyckel och
en Color
som värde. I Java finns dock ingen
specialsyntax för mappningar, utan det är en klass som alla
andra, och objekten manipuleras med metoder
som put()
och get()
.
Med denna lösning kan din komponent till och med ta in
en EnumMap
som konstruktorparameter, så kan den
som skapar komponenten lätt konfigurera uppslagstabellen
uppslagningstabellen (och därmed färgerna) utifrån –
betydligt mer flexibelt än att hårdkoda en switchsats
i paintComponent()
.
paintComponent()
exempel
För att rita ut grafik används override på
funktionen paintComponent()
som ärvs
från JComponent
. Det kan t.ex. se ut så här:
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
final Graphics2D g2d = (Graphics2D) g;
g2d.setColor(Color.GREEN);
g2d.drawRect(a, b, c, d);
}
Här anropas super.paintComponent()
för att rensa bakgrunden i komponenten. I exemplet vill vi rita ut en grön rektangel med hjälp av ett Graphics2D
-objekt, men får ett Graphics
-objekt som inparameter. Därför castas detta först till Graphics2D
innan vi ritar ut rektangeln. Det finns många olika sätt att rita i en komponent och detta är bara ett exempel. Mer information finner du på föreläsningarna samt nätet.
Att göra 4.9.2: Grafik del 2 – utritningen
Implementera paintComponent()
enligt beskrivningen ovan.
Ändra i det tidigare GUIt så att TetrisComponent
nu används
istället för JTextArea
plus BoardToTextConverter
.
Ändra i testklassen, eller skriv en ny testklass, så att den
Timer
som introducerades tidigare ber din
TetrisComponent
att rita om sig istället för att manipulera den
textarea vi brukade använda. Slutmålet är att brädet ska
slumpas om med regelbundna mellanrum och att varje ny
konfiguration ska visas på skärmen i TetrisComponent
-komponenten
som finns i en JFrame
. För tillfället kan du be din
TetrisComponent
att rita om sig genom att timerhandlingen direkt
anropar repaint()
i TetrisComponent
-objekten. I en kommande
uppgift ska vi använda ett mer principiellt sätt att få
TetrisComponent
att uppdatera sig vid ändringar.
Magiska konstanter
Här är det läge att påpeka att du ska
undvika magiska
konstanter, i betydelsen "konstanter som används i koden utan
någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas
ut i en konstant storlek av 30x30 pixel är det bra att lägga
in namngivna konstanter som private final static int
SQUARE_WIDTH = 30;
i klassen och sedan använda denna konstant
varje gång du refererar till bredden. Då blir det mycket lättare
att läsa uttrycken och att förstå hur koden fungerar. (Vi använder
"final" för att konstanten inte ändras, "static" för att vi inte
behöver en kopia av konstanten för varje TetrisComponent
som
skapas.)
Speciellt svårt att läsa magiska konstanter är det om du gör
beräkningar som utgår från dem och sedan stoppar in de uträknade
värdena i koden. Till exempel är det oftare lättare att förstå
betydelsen hos "if (x > SQUARE_WIDTH - MARGIN)
" än
"if (x > 27)
", även för den som vet att SQUARE_WIDTH
är
30
och MARGIN
är 3
.
4.10. Observer / Observable
Bakgrund 4.10.1: Lyssnare och notifiering
I det program du nu har skrivit har du själva fått hitta på ett sätt
att få spelplanen att ritas om när något ändras. Det föreslagna
sättet har varit att en timerhandling som utförs med regelbundna
mellanrum både ser till att spelmodellen uppdateras (ett block
faller nedåt) och att TetrisComponent
därefter ritar om sig.
Problemet med den lösningen är att timerhandlingen måste känna
till precis vilka som vill veta när spelplanen ändrar sig. Vi
får därmed en alldeles för stark koppling mellan två klasser
(timerhandling och TetrisComponent
) som egentligen inte alls borde
behöva känna till varandra.
Ett annat sätt är att använda designmönstret Observer. I korthet
innebär detta att vi ser spelplanen som något "observerbart"
(Observable). Varje gång planen förändras på något sätt (t.ex. genom
att ett block faller ner ett steg) kan den själv informera en eller
flera "observatörer" (Observer). Varje observatör kan då själv
registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs
framåt ett steg är det sedan spelplanen som talar om för alla
intresserade att något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt intresse.
Vi har faktiskt redan sett en användning av detta mönster i Javas
GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare
är observatörer. Metoder som addActionListener()
lägger till en
lyssnare, en observatör, som är intresserad av en specifik typ av
handling. Klasser som JButton
känner inte till era specifika
lyssnare i förväg, men de har ett sätt för lyssnarna att registrera
sitt intresse.
Att göra 4.10.1: Lyssnare och notifiering
Skapa ett interface BoardListener
med en
metod public void boardChanged();
Lägg till i klassen Board
ett privat fält som innehåller en
lista av BoardListeners. Lägg också till en metod public void
addBoardListener(BoardListener bl)
som adderar den givna
lyssnaren till listan. I detta spel kommer vi inte att behöva
något sätt att ta bort lyssnare, även om man så klart kan lägga
till även en sådan metod för att vara mer fullständig.
Skapa i Board
en privat metod private void
notifyListeners()
som loopar över alla element i
lyssnarlistan och anropar deras boardChanged()
-metoder.
Se till att alla publika metoder i Board
som ändrar
på spelplanen anropar notifyListeners()
på slutet. Detta
inkluderar metoder som t.ex. ändrar koordinater på det block som
håller på att ramla ner. Du måste också se till att göra på
samma sätt i nya metoder du lägger till senare i labben.
Ändra TetrisComponent
så att den implementerar gränssnittet
BoardListener
. Implementera metoden boardChanged()
så att den
anropar repaint()
.
Se till att timerhandlingen som driver animeringen framåt
inte längre anropar repaint()
själv, som i tidigare lösning.
Ändra istället på era testklasser så att
TetrisComponent
-objektet du skapar adderas som en lyssnare på
spelplanen.
Resultat
Slutresultatet av denna ändring ska bli att allt på
skärmen ser ut precis som tidigare – men
att koden har en bättre struktur. Den är mer modulär i
och med att vi har minskat den starka kopplingen mellan
datalagring och utritning på skärmen. Detta är en mycket viktig
ändring.
4.11. Fallande block
Bakgrund 4.11.1: Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i
Tetris. Vi gör detta i flera steg.
I stället för att som tidigare att utnyttja timerhandlingen till att slumpa fram en helt ny
spelplan full av kvadrater ska den nu driva spelet ett steg framåt.
Det är dock en dålig idé att lägga spelmekanik och spelregler i
själva timerhandlingen – då skulle vi återigen blanda ihop två
helt olika typer av funktionalitet.
En bättre variant är att du skapar en tick()
-metod i Board
och
anropar den metoden från timerhandlingen. Metoden tick()
kan sedan
veta hur spelet drivs fram ett steg. Då hamnar kunskapen om spelets
regler i Board
, medan timerhandlingen används uteslutande för att
driva spelet framåt i rätt fart. (Ett alternativ kan vara att
separera spelmekaniken ytterligare och placera den i en
TetrisGame
-klass.)
Metoden tick()
behöver titta om spelplanen just nu innehåller ett
block (Poly
) som håller på att ramla ner. I så fall flyttar den det
blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T, L,
...) och placerar ett nytt block av den typen överst på spelplanen,
centrerat. Mer spelmekanik tillkommer senare.
Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite
mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!
Att göra 4.11.1: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället
för en framslumpad spelplan.
-
Implementera tick()
enligt beskrivningen ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block
slumpas fram och sedan faller nedåt tills det "faller av skärmen"
(och då fortsätter det falla i all oändlighet, även om detta inte
syns), eller orsakar ett undantag (Exception) beroende på implementationen. Att stoppa blocket kommer i nästa uppgift.
4.12. Ändlig skärmstorlek
Bakgrund 4.12.1: Stoppa fallande block
Nu är det dags att få blocken att stanna när det nått
botten av skärmen / spelplanen. Detta kräver så klart att du tittar
på det fallande blockets form och beräknar när den nedersta
kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går, så
blocket flyttas till nästa position.
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går
inte, så blocket är redan på positionen längst ner. Dess
kvadrater kopieras in till rätt koordinater på spelplanen och
själva blocket (Poly
) "tas bort". Nu finns alltså inget
fallande flyttbart block, men motsvarande kvadrater finns
inlagda "permanent" i spelplanen. Utåt ser det ut som att
spelet står still i ett tick – ingen skillnad syns.
tick()
. Metoden ser att det inte finns något block som
håller på att ramla ner, så ett nytt slumpas fram på översta
positionen.
tick()
. Som i första punkten...
Att göra 4.12.1: Stoppa fallande block
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod.
Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med
dess Extract
Method-refactoring.
Resultat
Resultatet i denna uppgift blir att nya block ständigt faller ända
ner till botten. Eftersom vi ännu inte har kollisionshantering
mellan blocken utan bara stoppar block vid botten av skärmen kommer
blocken att helt eller delvis ersätta varandra längst ner.
Kollisionshantering mellan block kommer i nästa uppgift.
Kodinspektion
Glöm inte att inspektera koden igen, om det var ett tag sedan du
gjorde det senast.
4.13. Kollisionshantering och "game over"
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att
ett block inte ska kunna "ramla förbi" ett annat utan att block ska
staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu
kvadrater omedelbart under sig.
tick()
. Blocket kan inte flyttas ett steg ned.
Hanteras som tidigare genom att lägga in kvadrater i arrayen.
tick()
. Det finns inget block som håller på att ramla
ner, så ett nytt slumpas fram. Kollisionshantering upptäcker
att detta block inte ens kan placeras på översta positionen, så
blocket placeras inte ut och gameOver
-flaggan sätts.
tick()
. Eftersom gameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta
blocket ett steg längre ner" enligt förra uppgiften. Nu räcker
det inte att titta om blocket vid en hypotetisk flytt till nästa
position skulle gå under kanten på skärmen. Du måste också
kolla om blocket vid en sådan hypotetisk flytt skulle krocka med
ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt
block slumpas fram måste spelet titta efter om det nya blocket
överhuvudtaget får plats på sin initialposition, centrerat längst
upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras
genom att en flagga sätts som gör att tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Vi vill gärna kunna visa spelplanen för att se om vi har gjort rätt. Istället för att gå direkt på grafiken kommer vi i ett första steg att köra textbaserat. Dels är detta enklare och gör att vi snabbt kan komma vidare, dels kan det vara användbart för debuggning av programmet, och dels är det ett steg i att demonstrera användningen av flera vyer i Model-View-Controller-mönstret och användningen av strängar i Java.
Vy-klassen som ska skapas behöver kunna få
reda på hur stort ett Board
är och vilken typ av kvadrat (om någon)
som finns på en viss position. Detta ska den så klart inte få reda
på genom att själv titta på fälten i Board
(de ska ju vara privata).
Istället krävs nya metoder i Board
. De metoderna ska bland annat
returnera information om en specifik position – returnerar du
hela arrayen kan den ju ändras utifrån, och du låser också fast dig
för hårt vid arrayrepresentationen.
-
Skapa getters för
width
ochheight
iBoard
samt en metod för att hämta enSquareType
ur en cell isquares
.
Javas strängar är oföränderliga: Man kan inte ändra på dem när de väl har skapats.
För att inkrementellt bygga upp en lång sträng använder man lämpligen
klassen StringBuilder
och dess append()
-metoder. För att lägga till en
radbrytning efter varje rad används "\n
" (backslash n). När
strängen är klar tas den fram som String
-objekt med
toString()
. Här är ett exempel:
// Create new StringBuilder.
StringBuilder builder = new StringBuilder();
// Loop and append values.
for (int i = 0; i < 5; i++) {
builder.append("abc ");
}
// Convert to string.
String result = builder.toString();
// Print result.
System.out.println(result);
Givet en
SquareType
behöver vi veta vilken symbol den ska illustreras med.
Man kunde tänka sig att detta skulle läggas in direkt i själva
SquareType
-objektet men då har vi blandat ihop modellen
(SquareType
) med ett specifikt sätt att visa modellen
på (text). Istället får vi låta vyn känna till
vilka SquareType
s som finns och ha en hjälpmetod som
returnerar symbolen motsvarande en viss SquareType
.
-
Skapa klassen
BoardToTextConverter
, med metodenconvertToText(Board)
som returnerar en strängrepresentation av ett modellobjekt – den skriver alltså inte ut på skärmen själv utan returnerar en sträng som anroparen kan skriva ut. Tomma rutor ska då representeras med t.ex. mellanslag eller bindestreck, och olika typer på kvadrater (SquareType
s) ska representeras som textsymboler, t.ex. "#
", "%
" och "-
".Du behöver en nästlad loop som itererar över rader och kolumner i lämplig ordning. Inuti loopen kan man använda en
switch
-sats med ettcase
för varjeSquareType
-värde, något som är möjligt på grund av attSquareType
är en enum. Det vill säga, om elementet på en viss position ärSquareType.EMPTY
lägger du till ett mellanslag, om elementet ärSquareType.I
lägger du till en annan symbol, osv.Glöm inte radbrytningarna
"\n"
mellan raderna.Metoden kan vara
static
eftersom den inte behöver tillgång till tillståndet i en specifikBoardToTextConverter
. -
Skriv en testklass,
BoardTest
, som skapar en tom spelplan, konverterar den till en sträng med hjälp avBoardToTextConverter
-klassen, och skriver ut resultatet. När det är klart har du åstadkommit ett testbart ramverk och kan se att du har gjort konkreta framsteg!
Bakgrund 4.3.1: Test
För vår testning vill vi också ha ett sätt att
slumpa fram en spelplan. Detta kan ligga i en separat hjälpklass
eller läggas in i klassen Board
.
Att göra 4.3.1: Test
-
Skapa en metod som kan användas för att generera en spelplan som har fast storlek men slumpmässigt innehåll.
Klassen
java.util.Random
och metodennextInt(n)
kan användas för att hitta ett pseudoslumptal mellan0
ochn-1
.Med hjälp av klassmetoden (statiska metoden)
SquareType.values()
får vi fram en array som innehåller alla värden i enum-typenSquareType
, vilket sedan kan indexeras med slumptalet. -
Ändra klassen
BoardTest
så att den slumpar fram och skriver ut 10 spelplaner. Testkör.
4.4. Tetrisblock / tetrominoes
Bakgrund 4.4.1: Polyominos
Efter spelplanen kommer själva Tetrisblocken. Det finns totalt 7
varianter i grundspelet, och vi kan tänkas vilja utöka detta på
olika sätt i framtiden – genom nya former (utökning till
pentominoes med fem kvadrater per block, penta=5) eller andra varianter
(block som exploderar). Frågan är då hur man ska implementera block
på bästa sätt för att tillåta sådana utökningar.
-
En generell abstrakt klass + en konkret subklass per blocktyp
(L, T, ...)? Det är så klart en möjlighet, men om vi tänker
lite närmare på spelet inser vi att blocken egentligen inte
beter sig olika. Därför skulle vi egentligen inte ha så mycket
nytta av de olika subklasserna – vi skulle till exempel
inte behöva "overrida" metoder från den generella klassen för
att utnyttja subtypspolymorfism, och alla klasser skulle behöva
samma fält för att lagra information. Det enda som skulle
skilja sig mellan klasserna är formen på varje block, och detta
är data, inte beteende.
Viktig regel: Skapa bara nya klasser om de verkligen har
olika beteende som ger större skillnad i koden. Skapa inte
nya klasser när det räcker med att några värden skiljer sig.
-
En enda klass? Det verkar vara ett bättre val. Och om
vi sedan vill implementera exploderande block kan vi göra det
med en enda ny subklass, snarare än sju olika
subklasser ("exploderande L", "exploderande O", osv).
Vi skapar därför en enda blockklass, som vi kan
kalla Poly
(kort för Polyomino).
Vi behöver ett sätt för varje Poly
-objekt att tala om
hur det ser ut. Detta har inte att göra med visualisering (pixlar
på skärmen) utan är en del av den fundamentala modellen för ett
block (till exempel avgör det hur långt ett block kan falla).
Därför ska det definitivt finnas med i
Poly
-klassen. Eftersom vi ska placera ut
Poly
-objekt på vårt Board
kan vi med
fördel representera denna på samma sätt. Vi väljer därför en
tvådimensionell array för vår Poly
.
Tanken är att klassen Poly
ska kunna användas för
vilken typ av block som helst, inklusive t.ex. block med 3 eller 5
kvadrater. Därför vill vi inte direkt i Poly
hårdkoda
kunskapen om exakt vilka blocktyper som finns. I stället låter vi
konstruktorn ta in en tvådimensionell array av kvadrater som
parameter. Den exakta storleken på arrayen bestäms då av den som
anropar konstruktorn.
Att göra 4.4.1: Polyominos
-
Implementera klassen Poly
enligt beskrivningen
ovan.
Klassens konstruktor ska alltså ta in en array som beskriver
konfigurationen hos en godtycklig polyomino. Sådana arrayer
kommer sedan att skickas in av TetrominoMaker
i
nästa uppgift – det är där de 7 specifika blocktyperna
definieras.
Bakgrund 4.4.2: TetrominoMaker
Vi behöver något sätt att skapa de sju sorternas Poly
.
För att uppnå detta kan vi skapa en separat
klass, TetrominoMaker
. Klassen kan t.ex. ha dessa
metoder:
public int getNumberOfTypes() { ... }
public Poly getPoly(int n) { ... }
En anropare kan då skapa en TetrominoMaker
och
anropa getNumberOfTypes()
för att fråga om hur många
blocktyper som den kan ge oss (just nu 7).
Anroparen kan sedan slumpa fram ett tal mellan 0
och getNumberOfTypes()-1
och anropa
objektets getPoly()
med detta som argument.
Metoden getPoly()
returnerar då ett
nytt Poly
-objekt av den givna typen.
getPoly()
gör detta genom att skapa en 2D-array av
lämplig storlek, fylla den med kvadrater motsvarande det begärda
blocket, skapa en ny Poly
för denna array, och
returnera det nya objektet.
Vilken storlek ska då arrayen ha? För att underlätta för
senare rotation av blocken är det en fördel att använda en
storlek på 2x2, 3x3 eller 4x4, beroende på vilken tetromino som den
innehåller. Arrayerna kan då skapas enligt vänstra kolumnen i
följande bild (där de övriga kolumnerna visar hur blocken ser ut när
de roteras, vilket vi kommer till senare). Blocket O ("kvadraten")
ser ut att ligga i en array med storlek 4x3, men för att förenkla det
för oss själva kan vi helt enkelt lägga det i storleken 2x2 istället.
Bilden kommer
från en beskrivning av det
standardiserade rotationssystemet.
Att göra 4.4.2: TetrominoMaker
-
Implementera klassen TetrominoMaker
enligt
beskrivningen ovan.
Tänk på att inte skapa för långa metoder –
om getPoly()
blir för lång när du lägger in
skapandet av alla blocktyper i den metoden kan du bryta ut
delar till sju olika hjälpmetoder istället.
Info: Fabriker och designmönster
En TetrominoMaker
är en fabrik (factory): Ett
objekt som kan skapa andra objekt och returnera dem till anroparen.
Man kan också bredda detta begrepp och kalla getPoly()
för en fabriksmetod (factory method).
Oftast ser man inte detta som ett objektorienterat
designmönster, även om det finns objektorienterade designmönster med
liknande namn, t.ex. factory method pattern
och abstract factory pattern. Dessa mönster bygger
istället på en specifik och mer komplicerad relation mellan olika
objekt och klasser. Att ha ett objekt som kan returnera andra
objekt räcker alltså inte för att man ska kunna säga att man
använder ett objektorienterat designmönster. (Eller omvänt: Det
behövs som tur är inget objektorienterat designmönster för att man
ska kunna göra något så enkelt som att returnera ett nytt objekt
från en metod!)
Bakgrund 4.4.3: Tetrisblock på spelplanen
Vår spelplan kan representera de
block som redan har fallit ner. Detta representeras som en array av
SquareType
s, vilket ger oss all information vi behöver för att rita
upp det som är kvar av de block som fallit ner. Nu behöver den
också kunna representera ett block som är på väg att falla
ner, så att all information om spelets nuvarande tillstånd sparas på
ett och samma ställe. Hur åstadkommer vi det?
Alternativ 1 är att de fyra kvadraterna
(SquareType
) från det fallande blocket läggs in i samma
tvådimensionella array som används för att lagra de block som redan
har fallit ner. Det har en fördel: Eftersom allt som ska ritas ut
ligger lagrat på samma sätt som förut, behöver vi inte behöver ändra
uppritningsfunktionen (BoardToTextConverter
). Å andra
sidan har vi en stor nackdel: När det fallande blocket flyttar sig
ett steg (eller roteras) måste vi komma ihåg var vi hade lagt in det,
ta bort dess SquareType
s från dessa positioner
i arrayen och lägga till dem igen på nya positioner. Detta kan bli
lite omständigt.
Alternativ 2 är att den tvådimensionella arrayen
i Board
bara lagrar de block som redan har fallit
färdigt. Det block som fortfarande faller får Board
hålla reda på genom att ha ett fält som pekar på en
fallande Poly
(eller null
om inget block
faller just nu) och två fält som anger dess x- och y-koordinater.
Då blir det lätt att flytta det fallande blocket genom att helt
enkelt ändra dess koordinater, utan att överhuvudtaget peta
i Board
:s tvådimensionella array. Det underlättar
också framtida utökningar där ett block inte nödvändigtvis faller
och roterar med ett "hopp" från ett läge till ett annat utan
animeras jämnt och mjukt. Å andra sidan behöver vi ändra
uppritningsfunktionen så den tar hänsyn till både de block som
fallit färdigt och det fallande blocket.
I den här labben ska det andra alternativet användas. Det
kräver nya fält och metoder i Board
samt utökningar
i BoardToTextConverter
.
Just nu (utan mjuka animeringar) gäller alltså för varje position
(x,y) som ska ritas ut (läggas till i textsträngen): (1) Om
positionen täcks av en kvadrat i ett fallande block gäller
denna SquareType
. (2) Annars gäller
den SquareType
som anges av Board
.
Att göra 4.4.3: Tetrisblock på spelplanen
-
Lägg till ett fält Poly falling
i Board
. Där ska du lagra en pekare till "den
poly som just nu håller på att ramla ner", eller null om ingen
poly faller just nu. Du behöver också ha koll på tetrominons
nuvarande position (x,y), så lägg till fält för detta. Lägg
slutligen till getters så att BoardToTextConverter
kan
få ut den fallande tetrominon och dess koordinater.
-
Ändra i BoardToTextConverter
så att även den
fallande tetrominon "ritas" ut (i textsträngen).
Nu finns det positioner (x,y) där det både finns en
ruta från spelplanen och en ruta från den fallande tetrominon.
I dessa fall är det alltid rutan från den fallande tetrominon,
inte rutan från spelplanen, som ska "skrivas ut" (läggas till
i strängen). Det vill säga, den fallande tetrominon ska
aldrig "döljas bakom" de rutor som redan ligger i spelplanen,
utan ska alltid synas. (När vi har spelmekaniken på plats
kommer den här typen av överlapp inte att kunna ske!)
4.5. Ett enkelt GUI
Bakgrund 4.5.1: Textgrafik
Det är nu dags att gå över från rent textformat till ett grafiskt
gränssnitt.
Om du inte är van vid GUI-programmering med Swing i Java: Vänta
till du har varit på GUI-föreläsningen.
För att förenkla steget in till ett GUI-program kommer du att börja
med att skriva ett grafiskt gränssnitt som använder en grafisk
textkomponent, en JTextArea
, för att visa den gamla
textrepresentationen av en "spelplan". Det verkar kanske som ett
lite underligt sätt, men det gör dels att du kan börja med GUIt
redan innan du lär dig hur man skapar egna komponenter, dels att du
får ett enklare "mellansteg" så att du inte behöver göra om hela
användargränssnittet på en gång. Det gör det också möjligt att göra
en väldigt enkel animering av t.ex. nedfallande block genom att du i
JTextArea
byter ut texten i varje steg, istället för att
som med System.out.println()
skriva ut varje ny
spelplan under den förra.
Att göra 4.5.1: Textgrafik
Skapa en nya klassen TetrisFrame
och gör den
till en subklass till Javas fönsterklass JFrame
.
På något sätt ska TetrisFrame
få tillgång till
ett Board
-objekt. Man kunde tänka sig
att TetrisFrame
själv skulle skapa
ett Board
, men det är mer flexibelt om man istället
skickar in ett Board
som parameter till
konstruktorn. Detta följer principen att varje klass ska ha en
väl avdelad uppgift att utföra och ska inte göra något som
ligger utanför den uppgiften! TetrisFrame
s uppgift
är att visa ett spelplan och låta oss interagera med det, inte
nödvändigtvis att skapa det.
När du skapar en subklass måste du alltid tänka på vilka
parametrar som du måste – eller vill – skicka med
till superklassens konstruktor. JFrame
har i och
för sig en konstruktor som saknar argument, så det går
att låta bli att göra något explicit anrop till superklassens
konstruktor från TetrisFrame
s konstruktor, men då
får vi ingen fönstertitel. Det är bättre att anropa
den JFrame
-konstruktor som tar emot en fönstertitel
som parameter. Konstruktorn till subklassen
TetrisFrame
behöver då ange den parametern genom
att först av allt anropa
t.ex. super("MyWindowTitle")
. Se mer information
om detta i föreläsningsbildernas diskussion om konstruktorer i
subklasser.
I konstruktorn till TetrisFrame
(eller i
metoder som anropas från konstruktorn) ska du sedan bygga upp
ett lämpligt användargränssnitt. Den viktigaste delen just nu
är den JTextArea
som ska användas till att visa
själva "spelplanen". Ange
i konstruktorn
till JTextArea
hur många kolumner och rader som
ska visas – detta avgör vilken preferred size
textarean ska ha. Antalet kolumner och rader får du reda på
genom att fråga det Board
som konstruktorn har fått
som parameter, inte genom att hårdkoda!
Hur hittar man vilka konstruktorer som finns? I IDEA, skriv
"new JTextArea(
" och tryck Ctrl-P. Då får du se
alla varianter på parametrar. Välj en av dem och tryck Ctrl-Q
så får du dokumentation för parametrarna.
Redan när en TetrisFrame
skapas måste textarean
ges sitt första innehåll. Här förutsätter vi att du har skrivit
en BoardToTextConverter
med en metod som returnerar en
sträng, inte en metod som skriver ut en spelplan direkt till
t.ex. System.out
. Då kan du använda den metoden för att få en
sträng motsvarande ett Board
, och sedan använda
textareans metod setText()
för att sätta rätt text.
Att uppdatera textarean när spelplanen ändras blir en senare
uppgift.
I övrigt behövs en del kod i konstruktorn för att definiera
layouten (inklusive att sätta fönstrets layouthanterare), se
till att fönstret visas, osv. Om du kommer hit innan
GUI-föreläsningen kan vi ge följande tips, utöver läroböckerna:
Layouten för ett fönster hanteras av
en layouthanterare, inte genom att man anger
koordinater för komponenter. En lämplig start kan vara att
fönstret sätter sin layouthanterare
med this.setLayout(new BorderLayout())
.
Fönstret kan sedan lägga till textarean
med this.add(textarea, BorderLayout.CENTER)
.
Detta placerar textarean "i mitten" i fönstret. Eftersom vi
inte har lagt till några andra komponenter i fönstret kommer
textarean att ta upp hela utrymmet i fönstret.
För att ge fönstret en storlek och göra det synligt:
frame.pack(); frame.setVisible(true);
Testklassen behöver ändras så att den skapar
ett Board
och öppnar
ett TetrisFrame
-fönster. När testet körs ska
slutresultatet vara att du ser en lagom
stor TetrisFrame
med en framslumpad spelplan (inte
10 spelplaner, som tidigare). Just nu finns ingen
händelsehantering (event handling), så du får använda
"stoppknappen" i utvecklingsmiljön för att stänga av
testprogrammet.
4.6. Timer för spelloop
Bakgrund 4.6.1: Timer
Så småningom behöver vi ha något sätt att driva spelet framåt, att
få block att falla ner i lagom takt och så vidare.
Ett inte alltför ovanligt misstag är att man lägger in en loop som
"stegar fram" ett steg, gör en paus av konstant längd, "stegar fram"
nästa steg, och så vidare. Problemet är att när pausen är
av konstant längd kommer tiden från starten av ett steg till starten
av nästa att variera, beroende på hur lång tid
själva steget tar, vilket är olika beroende på dator och
CPU-belastning på datorn. Vi får göra på något annat sätt.
Som tur är finns en klass som
heter javax.swing.Timer
som vi kan använda för att få ett "steg" i spelet att köras
regelbundet. Blanda inte ihop den med andra klasser som heter
Timer, t.ex. java.util.Timer
! Här ser vi en
anledning till att man lägger klasser i olika paket.
I den här uppgiften ska vi bara göra en enkel utforskning av
Timer
. Senare, när vi har gått vidare till en
fullständig grafisk komponent, kommer vi att titta närmare på
lämplig modellering av spelet som helhet och var spelloopen
egentligen ska placeras.
Det vi behöver veta kan vi då lära oss av följande exempel och
beskrivningen under.
final Action doOneStep = new AbstractAction() {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Timer clockTimer = new Timer(500, doOneStep);
clockTimer.setCoalesce(true);
clockTimer.start();
...
clockTimer.stop();
En Timer
ser alltså till att Swings egna händelsehanteringstråd
anropar en viss handling ("action") med jämna mellanrum. I detta fall är det
500 millisekunder mellan anropen, och det som anropas är den
handling som vi kallade doOneStep
. Genom att anropa
setCoalesce(true)
ser vi till att anropen inte "köas
upp" om ett visst anrop till doOneStep()
skulle ta för
lång tid. Vi kan starta timern när spelet börjar och stoppa den när
spelet är över.
I övrigt kan
även clockTimer.setLogTimers()
vara intressant för felsökning. Se även Javadoc-dokumentationen som
vi länkar till ovan!
Att göra 4.6.1: Timers
-
Skapa någonstans en Timer
som kör en handling med
regelbundna mellanrum, en gång i sekunden. Handlingen ska
slumpa om spelplanen (inte byta ut Board
-objektet utan ändra i
det!) på motsvarande sätt som du gjort tidigare, och visa
resultatet i textarean som du skapade i en tidigare uppgift.
Handlingen behöver alltså på något sätt få tillgång till både
spelplan och textarea. Slutresultatet ska helt enkelt bli att
du ser en enkel "animering" på skärmen medan programmet körs.
Du behöver inte kunna stoppa timern.
4.7. Kodinspektion!
Bakgrund 4.7.1: Kodinspektion
Det är viktigt att man granskar sin egen kod då och då, för att
eventuella problem inte ska bli för långlivade. Ju tidigare man kan
förbättra koden, desto längre tid har man att utnyttja de
förbättringarna. Det gäller ännu mer i den här kursen, när ni
själva håller på att lära er objektorientering och Java.
Att göra 4.7.1: Kodinspektion
-
Om du normalt använder en annan miljö: Importera ditt projekt
till IDEA. Det behöver du ändå kunna göra inför inlämningen.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100%
korrekt. Varningen ska alltså oftast tolkas som "har du tänkt
på det här?" snarare än "du har fel!". Så småningom måste
sådana kommenteras på plats i koden, med god motivering till
varför varningen var "ogiltig" i detta fall. Vissa varningar
kanske du vill kommentera redan nu. Se
även kvalitetskriterierna.
Det kan även finnas varningar som kommer från att du helt
enkelt inte är klar än, t.ex. varningar för kod som inte
används. De varningarna kan du så klart ignorera i det här
läget.
Saknar du inspektionsprofilen? Om du kör på universitetets
datorer, och installerade IDEAs konfigurationsfiler korrekt,
borde profilen finnas där. Felanmäl till oss! Om du kör
hemma behöver du ladda ner konfigurationsfilerna.
Se slutet
av kvalitetskriterierna.
4.8. Mer GUI-programmering: Menyer!
Bakgrund 4.8.1: Menyer
Innan vi går vidare med själva spelmekaniken är det dags att
vidareutveckla resten av det grafiska gränssnittet för spelet en
liten aning.
Att göra 4.8.1: Menyer
-
Lägg till menyer i spelet. Information om hur man gör detta
finns i föreläsningsanteckningarna. Se minst till att det finns
en meny med valet "Avsluta".
Implementera en händelsehanterare –
en ActionListener
eller en Action
,
enligt vad vi har diskuterat under föreläsningarna. Se på detta
sätt till att valet "avsluta" gör att en dialogruta visas med
hjälp av JOptionPane
, där man får bekräfta att man
vill sluta. Vid bekräftelse ska spelet avslutas med hjälp
av System.exit(0)
, som "stänger av" programmet med
felkod 0 = "inget fel".
4.9. En grafisk spelplan
Bakgrund 4.9.1: Grafik del 1 – komponenten
Det är nu dags att gå över från en textbaserad visning till en helt
grafisk.
Om du inte är van vid grafikprogrammering med Graphics2D i
Java: Vänta till du har varit på grafik-föreläsningen.
Att göra 4.9.1: Grafik del 1 – komponenten
Skapa en TetrisComponent
-klass som är subklass
till JComponent
.
Låt TetrisComponent
ha en pekare till det Board
som den visar.
Den behöver alltså ett fält som pekar på ett Board
, och den
behöver en konstruktor som tar ett Board
som parameter.
Implementera metoden getPreferredSize()
så att den
returnerar den storlek du helst vill ha för den grafiska
visaren. Enheten är pixlar. Om du vill anpassa
komponentstorleken till skärmstorleken har vi gått genom på
föreläsningarna hur man får fram skärmupplösningen. Tänk bara
på att TetrisComponent
-komponenten inte kan vara riktigt så
stor, eftersom menyer, knappar, ramar och annat också tar plats.
Grafik
Snygg grafik är inget vi premierar i den här labben.
Experimentera gärna, men spendera inte alltför mycket tid på det.
Enfärgade kvadrater med 1 pixels mellanrum duger gott när syftet är
att lära sig objektorientering!
Bakgrund 4.9.2: Grafik del 2 – utritningen
En JComponent
behöver kunna rita upp sig själv när som
helst, när Swings bakgrundstråd anropar den. Vi skall därför implementera metoden paintComponent()
så att den
ritar upp spelplanen som det ser ut just nu. Om metoden blir
stor kanske den behöver delas upp i delmetoder / hjälpmetoder
för att förbättra läsbarheten.
Tänk på att paintComponent()
behöver rita upp både bakgrunden,
de kvadrater som har ramlat ner på spelplanen, och det block (om
något) som håller på att ramla ner.
Fundera på hur paintComponent()
ska veta vilken färg
(java.awt.Color
) den ska använda för varje SquareType
.
Ett
sätt är att använda en switch-sats med en gren för varje
SquareType
.
-
Ett annat sätt är att den har en
EnumMap<SquareType,java.awt.Color>
som
lagrar mappningen.
En EnumMap
är en mappning, en uppslagningstabell liknande det som
kallas dictionaries i Python – i detta fall
med en SquareType
som nyckel och
en Color
som värde. I Java finns dock ingen
specialsyntax för mappningar, utan det är en klass som alla
andra, och objekten manipuleras med metoder
som put()
och get()
.
Med denna lösning kan din komponent till och med ta in
en EnumMap
som konstruktorparameter, så kan den
som skapar komponenten lätt konfigurera uppslagstabellen
uppslagningstabellen (och därmed färgerna) utifrån –
betydligt mer flexibelt än att hårdkoda en switchsats
i paintComponent()
.
paintComponent()
exempel
För att rita ut grafik används override på
funktionen paintComponent()
som ärvs
från JComponent
. Det kan t.ex. se ut så här:
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
final Graphics2D g2d = (Graphics2D) g;
g2d.setColor(Color.GREEN);
g2d.drawRect(a, b, c, d);
}
Här anropas super.paintComponent()
för att rensa bakgrunden i komponenten. I exemplet vill vi rita ut en grön rektangel med hjälp av ett Graphics2D
-objekt, men får ett Graphics
-objekt som inparameter. Därför castas detta först till Graphics2D
innan vi ritar ut rektangeln. Det finns många olika sätt att rita i en komponent och detta är bara ett exempel. Mer information finner du på föreläsningarna samt nätet.
Att göra 4.9.2: Grafik del 2 – utritningen
Implementera paintComponent()
enligt beskrivningen ovan.
Ändra i det tidigare GUIt så att TetrisComponent
nu används
istället för JTextArea
plus BoardToTextConverter
.
Ändra i testklassen, eller skriv en ny testklass, så att den
Timer
som introducerades tidigare ber din
TetrisComponent
att rita om sig istället för att manipulera den
textarea vi brukade använda. Slutmålet är att brädet ska
slumpas om med regelbundna mellanrum och att varje ny
konfiguration ska visas på skärmen i TetrisComponent
-komponenten
som finns i en JFrame
. För tillfället kan du be din
TetrisComponent
att rita om sig genom att timerhandlingen direkt
anropar repaint()
i TetrisComponent
-objekten. I en kommande
uppgift ska vi använda ett mer principiellt sätt att få
TetrisComponent
att uppdatera sig vid ändringar.
Magiska konstanter
Här är det läge att påpeka att du ska
undvika magiska
konstanter, i betydelsen "konstanter som används i koden utan
någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas
ut i en konstant storlek av 30x30 pixel är det bra att lägga
in namngivna konstanter som private final static int
SQUARE_WIDTH = 30;
i klassen och sedan använda denna konstant
varje gång du refererar till bredden. Då blir det mycket lättare
att läsa uttrycken och att förstå hur koden fungerar. (Vi använder
"final" för att konstanten inte ändras, "static" för att vi inte
behöver en kopia av konstanten för varje TetrisComponent
som
skapas.)
Speciellt svårt att läsa magiska konstanter är det om du gör
beräkningar som utgår från dem och sedan stoppar in de uträknade
värdena i koden. Till exempel är det oftare lättare att förstå
betydelsen hos "if (x > SQUARE_WIDTH - MARGIN)
" än
"if (x > 27)
", även för den som vet att SQUARE_WIDTH
är
30
och MARGIN
är 3
.
4.10. Observer / Observable
Bakgrund 4.10.1: Lyssnare och notifiering
I det program du nu har skrivit har du själva fått hitta på ett sätt
att få spelplanen att ritas om när något ändras. Det föreslagna
sättet har varit att en timerhandling som utförs med regelbundna
mellanrum både ser till att spelmodellen uppdateras (ett block
faller nedåt) och att TetrisComponent
därefter ritar om sig.
Problemet med den lösningen är att timerhandlingen måste känna
till precis vilka som vill veta när spelplanen ändrar sig. Vi
får därmed en alldeles för stark koppling mellan två klasser
(timerhandling och TetrisComponent
) som egentligen inte alls borde
behöva känna till varandra.
Ett annat sätt är att använda designmönstret Observer. I korthet
innebär detta att vi ser spelplanen som något "observerbart"
(Observable). Varje gång planen förändras på något sätt (t.ex. genom
att ett block faller ner ett steg) kan den själv informera en eller
flera "observatörer" (Observer). Varje observatör kan då själv
registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs
framåt ett steg är det sedan spelplanen som talar om för alla
intresserade att något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt intresse.
Vi har faktiskt redan sett en användning av detta mönster i Javas
GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare
är observatörer. Metoder som addActionListener()
lägger till en
lyssnare, en observatör, som är intresserad av en specifik typ av
handling. Klasser som JButton
känner inte till era specifika
lyssnare i förväg, men de har ett sätt för lyssnarna att registrera
sitt intresse.
Att göra 4.10.1: Lyssnare och notifiering
Skapa ett interface BoardListener
med en
metod public void boardChanged();
Lägg till i klassen Board
ett privat fält som innehåller en
lista av BoardListeners. Lägg också till en metod public void
addBoardListener(BoardListener bl)
som adderar den givna
lyssnaren till listan. I detta spel kommer vi inte att behöva
något sätt att ta bort lyssnare, även om man så klart kan lägga
till även en sådan metod för att vara mer fullständig.
Skapa i Board
en privat metod private void
notifyListeners()
som loopar över alla element i
lyssnarlistan och anropar deras boardChanged()
-metoder.
Se till att alla publika metoder i Board
som ändrar
på spelplanen anropar notifyListeners()
på slutet. Detta
inkluderar metoder som t.ex. ändrar koordinater på det block som
håller på att ramla ner. Du måste också se till att göra på
samma sätt i nya metoder du lägger till senare i labben.
Ändra TetrisComponent
så att den implementerar gränssnittet
BoardListener
. Implementera metoden boardChanged()
så att den
anropar repaint()
.
Se till att timerhandlingen som driver animeringen framåt
inte längre anropar repaint()
själv, som i tidigare lösning.
Ändra istället på era testklasser så att
TetrisComponent
-objektet du skapar adderas som en lyssnare på
spelplanen.
Resultat
Slutresultatet av denna ändring ska bli att allt på
skärmen ser ut precis som tidigare – men
att koden har en bättre struktur. Den är mer modulär i
och med att vi har minskat den starka kopplingen mellan
datalagring och utritning på skärmen. Detta är en mycket viktig
ändring.
4.11. Fallande block
Bakgrund 4.11.1: Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i
Tetris. Vi gör detta i flera steg.
I stället för att som tidigare att utnyttja timerhandlingen till att slumpa fram en helt ny
spelplan full av kvadrater ska den nu driva spelet ett steg framåt.
Det är dock en dålig idé att lägga spelmekanik och spelregler i
själva timerhandlingen – då skulle vi återigen blanda ihop två
helt olika typer av funktionalitet.
En bättre variant är att du skapar en tick()
-metod i Board
och
anropar den metoden från timerhandlingen. Metoden tick()
kan sedan
veta hur spelet drivs fram ett steg. Då hamnar kunskapen om spelets
regler i Board
, medan timerhandlingen används uteslutande för att
driva spelet framåt i rätt fart. (Ett alternativ kan vara att
separera spelmekaniken ytterligare och placera den i en
TetrisGame
-klass.)
Metoden tick()
behöver titta om spelplanen just nu innehåller ett
block (Poly
) som håller på att ramla ner. I så fall flyttar den det
blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T, L,
...) och placerar ett nytt block av den typen överst på spelplanen,
centrerat. Mer spelmekanik tillkommer senare.
Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite
mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!
Att göra 4.11.1: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället
för en framslumpad spelplan.
-
Implementera tick()
enligt beskrivningen ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block
slumpas fram och sedan faller nedåt tills det "faller av skärmen"
(och då fortsätter det falla i all oändlighet, även om detta inte
syns), eller orsakar ett undantag (Exception) beroende på implementationen. Att stoppa blocket kommer i nästa uppgift.
4.12. Ändlig skärmstorlek
Bakgrund 4.12.1: Stoppa fallande block
Nu är det dags att få blocken att stanna när det nått
botten av skärmen / spelplanen. Detta kräver så klart att du tittar
på det fallande blockets form och beräknar när den nedersta
kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går, så
blocket flyttas till nästa position.
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går
inte, så blocket är redan på positionen längst ner. Dess
kvadrater kopieras in till rätt koordinater på spelplanen och
själva blocket (Poly
) "tas bort". Nu finns alltså inget
fallande flyttbart block, men motsvarande kvadrater finns
inlagda "permanent" i spelplanen. Utåt ser det ut som att
spelet står still i ett tick – ingen skillnad syns.
tick()
. Metoden ser att det inte finns något block som
håller på att ramla ner, så ett nytt slumpas fram på översta
positionen.
tick()
. Som i första punkten...
Att göra 4.12.1: Stoppa fallande block
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod.
Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med
dess Extract
Method-refactoring.
Resultat
Resultatet i denna uppgift blir att nya block ständigt faller ända
ner till botten. Eftersom vi ännu inte har kollisionshantering
mellan blocken utan bara stoppar block vid botten av skärmen kommer
blocken att helt eller delvis ersätta varandra längst ner.
Kollisionshantering mellan block kommer i nästa uppgift.
Kodinspektion
Glöm inte att inspektera koden igen, om det var ett tag sedan du
gjorde det senast.
4.13. Kollisionshantering och "game over"
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att
ett block inte ska kunna "ramla förbi" ett annat utan att block ska
staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu
kvadrater omedelbart under sig.
tick()
. Blocket kan inte flyttas ett steg ned.
Hanteras som tidigare genom att lägga in kvadrater i arrayen.
tick()
. Det finns inget block som håller på att ramla
ner, så ett nytt slumpas fram. Kollisionshantering upptäcker
att detta block inte ens kan placeras på översta positionen, så
blocket placeras inte ut och gameOver
-flaggan sätts.
tick()
. Eftersom gameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta
blocket ett steg längre ner" enligt förra uppgiften. Nu räcker
det inte att titta om blocket vid en hypotetisk flytt till nästa
position skulle gå under kanten på skärmen. Du måste också
kolla om blocket vid en sådan hypotetisk flytt skulle krocka med
ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt
block slumpas fram måste spelet titta efter om det nya blocket
överhuvudtaget får plats på sin initialposition, centrerat längst
upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras
genom att en flagga sätts som gör att tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Efter spelplanen kommer själva Tetrisblocken. Det finns totalt 7 varianter i grundspelet, och vi kan tänkas vilja utöka detta på olika sätt i framtiden – genom nya former (utökning till pentominoes med fem kvadrater per block, penta=5) eller andra varianter (block som exploderar). Frågan är då hur man ska implementera block på bästa sätt för att tillåta sådana utökningar.
-
En generell abstrakt klass + en konkret subklass per blocktyp (L, T, ...)? Det är så klart en möjlighet, men om vi tänker lite närmare på spelet inser vi att blocken egentligen inte beter sig olika. Därför skulle vi egentligen inte ha så mycket nytta av de olika subklasserna – vi skulle till exempel inte behöva "overrida" metoder från den generella klassen för att utnyttja subtypspolymorfism, och alla klasser skulle behöva samma fält för att lagra information. Det enda som skulle skilja sig mellan klasserna är formen på varje block, och detta är data, inte beteende.
Viktig regel: Skapa bara nya klasser om de verkligen har olika beteende som ger större skillnad i koden. Skapa inte nya klasser när det räcker med att några värden skiljer sig.
-
En enda klass? Det verkar vara ett bättre val. Och om vi sedan vill implementera exploderande block kan vi göra det med en enda ny subklass, snarare än sju olika subklasser ("exploderande L", "exploderande O", osv).
Vi skapar därför en enda blockklass, som vi kan
kalla Poly
(kort för Polyomino).
Vi behöver ett sätt för varje Poly
-objekt att tala om
hur det ser ut. Detta har inte att göra med visualisering (pixlar
på skärmen) utan är en del av den fundamentala modellen för ett
block (till exempel avgör det hur långt ett block kan falla).
Därför ska det definitivt finnas med i
Poly
-klassen. Eftersom vi ska placera ut
Poly
-objekt på vårt Board
kan vi med
fördel representera denna på samma sätt. Vi väljer därför en
tvådimensionell array för vår Poly
.
Tanken är att klassen Poly
ska kunna användas för
vilken typ av block som helst, inklusive t.ex. block med 3 eller 5
kvadrater. Därför vill vi inte direkt i Poly
hårdkoda
kunskapen om exakt vilka blocktyper som finns. I stället låter vi
konstruktorn ta in en tvådimensionell array av kvadrater som
parameter. Den exakta storleken på arrayen bestäms då av den som
anropar konstruktorn.
-
Implementera klassen
Poly
enligt beskrivningen ovan.Klassens konstruktor ska alltså ta in en array som beskriver konfigurationen hos en godtycklig polyomino. Sådana arrayer kommer sedan att skickas in av
TetrominoMaker
i nästa uppgift – det är där de 7 specifika blocktyperna definieras.
Vi behöver något sätt att skapa de sju sorternas Poly
.
För att uppnå detta kan vi skapa en separat
klass, TetrominoMaker
. Klassen kan t.ex. ha dessa
metoder:
public int getNumberOfTypes() { ... }
public Poly getPoly(int n) { ... }
En anropare kan då skapa en TetrominoMaker
och
anropa getNumberOfTypes()
för att fråga om hur många
blocktyper som den kan ge oss (just nu 7).
Anroparen kan sedan slumpa fram ett tal mellan 0
och getNumberOfTypes()-1
och anropa
objektets getPoly()
med detta som argument.
Metoden getPoly()
returnerar då ett
nytt Poly
-objekt av den givna typen.
getPoly()
gör detta genom att skapa en 2D-array av
lämplig storlek, fylla den med kvadrater motsvarande det begärda
blocket, skapa en ny Poly
för denna array, och
returnera det nya objektet.
Vilken storlek ska då arrayen ha? För att underlätta för senare rotation av blocken är det en fördel att använda en storlek på 2x2, 3x3 eller 4x4, beroende på vilken tetromino som den innehåller. Arrayerna kan då skapas enligt vänstra kolumnen i följande bild (där de övriga kolumnerna visar hur blocken ser ut när de roteras, vilket vi kommer till senare). Blocket O ("kvadraten") ser ut att ligga i en array med storlek 4x3, men för att förenkla det för oss själva kan vi helt enkelt lägga det i storleken 2x2 istället.
Bilden kommer från en beskrivning av det standardiserade rotationssystemet.
-
Implementera klassen
TetrominoMaker
enligt beskrivningen ovan.Tänk på att inte skapa för långa metoder – om
getPoly()
blir för lång när du lägger in skapandet av alla blocktyper i den metoden kan du bryta ut delar till sju olika hjälpmetoder istället.
En TetrominoMaker
är en fabrik (factory): Ett
objekt som kan skapa andra objekt och returnera dem till anroparen.
Man kan också bredda detta begrepp och kalla getPoly()
för en fabriksmetod (factory method).
Oftast ser man inte detta som ett objektorienterat designmönster, även om det finns objektorienterade designmönster med liknande namn, t.ex. factory method pattern och abstract factory pattern. Dessa mönster bygger istället på en specifik och mer komplicerad relation mellan olika objekt och klasser. Att ha ett objekt som kan returnera andra objekt räcker alltså inte för att man ska kunna säga att man använder ett objektorienterat designmönster. (Eller omvänt: Det behövs som tur är inget objektorienterat designmönster för att man ska kunna göra något så enkelt som att returnera ett nytt objekt från en metod!)
Vår spelplan kan representera de
block som redan har fallit ner. Detta representeras som en array av
SquareType
s, vilket ger oss all information vi behöver för att rita
upp det som är kvar av de block som fallit ner. Nu behöver den
också kunna representera ett block som är på väg att falla
ner, så att all information om spelets nuvarande tillstånd sparas på
ett och samma ställe. Hur åstadkommer vi det?
Alternativ 1 är att de fyra kvadraterna
(SquareType
) från det fallande blocket läggs in i samma
tvådimensionella array som används för att lagra de block som redan
har fallit ner. Det har en fördel: Eftersom allt som ska ritas ut
ligger lagrat på samma sätt som förut, behöver vi inte behöver ändra
uppritningsfunktionen (BoardToTextConverter
). Å andra
sidan har vi en stor nackdel: När det fallande blocket flyttar sig
ett steg (eller roteras) måste vi komma ihåg var vi hade lagt in det,
ta bort dess SquareType
s från dessa positioner
i arrayen och lägga till dem igen på nya positioner. Detta kan bli
lite omständigt.
Alternativ 2 är att den tvådimensionella arrayen
i Board
bara lagrar de block som redan har fallit
färdigt. Det block som fortfarande faller får Board
hålla reda på genom att ha ett fält som pekar på en
fallande Poly
(eller null
om inget block
faller just nu) och två fält som anger dess x- och y-koordinater.
Då blir det lätt att flytta det fallande blocket genom att helt
enkelt ändra dess koordinater, utan att överhuvudtaget peta
i Board
:s tvådimensionella array. Det underlättar
också framtida utökningar där ett block inte nödvändigtvis faller
och roterar med ett "hopp" från ett läge till ett annat utan
animeras jämnt och mjukt. Å andra sidan behöver vi ändra
uppritningsfunktionen så den tar hänsyn till både de block som
fallit färdigt och det fallande blocket.
I den här labben ska det andra alternativet användas. Det
kräver nya fält och metoder i Board
samt utökningar
i BoardToTextConverter
.
Just nu (utan mjuka animeringar) gäller alltså för varje position
(x,y) som ska ritas ut (läggas till i textsträngen): (1) Om
positionen täcks av en kvadrat i ett fallande block gäller
denna SquareType
. (2) Annars gäller
den SquareType
som anges av Board
.
-
Lägg till ett fält
Poly falling
iBoard
. Där ska du lagra en pekare till "den poly som just nu håller på att ramla ner", eller null om ingen poly faller just nu. Du behöver också ha koll på tetrominons nuvarande position (x,y), så lägg till fält för detta. Lägg slutligen till getters så attBoardToTextConverter
kan få ut den fallande tetrominon och dess koordinater. -
Ändra i
BoardToTextConverter
så att även den fallande tetrominon "ritas" ut (i textsträngen).Nu finns det positioner (x,y) där det både finns en ruta från spelplanen och en ruta från den fallande tetrominon. I dessa fall är det alltid rutan från den fallande tetrominon, inte rutan från spelplanen, som ska "skrivas ut" (läggas till i strängen). Det vill säga, den fallande tetrominon ska aldrig "döljas bakom" de rutor som redan ligger i spelplanen, utan ska alltid synas. (När vi har spelmekaniken på plats kommer den här typen av överlapp inte att kunna ske!)
Bakgrund 4.5.1: Textgrafik
Det är nu dags att gå över från rent textformat till ett grafiskt gränssnitt.
Om du inte är van vid GUI-programmering med Swing i Java: Vänta till du har varit på GUI-föreläsningen.
För att förenkla steget in till ett GUI-program kommer du att börja
med att skriva ett grafiskt gränssnitt som använder en grafisk
textkomponent, en JTextArea
, för att visa den gamla
textrepresentationen av en "spelplan". Det verkar kanske som ett
lite underligt sätt, men det gör dels att du kan börja med GUIt
redan innan du lär dig hur man skapar egna komponenter, dels att du
får ett enklare "mellansteg" så att du inte behöver göra om hela
användargränssnittet på en gång. Det gör det också möjligt att göra
en väldigt enkel animering av t.ex. nedfallande block genom att du i
JTextArea
byter ut texten i varje steg, istället för att
som med System.out.println()
skriva ut varje ny
spelplan under den förra.
Att göra 4.5.1: Textgrafik
Skapa en nya klassen
TetrisFrame
och gör den till en subklass till Javas fönsterklassJFrame
.På något sätt ska
TetrisFrame
få tillgång till ettBoard
-objekt. Man kunde tänka sig attTetrisFrame
själv skulle skapa ettBoard
, men det är mer flexibelt om man istället skickar in ettBoard
som parameter till konstruktorn. Detta följer principen att varje klass ska ha en väl avdelad uppgift att utföra och ska inte göra något som ligger utanför den uppgiften!TetrisFrame
s uppgift är att visa ett spelplan och låta oss interagera med det, inte nödvändigtvis att skapa det.När du skapar en subklass måste du alltid tänka på vilka parametrar som du måste – eller vill – skicka med till superklassens konstruktor.
JFrame
har i och för sig en konstruktor som saknar argument, så det går att låta bli att göra något explicit anrop till superklassens konstruktor frånTetrisFrame
s konstruktor, men då får vi ingen fönstertitel. Det är bättre att anropa denJFrame
-konstruktor som tar emot en fönstertitel som parameter. Konstruktorn till subklassenTetrisFrame
behöver då ange den parametern genom att först av allt anropa t.ex.super("MyWindowTitle")
. Se mer information om detta i föreläsningsbildernas diskussion om konstruktorer i subklasser.I konstruktorn till
TetrisFrame
(eller i metoder som anropas från konstruktorn) ska du sedan bygga upp ett lämpligt användargränssnitt. Den viktigaste delen just nu är denJTextArea
som ska användas till att visa själva "spelplanen". Ange i konstruktorn tillJTextArea
hur många kolumner och rader som ska visas – detta avgör vilken preferred size textarean ska ha. Antalet kolumner och rader får du reda på genom att fråga detBoard
som konstruktorn har fått som parameter, inte genom att hårdkoda!Hur hittar man vilka konstruktorer som finns? I IDEA, skriv "
new JTextArea(
" och tryck Ctrl-P. Då får du se alla varianter på parametrar. Välj en av dem och tryck Ctrl-Q så får du dokumentation för parametrarna.Redan när en
TetrisFrame
skapas måste textarean ges sitt första innehåll. Här förutsätter vi att du har skrivit enBoardToTextConverter
med en metod som returnerar en sträng, inte en metod som skriver ut en spelplan direkt till t.ex.System.out
. Då kan du använda den metoden för att få en sträng motsvarande ettBoard
, och sedan använda textareans metodsetText()
för att sätta rätt text. Att uppdatera textarean när spelplanen ändras blir en senare uppgift.I övrigt behövs en del kod i konstruktorn för att definiera layouten (inklusive att sätta fönstrets layouthanterare), se till att fönstret visas, osv. Om du kommer hit innan GUI-föreläsningen kan vi ge följande tips, utöver läroböckerna:
Layouten för ett fönster hanteras av en layouthanterare, inte genom att man anger koordinater för komponenter. En lämplig start kan vara att fönstret sätter sin layouthanterare med
this.setLayout(new BorderLayout())
.Fönstret kan sedan lägga till textarean med
this.add(textarea, BorderLayout.CENTER)
. Detta placerar textarean "i mitten" i fönstret. Eftersom vi inte har lagt till några andra komponenter i fönstret kommer textarean att ta upp hela utrymmet i fönstret.För att ge fönstret en storlek och göra det synligt:
frame.pack(); frame.setVisible(true);
Testklassen behöver ändras så att den skapar ett
Board
och öppnar ettTetrisFrame
-fönster. När testet körs ska slutresultatet vara att du ser en lagom storTetrisFrame
med en framslumpad spelplan (inte 10 spelplaner, som tidigare). Just nu finns ingen händelsehantering (event handling), så du får använda "stoppknappen" i utvecklingsmiljön för att stänga av testprogrammet.
4.6. Timer för spelloop
Bakgrund 4.6.1: Timer
Så småningom behöver vi ha något sätt att driva spelet framåt, att
få block att falla ner i lagom takt och så vidare.
Ett inte alltför ovanligt misstag är att man lägger in en loop som
"stegar fram" ett steg, gör en paus av konstant längd, "stegar fram"
nästa steg, och så vidare. Problemet är att när pausen är
av konstant längd kommer tiden från starten av ett steg till starten
av nästa att variera, beroende på hur lång tid
själva steget tar, vilket är olika beroende på dator och
CPU-belastning på datorn. Vi får göra på något annat sätt.
Som tur är finns en klass som
heter javax.swing.Timer
som vi kan använda för att få ett "steg" i spelet att köras
regelbundet. Blanda inte ihop den med andra klasser som heter
Timer, t.ex. java.util.Timer
! Här ser vi en
anledning till att man lägger klasser i olika paket.
I den här uppgiften ska vi bara göra en enkel utforskning av
Timer
. Senare, när vi har gått vidare till en
fullständig grafisk komponent, kommer vi att titta närmare på
lämplig modellering av spelet som helhet och var spelloopen
egentligen ska placeras.
Det vi behöver veta kan vi då lära oss av följande exempel och
beskrivningen under.
final Action doOneStep = new AbstractAction() {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Timer clockTimer = new Timer(500, doOneStep);
clockTimer.setCoalesce(true);
clockTimer.start();
...
clockTimer.stop();
En Timer
ser alltså till att Swings egna händelsehanteringstråd
anropar en viss handling ("action") med jämna mellanrum. I detta fall är det
500 millisekunder mellan anropen, och det som anropas är den
handling som vi kallade doOneStep
. Genom att anropa
setCoalesce(true)
ser vi till att anropen inte "köas
upp" om ett visst anrop till doOneStep()
skulle ta för
lång tid. Vi kan starta timern när spelet börjar och stoppa den när
spelet är över.
I övrigt kan
även clockTimer.setLogTimers()
vara intressant för felsökning. Se även Javadoc-dokumentationen som
vi länkar till ovan!
Att göra 4.6.1: Timers
-
Skapa någonstans en Timer
som kör en handling med
regelbundna mellanrum, en gång i sekunden. Handlingen ska
slumpa om spelplanen (inte byta ut Board
-objektet utan ändra i
det!) på motsvarande sätt som du gjort tidigare, och visa
resultatet i textarean som du skapade i en tidigare uppgift.
Handlingen behöver alltså på något sätt få tillgång till både
spelplan och textarea. Slutresultatet ska helt enkelt bli att
du ser en enkel "animering" på skärmen medan programmet körs.
Du behöver inte kunna stoppa timern.
4.7. Kodinspektion!
Bakgrund 4.7.1: Kodinspektion
Det är viktigt att man granskar sin egen kod då och då, för att
eventuella problem inte ska bli för långlivade. Ju tidigare man kan
förbättra koden, desto längre tid har man att utnyttja de
förbättringarna. Det gäller ännu mer i den här kursen, när ni
själva håller på att lära er objektorientering och Java.
Att göra 4.7.1: Kodinspektion
-
Om du normalt använder en annan miljö: Importera ditt projekt
till IDEA. Det behöver du ändå kunna göra inför inlämningen.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100%
korrekt. Varningen ska alltså oftast tolkas som "har du tänkt
på det här?" snarare än "du har fel!". Så småningom måste
sådana kommenteras på plats i koden, med god motivering till
varför varningen var "ogiltig" i detta fall. Vissa varningar
kanske du vill kommentera redan nu. Se
även kvalitetskriterierna.
Det kan även finnas varningar som kommer från att du helt
enkelt inte är klar än, t.ex. varningar för kod som inte
används. De varningarna kan du så klart ignorera i det här
läget.
Saknar du inspektionsprofilen? Om du kör på universitetets
datorer, och installerade IDEAs konfigurationsfiler korrekt,
borde profilen finnas där. Felanmäl till oss! Om du kör
hemma behöver du ladda ner konfigurationsfilerna.
Se slutet
av kvalitetskriterierna.
4.8. Mer GUI-programmering: Menyer!
Bakgrund 4.8.1: Menyer
Innan vi går vidare med själva spelmekaniken är det dags att
vidareutveckla resten av det grafiska gränssnittet för spelet en
liten aning.
Att göra 4.8.1: Menyer
-
Lägg till menyer i spelet. Information om hur man gör detta
finns i föreläsningsanteckningarna. Se minst till att det finns
en meny med valet "Avsluta".
Implementera en händelsehanterare –
en ActionListener
eller en Action
,
enligt vad vi har diskuterat under föreläsningarna. Se på detta
sätt till att valet "avsluta" gör att en dialogruta visas med
hjälp av JOptionPane
, där man får bekräfta att man
vill sluta. Vid bekräftelse ska spelet avslutas med hjälp
av System.exit(0)
, som "stänger av" programmet med
felkod 0 = "inget fel".
4.9. En grafisk spelplan
Bakgrund 4.9.1: Grafik del 1 – komponenten
Det är nu dags att gå över från en textbaserad visning till en helt
grafisk.
Om du inte är van vid grafikprogrammering med Graphics2D i
Java: Vänta till du har varit på grafik-föreläsningen.
Att göra 4.9.1: Grafik del 1 – komponenten
Skapa en TetrisComponent
-klass som är subklass
till JComponent
.
Låt TetrisComponent
ha en pekare till det Board
som den visar.
Den behöver alltså ett fält som pekar på ett Board
, och den
behöver en konstruktor som tar ett Board
som parameter.
Implementera metoden getPreferredSize()
så att den
returnerar den storlek du helst vill ha för den grafiska
visaren. Enheten är pixlar. Om du vill anpassa
komponentstorleken till skärmstorleken har vi gått genom på
föreläsningarna hur man får fram skärmupplösningen. Tänk bara
på att TetrisComponent
-komponenten inte kan vara riktigt så
stor, eftersom menyer, knappar, ramar och annat också tar plats.
Grafik
Snygg grafik är inget vi premierar i den här labben.
Experimentera gärna, men spendera inte alltför mycket tid på det.
Enfärgade kvadrater med 1 pixels mellanrum duger gott när syftet är
att lära sig objektorientering!
Bakgrund 4.9.2: Grafik del 2 – utritningen
En JComponent
behöver kunna rita upp sig själv när som
helst, när Swings bakgrundstråd anropar den. Vi skall därför implementera metoden paintComponent()
så att den
ritar upp spelplanen som det ser ut just nu. Om metoden blir
stor kanske den behöver delas upp i delmetoder / hjälpmetoder
för att förbättra läsbarheten.
Tänk på att paintComponent()
behöver rita upp både bakgrunden,
de kvadrater som har ramlat ner på spelplanen, och det block (om
något) som håller på att ramla ner.
Fundera på hur paintComponent()
ska veta vilken färg
(java.awt.Color
) den ska använda för varje SquareType
.
Ett
sätt är att använda en switch-sats med en gren för varje
SquareType
.
-
Ett annat sätt är att den har en
EnumMap<SquareType,java.awt.Color>
som
lagrar mappningen.
En EnumMap
är en mappning, en uppslagningstabell liknande det som
kallas dictionaries i Python – i detta fall
med en SquareType
som nyckel och
en Color
som värde. I Java finns dock ingen
specialsyntax för mappningar, utan det är en klass som alla
andra, och objekten manipuleras med metoder
som put()
och get()
.
Med denna lösning kan din komponent till och med ta in
en EnumMap
som konstruktorparameter, så kan den
som skapar komponenten lätt konfigurera uppslagstabellen
uppslagningstabellen (och därmed färgerna) utifrån –
betydligt mer flexibelt än att hårdkoda en switchsats
i paintComponent()
.
paintComponent()
exempel
För att rita ut grafik används override på
funktionen paintComponent()
som ärvs
från JComponent
. Det kan t.ex. se ut så här:
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
final Graphics2D g2d = (Graphics2D) g;
g2d.setColor(Color.GREEN);
g2d.drawRect(a, b, c, d);
}
Här anropas super.paintComponent()
för att rensa bakgrunden i komponenten. I exemplet vill vi rita ut en grön rektangel med hjälp av ett Graphics2D
-objekt, men får ett Graphics
-objekt som inparameter. Därför castas detta först till Graphics2D
innan vi ritar ut rektangeln. Det finns många olika sätt att rita i en komponent och detta är bara ett exempel. Mer information finner du på föreläsningarna samt nätet.
Att göra 4.9.2: Grafik del 2 – utritningen
Implementera paintComponent()
enligt beskrivningen ovan.
Ändra i det tidigare GUIt så att TetrisComponent
nu används
istället för JTextArea
plus BoardToTextConverter
.
Ändra i testklassen, eller skriv en ny testklass, så att den
Timer
som introducerades tidigare ber din
TetrisComponent
att rita om sig istället för att manipulera den
textarea vi brukade använda. Slutmålet är att brädet ska
slumpas om med regelbundna mellanrum och att varje ny
konfiguration ska visas på skärmen i TetrisComponent
-komponenten
som finns i en JFrame
. För tillfället kan du be din
TetrisComponent
att rita om sig genom att timerhandlingen direkt
anropar repaint()
i TetrisComponent
-objekten. I en kommande
uppgift ska vi använda ett mer principiellt sätt att få
TetrisComponent
att uppdatera sig vid ändringar.
Magiska konstanter
Här är det läge att påpeka att du ska
undvika magiska
konstanter, i betydelsen "konstanter som används i koden utan
någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas
ut i en konstant storlek av 30x30 pixel är det bra att lägga
in namngivna konstanter som private final static int
SQUARE_WIDTH = 30;
i klassen och sedan använda denna konstant
varje gång du refererar till bredden. Då blir det mycket lättare
att läsa uttrycken och att förstå hur koden fungerar. (Vi använder
"final" för att konstanten inte ändras, "static" för att vi inte
behöver en kopia av konstanten för varje TetrisComponent
som
skapas.)
Speciellt svårt att läsa magiska konstanter är det om du gör
beräkningar som utgår från dem och sedan stoppar in de uträknade
värdena i koden. Till exempel är det oftare lättare att förstå
betydelsen hos "if (x > SQUARE_WIDTH - MARGIN)
" än
"if (x > 27)
", även för den som vet att SQUARE_WIDTH
är
30
och MARGIN
är 3
.
4.10. Observer / Observable
Bakgrund 4.10.1: Lyssnare och notifiering
I det program du nu har skrivit har du själva fått hitta på ett sätt
att få spelplanen att ritas om när något ändras. Det föreslagna
sättet har varit att en timerhandling som utförs med regelbundna
mellanrum både ser till att spelmodellen uppdateras (ett block
faller nedåt) och att TetrisComponent
därefter ritar om sig.
Problemet med den lösningen är att timerhandlingen måste känna
till precis vilka som vill veta när spelplanen ändrar sig. Vi
får därmed en alldeles för stark koppling mellan två klasser
(timerhandling och TetrisComponent
) som egentligen inte alls borde
behöva känna till varandra.
Ett annat sätt är att använda designmönstret Observer. I korthet
innebär detta att vi ser spelplanen som något "observerbart"
(Observable). Varje gång planen förändras på något sätt (t.ex. genom
att ett block faller ner ett steg) kan den själv informera en eller
flera "observatörer" (Observer). Varje observatör kan då själv
registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs
framåt ett steg är det sedan spelplanen som talar om för alla
intresserade att något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt intresse.
Vi har faktiskt redan sett en användning av detta mönster i Javas
GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare
är observatörer. Metoder som addActionListener()
lägger till en
lyssnare, en observatör, som är intresserad av en specifik typ av
handling. Klasser som JButton
känner inte till era specifika
lyssnare i förväg, men de har ett sätt för lyssnarna att registrera
sitt intresse.
Att göra 4.10.1: Lyssnare och notifiering
Skapa ett interface BoardListener
med en
metod public void boardChanged();
Lägg till i klassen Board
ett privat fält som innehåller en
lista av BoardListeners. Lägg också till en metod public void
addBoardListener(BoardListener bl)
som adderar den givna
lyssnaren till listan. I detta spel kommer vi inte att behöva
något sätt att ta bort lyssnare, även om man så klart kan lägga
till även en sådan metod för att vara mer fullständig.
Skapa i Board
en privat metod private void
notifyListeners()
som loopar över alla element i
lyssnarlistan och anropar deras boardChanged()
-metoder.
Se till att alla publika metoder i Board
som ändrar
på spelplanen anropar notifyListeners()
på slutet. Detta
inkluderar metoder som t.ex. ändrar koordinater på det block som
håller på att ramla ner. Du måste också se till att göra på
samma sätt i nya metoder du lägger till senare i labben.
Ändra TetrisComponent
så att den implementerar gränssnittet
BoardListener
. Implementera metoden boardChanged()
så att den
anropar repaint()
.
Se till att timerhandlingen som driver animeringen framåt
inte längre anropar repaint()
själv, som i tidigare lösning.
Ändra istället på era testklasser så att
TetrisComponent
-objektet du skapar adderas som en lyssnare på
spelplanen.
Resultat
Slutresultatet av denna ändring ska bli att allt på
skärmen ser ut precis som tidigare – men
att koden har en bättre struktur. Den är mer modulär i
och med att vi har minskat den starka kopplingen mellan
datalagring och utritning på skärmen. Detta är en mycket viktig
ändring.
4.11. Fallande block
Bakgrund 4.11.1: Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i
Tetris. Vi gör detta i flera steg.
I stället för att som tidigare att utnyttja timerhandlingen till att slumpa fram en helt ny
spelplan full av kvadrater ska den nu driva spelet ett steg framåt.
Det är dock en dålig idé att lägga spelmekanik och spelregler i
själva timerhandlingen – då skulle vi återigen blanda ihop två
helt olika typer av funktionalitet.
En bättre variant är att du skapar en tick()
-metod i Board
och
anropar den metoden från timerhandlingen. Metoden tick()
kan sedan
veta hur spelet drivs fram ett steg. Då hamnar kunskapen om spelets
regler i Board
, medan timerhandlingen används uteslutande för att
driva spelet framåt i rätt fart. (Ett alternativ kan vara att
separera spelmekaniken ytterligare och placera den i en
TetrisGame
-klass.)
Metoden tick()
behöver titta om spelplanen just nu innehåller ett
block (Poly
) som håller på att ramla ner. I så fall flyttar den det
blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T, L,
...) och placerar ett nytt block av den typen överst på spelplanen,
centrerat. Mer spelmekanik tillkommer senare.
Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite
mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!
Att göra 4.11.1: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället
för en framslumpad spelplan.
-
Implementera tick()
enligt beskrivningen ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block
slumpas fram och sedan faller nedåt tills det "faller av skärmen"
(och då fortsätter det falla i all oändlighet, även om detta inte
syns), eller orsakar ett undantag (Exception) beroende på implementationen. Att stoppa blocket kommer i nästa uppgift.
4.12. Ändlig skärmstorlek
Bakgrund 4.12.1: Stoppa fallande block
Nu är det dags att få blocken att stanna när det nått
botten av skärmen / spelplanen. Detta kräver så klart att du tittar
på det fallande blockets form och beräknar när den nedersta
kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går, så
blocket flyttas till nästa position.
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går
inte, så blocket är redan på positionen längst ner. Dess
kvadrater kopieras in till rätt koordinater på spelplanen och
själva blocket (Poly
) "tas bort". Nu finns alltså inget
fallande flyttbart block, men motsvarande kvadrater finns
inlagda "permanent" i spelplanen. Utåt ser det ut som att
spelet står still i ett tick – ingen skillnad syns.
tick()
. Metoden ser att det inte finns något block som
håller på att ramla ner, så ett nytt slumpas fram på översta
positionen.
tick()
. Som i första punkten...
Att göra 4.12.1: Stoppa fallande block
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod.
Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med
dess Extract
Method-refactoring.
Resultat
Resultatet i denna uppgift blir att nya block ständigt faller ända
ner till botten. Eftersom vi ännu inte har kollisionshantering
mellan blocken utan bara stoppar block vid botten av skärmen kommer
blocken att helt eller delvis ersätta varandra längst ner.
Kollisionshantering mellan block kommer i nästa uppgift.
Kodinspektion
Glöm inte att inspektera koden igen, om det var ett tag sedan du
gjorde det senast.
4.13. Kollisionshantering och "game over"
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att
ett block inte ska kunna "ramla förbi" ett annat utan att block ska
staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu
kvadrater omedelbart under sig.
tick()
. Blocket kan inte flyttas ett steg ned.
Hanteras som tidigare genom att lägga in kvadrater i arrayen.
tick()
. Det finns inget block som håller på att ramla
ner, så ett nytt slumpas fram. Kollisionshantering upptäcker
att detta block inte ens kan placeras på översta positionen, så
blocket placeras inte ut och gameOver
-flaggan sätts.
tick()
. Eftersom gameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta
blocket ett steg längre ner" enligt förra uppgiften. Nu räcker
det inte att titta om blocket vid en hypotetisk flytt till nästa
position skulle gå under kanten på skärmen. Du måste också
kolla om blocket vid en sådan hypotetisk flytt skulle krocka med
ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt
block slumpas fram måste spelet titta efter om det nya blocket
överhuvudtaget får plats på sin initialposition, centrerat längst
upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras
genom att en flagga sätts som gör att tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Så småningom behöver vi ha något sätt att driva spelet framåt, att få block att falla ner i lagom takt och så vidare.
Ett inte alltför ovanligt misstag är att man lägger in en loop som "stegar fram" ett steg, gör en paus av konstant längd, "stegar fram" nästa steg, och så vidare. Problemet är att när pausen är av konstant längd kommer tiden från starten av ett steg till starten av nästa att variera, beroende på hur lång tid själva steget tar, vilket är olika beroende på dator och CPU-belastning på datorn. Vi får göra på något annat sätt.
Som tur är finns en klass som
heter javax.swing.Timer
som vi kan använda för att få ett "steg" i spelet att köras
regelbundet. Blanda inte ihop den med andra klasser som heter
Timer, t.ex.
! Här ser vi en
anledning till att man lägger klasser i olika paket.
java.util.Timer
I den här uppgiften ska vi bara göra en enkel utforskning av
Timer
. Senare, när vi har gått vidare till en
fullständig grafisk komponent, kommer vi att titta närmare på
lämplig modellering av spelet som helhet och var spelloopen
egentligen ska placeras.
Det vi behöver veta kan vi då lära oss av följande exempel och beskrivningen under.
final Action doOneStep = new AbstractAction() {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Timer clockTimer = new Timer(500, doOneStep);
clockTimer.setCoalesce(true);
clockTimer.start();
...
clockTimer.stop();
En Timer
ser alltså till att Swings egna händelsehanteringstråd
anropar en viss handling ("action") med jämna mellanrum. I detta fall är det
500 millisekunder mellan anropen, och det som anropas är den
handling som vi kallade doOneStep
. Genom att anropa
setCoalesce(true)
ser vi till att anropen inte "köas
upp" om ett visst anrop till doOneStep()
skulle ta för
lång tid. Vi kan starta timern när spelet börjar och stoppa den när
spelet är över.
I övrigt kan även clockTimer.setLogTimers() vara intressant för felsökning. Se även Javadoc-dokumentationen som vi länkar till ovan!
-
Skapa någonstans en
Timer
som kör en handling med regelbundna mellanrum, en gång i sekunden. Handlingen ska slumpa om spelplanen (inte byta utBoard
-objektet utan ändra i det!) på motsvarande sätt som du gjort tidigare, och visa resultatet i textarean som du skapade i en tidigare uppgift. Handlingen behöver alltså på något sätt få tillgång till både spelplan och textarea. Slutresultatet ska helt enkelt bli att du ser en enkel "animering" på skärmen medan programmet körs. Du behöver inte kunna stoppa timern.
Bakgrund 4.7.1: Kodinspektion
Det är viktigt att man granskar sin egen kod då och då, för att eventuella problem inte ska bli för långlivade. Ju tidigare man kan förbättra koden, desto längre tid har man att utnyttja de förbättringarna. Det gäller ännu mer i den här kursen, när ni själva håller på att lära er objektorientering och Java.
Att göra 4.7.1: Kodinspektion
-
Om du normalt använder en annan miljö: Importera ditt projekt till IDEA. Det behöver du ändå kunna göra inför inlämningen.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig skriva kod som inte bara gör vad den ska utan även är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en chans att lära dig något nytt, redan innan du lämnar in koden! Läs IDEAs inbyggda beskrivningar, läs våra utökade beskrivningar, och om det inte hjälper, fråga gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Varningen ska alltså oftast tolkas som "har du tänkt på det här?" snarare än "du har fel!". Så småningom måste sådana kommenteras på plats i koden, med god motivering till varför varningen var "ogiltig" i detta fall. Vissa varningar kanske du vill kommentera redan nu. Se även kvalitetskriterierna.
Det kan även finnas varningar som kommer från att du helt enkelt inte är klar än, t.ex. varningar för kod som inte används. De varningarna kan du så klart ignorera i det här läget.
Saknar du inspektionsprofilen? Om du kör på universitetets datorer, och installerade IDEAs konfigurationsfiler korrekt, borde profilen finnas där. Felanmäl till oss! Om du kör hemma behöver du ladda ner konfigurationsfilerna. Se slutet av kvalitetskriterierna.
4.8. Mer GUI-programmering: Menyer!
Bakgrund 4.8.1: Menyer
Innan vi går vidare med själva spelmekaniken är det dags att
vidareutveckla resten av det grafiska gränssnittet för spelet en
liten aning.
Att göra 4.8.1: Menyer
-
Lägg till menyer i spelet. Information om hur man gör detta
finns i föreläsningsanteckningarna. Se minst till att det finns
en meny med valet "Avsluta".
Implementera en händelsehanterare –
en ActionListener
eller en Action
,
enligt vad vi har diskuterat under föreläsningarna. Se på detta
sätt till att valet "avsluta" gör att en dialogruta visas med
hjälp av JOptionPane
, där man får bekräfta att man
vill sluta. Vid bekräftelse ska spelet avslutas med hjälp
av System.exit(0)
, som "stänger av" programmet med
felkod 0 = "inget fel".
4.9. En grafisk spelplan
Bakgrund 4.9.1: Grafik del 1 – komponenten
Det är nu dags att gå över från en textbaserad visning till en helt
grafisk.
Om du inte är van vid grafikprogrammering med Graphics2D i
Java: Vänta till du har varit på grafik-föreläsningen.
Att göra 4.9.1: Grafik del 1 – komponenten
Skapa en TetrisComponent
-klass som är subklass
till JComponent
.
Låt TetrisComponent
ha en pekare till det Board
som den visar.
Den behöver alltså ett fält som pekar på ett Board
, och den
behöver en konstruktor som tar ett Board
som parameter.
Implementera metoden getPreferredSize()
så att den
returnerar den storlek du helst vill ha för den grafiska
visaren. Enheten är pixlar. Om du vill anpassa
komponentstorleken till skärmstorleken har vi gått genom på
föreläsningarna hur man får fram skärmupplösningen. Tänk bara
på att TetrisComponent
-komponenten inte kan vara riktigt så
stor, eftersom menyer, knappar, ramar och annat också tar plats.
Grafik
Snygg grafik är inget vi premierar i den här labben.
Experimentera gärna, men spendera inte alltför mycket tid på det.
Enfärgade kvadrater med 1 pixels mellanrum duger gott när syftet är
att lära sig objektorientering!
Bakgrund 4.9.2: Grafik del 2 – utritningen
En JComponent
behöver kunna rita upp sig själv när som
helst, när Swings bakgrundstråd anropar den. Vi skall därför implementera metoden paintComponent()
så att den
ritar upp spelplanen som det ser ut just nu. Om metoden blir
stor kanske den behöver delas upp i delmetoder / hjälpmetoder
för att förbättra läsbarheten.
Tänk på att paintComponent()
behöver rita upp både bakgrunden,
de kvadrater som har ramlat ner på spelplanen, och det block (om
något) som håller på att ramla ner.
Fundera på hur paintComponent()
ska veta vilken färg
(java.awt.Color
) den ska använda för varje SquareType
.
Ett
sätt är att använda en switch-sats med en gren för varje
SquareType
.
-
Ett annat sätt är att den har en
EnumMap<SquareType,java.awt.Color>
som
lagrar mappningen.
En EnumMap
är en mappning, en uppslagningstabell liknande det som
kallas dictionaries i Python – i detta fall
med en SquareType
som nyckel och
en Color
som värde. I Java finns dock ingen
specialsyntax för mappningar, utan det är en klass som alla
andra, och objekten manipuleras med metoder
som put()
och get()
.
Med denna lösning kan din komponent till och med ta in
en EnumMap
som konstruktorparameter, så kan den
som skapar komponenten lätt konfigurera uppslagstabellen
uppslagningstabellen (och därmed färgerna) utifrån –
betydligt mer flexibelt än att hårdkoda en switchsats
i paintComponent()
.
paintComponent()
exempel
För att rita ut grafik används override på
funktionen paintComponent()
som ärvs
från JComponent
. Det kan t.ex. se ut så här:
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
final Graphics2D g2d = (Graphics2D) g;
g2d.setColor(Color.GREEN);
g2d.drawRect(a, b, c, d);
}
Här anropas super.paintComponent()
för att rensa bakgrunden i komponenten. I exemplet vill vi rita ut en grön rektangel med hjälp av ett Graphics2D
-objekt, men får ett Graphics
-objekt som inparameter. Därför castas detta först till Graphics2D
innan vi ritar ut rektangeln. Det finns många olika sätt att rita i en komponent och detta är bara ett exempel. Mer information finner du på föreläsningarna samt nätet.
Att göra 4.9.2: Grafik del 2 – utritningen
Implementera paintComponent()
enligt beskrivningen ovan.
Ändra i det tidigare GUIt så att TetrisComponent
nu används
istället för JTextArea
plus BoardToTextConverter
.
Ändra i testklassen, eller skriv en ny testklass, så att den
Timer
som introducerades tidigare ber din
TetrisComponent
att rita om sig istället för att manipulera den
textarea vi brukade använda. Slutmålet är att brädet ska
slumpas om med regelbundna mellanrum och att varje ny
konfiguration ska visas på skärmen i TetrisComponent
-komponenten
som finns i en JFrame
. För tillfället kan du be din
TetrisComponent
att rita om sig genom att timerhandlingen direkt
anropar repaint()
i TetrisComponent
-objekten. I en kommande
uppgift ska vi använda ett mer principiellt sätt att få
TetrisComponent
att uppdatera sig vid ändringar.
Magiska konstanter
Här är det läge att påpeka att du ska
undvika magiska
konstanter, i betydelsen "konstanter som används i koden utan
någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas
ut i en konstant storlek av 30x30 pixel är det bra att lägga
in namngivna konstanter som private final static int
SQUARE_WIDTH = 30;
i klassen och sedan använda denna konstant
varje gång du refererar till bredden. Då blir det mycket lättare
att läsa uttrycken och att förstå hur koden fungerar. (Vi använder
"final" för att konstanten inte ändras, "static" för att vi inte
behöver en kopia av konstanten för varje TetrisComponent
som
skapas.)
Speciellt svårt att läsa magiska konstanter är det om du gör
beräkningar som utgår från dem och sedan stoppar in de uträknade
värdena i koden. Till exempel är det oftare lättare att förstå
betydelsen hos "if (x > SQUARE_WIDTH - MARGIN)
" än
"if (x > 27)
", även för den som vet att SQUARE_WIDTH
är
30
och MARGIN
är 3
.
4.10. Observer / Observable
Bakgrund 4.10.1: Lyssnare och notifiering
I det program du nu har skrivit har du själva fått hitta på ett sätt
att få spelplanen att ritas om när något ändras. Det föreslagna
sättet har varit att en timerhandling som utförs med regelbundna
mellanrum både ser till att spelmodellen uppdateras (ett block
faller nedåt) och att TetrisComponent
därefter ritar om sig.
Problemet med den lösningen är att timerhandlingen måste känna
till precis vilka som vill veta när spelplanen ändrar sig. Vi
får därmed en alldeles för stark koppling mellan två klasser
(timerhandling och TetrisComponent
) som egentligen inte alls borde
behöva känna till varandra.
Ett annat sätt är att använda designmönstret Observer. I korthet
innebär detta att vi ser spelplanen som något "observerbart"
(Observable). Varje gång planen förändras på något sätt (t.ex. genom
att ett block faller ner ett steg) kan den själv informera en eller
flera "observatörer" (Observer). Varje observatör kan då själv
registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs
framåt ett steg är det sedan spelplanen som talar om för alla
intresserade att något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt intresse.
Vi har faktiskt redan sett en användning av detta mönster i Javas
GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare
är observatörer. Metoder som addActionListener()
lägger till en
lyssnare, en observatör, som är intresserad av en specifik typ av
handling. Klasser som JButton
känner inte till era specifika
lyssnare i förväg, men de har ett sätt för lyssnarna att registrera
sitt intresse.
Att göra 4.10.1: Lyssnare och notifiering
Skapa ett interface BoardListener
med en
metod public void boardChanged();
Lägg till i klassen Board
ett privat fält som innehåller en
lista av BoardListeners. Lägg också till en metod public void
addBoardListener(BoardListener bl)
som adderar den givna
lyssnaren till listan. I detta spel kommer vi inte att behöva
något sätt att ta bort lyssnare, även om man så klart kan lägga
till även en sådan metod för att vara mer fullständig.
Skapa i Board
en privat metod private void
notifyListeners()
som loopar över alla element i
lyssnarlistan och anropar deras boardChanged()
-metoder.
Se till att alla publika metoder i Board
som ändrar
på spelplanen anropar notifyListeners()
på slutet. Detta
inkluderar metoder som t.ex. ändrar koordinater på det block som
håller på att ramla ner. Du måste också se till att göra på
samma sätt i nya metoder du lägger till senare i labben.
Ändra TetrisComponent
så att den implementerar gränssnittet
BoardListener
. Implementera metoden boardChanged()
så att den
anropar repaint()
.
Se till att timerhandlingen som driver animeringen framåt
inte längre anropar repaint()
själv, som i tidigare lösning.
Ändra istället på era testklasser så att
TetrisComponent
-objektet du skapar adderas som en lyssnare på
spelplanen.
Resultat
Slutresultatet av denna ändring ska bli att allt på
skärmen ser ut precis som tidigare – men
att koden har en bättre struktur. Den är mer modulär i
och med att vi har minskat den starka kopplingen mellan
datalagring och utritning på skärmen. Detta är en mycket viktig
ändring.
4.11. Fallande block
Bakgrund 4.11.1: Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i
Tetris. Vi gör detta i flera steg.
I stället för att som tidigare att utnyttja timerhandlingen till att slumpa fram en helt ny
spelplan full av kvadrater ska den nu driva spelet ett steg framåt.
Det är dock en dålig idé att lägga spelmekanik och spelregler i
själva timerhandlingen – då skulle vi återigen blanda ihop två
helt olika typer av funktionalitet.
En bättre variant är att du skapar en tick()
-metod i Board
och
anropar den metoden från timerhandlingen. Metoden tick()
kan sedan
veta hur spelet drivs fram ett steg. Då hamnar kunskapen om spelets
regler i Board
, medan timerhandlingen används uteslutande för att
driva spelet framåt i rätt fart. (Ett alternativ kan vara att
separera spelmekaniken ytterligare och placera den i en
TetrisGame
-klass.)
Metoden tick()
behöver titta om spelplanen just nu innehåller ett
block (Poly
) som håller på att ramla ner. I så fall flyttar den det
blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T, L,
...) och placerar ett nytt block av den typen överst på spelplanen,
centrerat. Mer spelmekanik tillkommer senare.
Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite
mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!
Att göra 4.11.1: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället
för en framslumpad spelplan.
-
Implementera tick()
enligt beskrivningen ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block
slumpas fram och sedan faller nedåt tills det "faller av skärmen"
(och då fortsätter det falla i all oändlighet, även om detta inte
syns), eller orsakar ett undantag (Exception) beroende på implementationen. Att stoppa blocket kommer i nästa uppgift.
4.12. Ändlig skärmstorlek
Bakgrund 4.12.1: Stoppa fallande block
Nu är det dags att få blocken att stanna när det nått
botten av skärmen / spelplanen. Detta kräver så klart att du tittar
på det fallande blockets form och beräknar när den nedersta
kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går, så
blocket flyttas till nästa position.
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går
inte, så blocket är redan på positionen längst ner. Dess
kvadrater kopieras in till rätt koordinater på spelplanen och
själva blocket (Poly
) "tas bort". Nu finns alltså inget
fallande flyttbart block, men motsvarande kvadrater finns
inlagda "permanent" i spelplanen. Utåt ser det ut som att
spelet står still i ett tick – ingen skillnad syns.
tick()
. Metoden ser att det inte finns något block som
håller på att ramla ner, så ett nytt slumpas fram på översta
positionen.
tick()
. Som i första punkten...
Att göra 4.12.1: Stoppa fallande block
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod.
Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med
dess Extract
Method-refactoring.
Resultat
Resultatet i denna uppgift blir att nya block ständigt faller ända
ner till botten. Eftersom vi ännu inte har kollisionshantering
mellan blocken utan bara stoppar block vid botten av skärmen kommer
blocken att helt eller delvis ersätta varandra längst ner.
Kollisionshantering mellan block kommer i nästa uppgift.
Kodinspektion
Glöm inte att inspektera koden igen, om det var ett tag sedan du
gjorde det senast.
4.13. Kollisionshantering och "game over"
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att
ett block inte ska kunna "ramla förbi" ett annat utan att block ska
staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu
kvadrater omedelbart under sig.
tick()
. Blocket kan inte flyttas ett steg ned.
Hanteras som tidigare genom att lägga in kvadrater i arrayen.
tick()
. Det finns inget block som håller på att ramla
ner, så ett nytt slumpas fram. Kollisionshantering upptäcker
att detta block inte ens kan placeras på översta positionen, så
blocket placeras inte ut och gameOver
-flaggan sätts.
tick()
. Eftersom gameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta
blocket ett steg längre ner" enligt förra uppgiften. Nu räcker
det inte att titta om blocket vid en hypotetisk flytt till nästa
position skulle gå under kanten på skärmen. Du måste också
kolla om blocket vid en sådan hypotetisk flytt skulle krocka med
ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt
block slumpas fram måste spelet titta efter om det nya blocket
överhuvudtaget får plats på sin initialposition, centrerat längst
upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras
genom att en flagga sätts som gör att tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Innan vi går vidare med själva spelmekaniken är det dags att vidareutveckla resten av det grafiska gränssnittet för spelet en liten aning.
-
Lägg till menyer i spelet. Information om hur man gör detta finns i föreläsningsanteckningarna. Se minst till att det finns en meny med valet "Avsluta".
Implementera en händelsehanterare – en
ActionListener
eller enAction
, enligt vad vi har diskuterat under föreläsningarna. Se på detta sätt till att valet "avsluta" gör att en dialogruta visas med hjälp avJOptionPane
, där man får bekräfta att man vill sluta. Vid bekräftelse ska spelet avslutas med hjälp avSystem.exit(0)
, som "stänger av" programmet med felkod 0 = "inget fel".
Bakgrund 4.9.1: Grafik del 1 – komponenten
Det är nu dags att gå över från en textbaserad visning till en helt grafisk.
Om du inte är van vid grafikprogrammering med Graphics2D i Java: Vänta till du har varit på grafik-föreläsningen.
Att göra 4.9.1: Grafik del 1 – komponenten
Skapa en
TetrisComponent
-klass som är subklass tillJComponent
.Låt
TetrisComponent
ha en pekare till detBoard
som den visar. Den behöver alltså ett fält som pekar på ettBoard
, och den behöver en konstruktor som tar ettBoard
som parameter.Implementera metoden
getPreferredSize()
så att den returnerar den storlek du helst vill ha för den grafiska visaren. Enheten är pixlar. Om du vill anpassa komponentstorleken till skärmstorleken har vi gått genom på föreläsningarna hur man får fram skärmupplösningen. Tänk bara på attTetrisComponent
-komponenten inte kan vara riktigt så stor, eftersom menyer, knappar, ramar och annat också tar plats.
Grafik
Snygg grafik är inget vi premierar i den här labben. Experimentera gärna, men spendera inte alltför mycket tid på det. Enfärgade kvadrater med 1 pixels mellanrum duger gott när syftet är att lära sig objektorientering!
Bakgrund 4.9.2: Grafik del 2 – utritningen
En JComponent
behöver kunna rita upp sig själv när som
helst, när Swings bakgrundstråd anropar den. Vi skall därför implementera metoden paintComponent()
så att den
ritar upp spelplanen som det ser ut just nu. Om metoden blir
stor kanske den behöver delas upp i delmetoder / hjälpmetoder
för att förbättra läsbarheten.
Tänk på att paintComponent()
behöver rita upp både bakgrunden,
de kvadrater som har ramlat ner på spelplanen, och det block (om
något) som håller på att ramla ner.
Fundera på hur paintComponent()
ska veta vilken färg
(java.awt.Color
) den ska använda för varje SquareType
.
Ett sätt är att använda en switch-sats med en gren för varje
SquareType
.-
Ett annat sätt är att den har en
EnumMap<SquareType,java.awt.Color>
som lagrar mappningen. En EnumMap är en mappning, en uppslagningstabell liknande det som kallas dictionaries i Python – i detta fall med enSquareType
som nyckel och enColor
som värde. I Java finns dock ingen specialsyntax för mappningar, utan det är en klass som alla andra, och objekten manipuleras med metoder somput()
ochget()
.Med denna lösning kan din komponent till och med ta in en
EnumMap
som konstruktorparameter, så kan den som skapar komponenten lätt konfigurera uppslagstabellen uppslagningstabellen (och därmed färgerna) utifrån – betydligt mer flexibelt än att hårdkoda en switchsats ipaintComponent()
.
paintComponent()
exempel
För att rita ut grafik används override på
funktionen paintComponent()
som ärvs
från JComponent
. Det kan t.ex. se ut så här:
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
final Graphics2D g2d = (Graphics2D) g;
g2d.setColor(Color.GREEN);
g2d.drawRect(a, b, c, d);
}
Här anropas super.paintComponent()
för att rensa bakgrunden i komponenten. I exemplet vill vi rita ut en grön rektangel med hjälp av ett Graphics2D
-objekt, men får ett Graphics
-objekt som inparameter. Därför castas detta först till Graphics2D
innan vi ritar ut rektangeln. Det finns många olika sätt att rita i en komponent och detta är bara ett exempel. Mer information finner du på föreläsningarna samt nätet.
Att göra 4.9.2: Grafik del 2 – utritningen
Implementera
paintComponent()
enligt beskrivningen ovan.Ändra i det tidigare GUIt så att
TetrisComponent
nu används istället förJTextArea
plusBoardToTextConverter
.Ändra i testklassen, eller skriv en ny testklass, så att den
Timer
som introducerades tidigare ber dinTetrisComponent
att rita om sig istället för att manipulera den textarea vi brukade använda. Slutmålet är att brädet ska slumpas om med regelbundna mellanrum och att varje ny konfiguration ska visas på skärmen iTetrisComponent
-komponenten som finns i enJFrame
. För tillfället kan du be dinTetrisComponent
att rita om sig genom att timerhandlingen direkt anroparrepaint()
iTetrisComponent
-objekten. I en kommande uppgift ska vi använda ett mer principiellt sätt att fåTetrisComponent
att uppdatera sig vid ändringar.
Magiska konstanter
Här är det läge att påpeka att du ska
undvika magiska
konstanter, i betydelsen "konstanter som används i koden utan
någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas
ut i en konstant storlek av 30x30 pixel är det bra att lägga
in namngivna konstanter som private final static int
SQUARE_WIDTH = 30;
i klassen och sedan använda denna konstant
varje gång du refererar till bredden. Då blir det mycket lättare
att läsa uttrycken och att förstå hur koden fungerar. (Vi använder
"final" för att konstanten inte ändras, "static" för att vi inte
behöver en kopia av konstanten för varje TetrisComponent
som
skapas.)
Speciellt svårt att läsa magiska konstanter är det om du gör
beräkningar som utgår från dem och sedan stoppar in de uträknade
värdena i koden. Till exempel är det oftare lättare att förstå
betydelsen hos "if (x > SQUARE_WIDTH - MARGIN)
" än
"if (x > 27)
", även för den som vet att SQUARE_WIDTH
är
30
och MARGIN
är 3
.
4.10. Observer / Observable
Bakgrund 4.10.1: Lyssnare och notifiering
I det program du nu har skrivit har du själva fått hitta på ett sätt
att få spelplanen att ritas om när något ändras. Det föreslagna
sättet har varit att en timerhandling som utförs med regelbundna
mellanrum både ser till att spelmodellen uppdateras (ett block
faller nedåt) och att TetrisComponent
därefter ritar om sig.
Problemet med den lösningen är att timerhandlingen måste känna
till precis vilka som vill veta när spelplanen ändrar sig. Vi
får därmed en alldeles för stark koppling mellan två klasser
(timerhandling och TetrisComponent
) som egentligen inte alls borde
behöva känna till varandra.
Ett annat sätt är att använda designmönstret Observer. I korthet
innebär detta att vi ser spelplanen som något "observerbart"
(Observable). Varje gång planen förändras på något sätt (t.ex. genom
att ett block faller ner ett steg) kan den själv informera en eller
flera "observatörer" (Observer). Varje observatör kan då själv
registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs
framåt ett steg är det sedan spelplanen som talar om för alla
intresserade att något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt intresse.
Vi har faktiskt redan sett en användning av detta mönster i Javas
GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare
är observatörer. Metoder som addActionListener()
lägger till en
lyssnare, en observatör, som är intresserad av en specifik typ av
handling. Klasser som JButton
känner inte till era specifika
lyssnare i förväg, men de har ett sätt för lyssnarna att registrera
sitt intresse.
Att göra 4.10.1: Lyssnare och notifiering
Skapa ett interface BoardListener
med en
metod public void boardChanged();
Lägg till i klassen Board
ett privat fält som innehåller en
lista av BoardListeners. Lägg också till en metod public void
addBoardListener(BoardListener bl)
som adderar den givna
lyssnaren till listan. I detta spel kommer vi inte att behöva
något sätt att ta bort lyssnare, även om man så klart kan lägga
till även en sådan metod för att vara mer fullständig.
Skapa i Board
en privat metod private void
notifyListeners()
som loopar över alla element i
lyssnarlistan och anropar deras boardChanged()
-metoder.
Se till att alla publika metoder i Board
som ändrar
på spelplanen anropar notifyListeners()
på slutet. Detta
inkluderar metoder som t.ex. ändrar koordinater på det block som
håller på att ramla ner. Du måste också se till att göra på
samma sätt i nya metoder du lägger till senare i labben.
Ändra TetrisComponent
så att den implementerar gränssnittet
BoardListener
. Implementera metoden boardChanged()
så att den
anropar repaint()
.
Se till att timerhandlingen som driver animeringen framåt
inte längre anropar repaint()
själv, som i tidigare lösning.
Ändra istället på era testklasser så att
TetrisComponent
-objektet du skapar adderas som en lyssnare på
spelplanen.
Resultat
Slutresultatet av denna ändring ska bli att allt på
skärmen ser ut precis som tidigare – men
att koden har en bättre struktur. Den är mer modulär i
och med att vi har minskat den starka kopplingen mellan
datalagring och utritning på skärmen. Detta är en mycket viktig
ändring.
4.11. Fallande block
Bakgrund 4.11.1: Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i
Tetris. Vi gör detta i flera steg.
I stället för att som tidigare att utnyttja timerhandlingen till att slumpa fram en helt ny
spelplan full av kvadrater ska den nu driva spelet ett steg framåt.
Det är dock en dålig idé att lägga spelmekanik och spelregler i
själva timerhandlingen – då skulle vi återigen blanda ihop två
helt olika typer av funktionalitet.
En bättre variant är att du skapar en tick()
-metod i Board
och
anropar den metoden från timerhandlingen. Metoden tick()
kan sedan
veta hur spelet drivs fram ett steg. Då hamnar kunskapen om spelets
regler i Board
, medan timerhandlingen används uteslutande för att
driva spelet framåt i rätt fart. (Ett alternativ kan vara att
separera spelmekaniken ytterligare och placera den i en
TetrisGame
-klass.)
Metoden tick()
behöver titta om spelplanen just nu innehåller ett
block (Poly
) som håller på att ramla ner. I så fall flyttar den det
blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T, L,
...) och placerar ett nytt block av den typen överst på spelplanen,
centrerat. Mer spelmekanik tillkommer senare.
Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite
mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!
Att göra 4.11.1: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället
för en framslumpad spelplan.
-
Implementera tick()
enligt beskrivningen ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block
slumpas fram och sedan faller nedåt tills det "faller av skärmen"
(och då fortsätter det falla i all oändlighet, även om detta inte
syns), eller orsakar ett undantag (Exception) beroende på implementationen. Att stoppa blocket kommer i nästa uppgift.
4.12. Ändlig skärmstorlek
Bakgrund 4.12.1: Stoppa fallande block
Nu är det dags att få blocken att stanna när det nått
botten av skärmen / spelplanen. Detta kräver så klart att du tittar
på det fallande blockets form och beräknar när den nedersta
kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går, så
blocket flyttas till nästa position.
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går
inte, så blocket är redan på positionen längst ner. Dess
kvadrater kopieras in till rätt koordinater på spelplanen och
själva blocket (Poly
) "tas bort". Nu finns alltså inget
fallande flyttbart block, men motsvarande kvadrater finns
inlagda "permanent" i spelplanen. Utåt ser det ut som att
spelet står still i ett tick – ingen skillnad syns.
tick()
. Metoden ser att det inte finns något block som
håller på att ramla ner, så ett nytt slumpas fram på översta
positionen.
tick()
. Som i första punkten...
Att göra 4.12.1: Stoppa fallande block
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod.
Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med
dess Extract
Method-refactoring.
Resultat
Resultatet i denna uppgift blir att nya block ständigt faller ända
ner till botten. Eftersom vi ännu inte har kollisionshantering
mellan blocken utan bara stoppar block vid botten av skärmen kommer
blocken att helt eller delvis ersätta varandra längst ner.
Kollisionshantering mellan block kommer i nästa uppgift.
Kodinspektion
Glöm inte att inspektera koden igen, om det var ett tag sedan du
gjorde det senast.
4.13. Kollisionshantering och "game over"
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att
ett block inte ska kunna "ramla förbi" ett annat utan att block ska
staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu
kvadrater omedelbart under sig.
tick()
. Blocket kan inte flyttas ett steg ned.
Hanteras som tidigare genom att lägga in kvadrater i arrayen.
tick()
. Det finns inget block som håller på att ramla
ner, så ett nytt slumpas fram. Kollisionshantering upptäcker
att detta block inte ens kan placeras på översta positionen, så
blocket placeras inte ut och gameOver
-flaggan sätts.
tick()
. Eftersom gameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta
blocket ett steg längre ner" enligt förra uppgiften. Nu räcker
det inte att titta om blocket vid en hypotetisk flytt till nästa
position skulle gå under kanten på skärmen. Du måste också
kolla om blocket vid en sådan hypotetisk flytt skulle krocka med
ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt
block slumpas fram måste spelet titta efter om det nya blocket
överhuvudtaget får plats på sin initialposition, centrerat längst
upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras
genom att en flagga sätts som gör att tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
I det program du nu har skrivit har du själva fått hitta på ett sätt
att få spelplanen att ritas om när något ändras. Det föreslagna
sättet har varit att en timerhandling som utförs med regelbundna
mellanrum både ser till att spelmodellen uppdateras (ett block
faller nedåt) och att TetrisComponent
därefter ritar om sig.
Problemet med den lösningen är att timerhandlingen måste känna
till precis vilka som vill veta när spelplanen ändrar sig. Vi
får därmed en alldeles för stark koppling mellan två klasser
(timerhandling och TetrisComponent
) som egentligen inte alls borde
behöva känna till varandra.
Ett annat sätt är att använda designmönstret Observer. I korthet
innebär detta att vi ser spelplanen som något "observerbart"
(Observable). Varje gång planen förändras på något sätt (t.ex. genom
att ett block faller ner ett steg) kan den själv informera en eller
flera "observatörer" (Observer). Varje observatör kan då själv
registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs
framåt ett steg är det sedan spelplanen som talar om för alla
intresserade att något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt intresse.
Vi har faktiskt redan sett en användning av detta mönster i Javas
GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare
är observatörer. Metoder som addActionListener()
lägger till en
lyssnare, en observatör, som är intresserad av en specifik typ av
handling. Klasser som JButton
känner inte till era specifika
lyssnare i förväg, men de har ett sätt för lyssnarna att registrera
sitt intresse.
Skapa ett interface
BoardListener
med en metodpublic void boardChanged();
Lägg till i klassen
Board
ett privat fält som innehåller en lista av BoardListeners. Lägg också till en metodpublic void addBoardListener(BoardListener bl)
som adderar den givna lyssnaren till listan. I detta spel kommer vi inte att behöva något sätt att ta bort lyssnare, även om man så klart kan lägga till även en sådan metod för att vara mer fullständig.Skapa i
Board
en privat metodprivate void notifyListeners()
som loopar över alla element i lyssnarlistan och anropar derasboardChanged()
-metoder.Se till att alla publika metoder i
Board
som ändrar på spelplanen anroparnotifyListeners()
på slutet. Detta inkluderar metoder som t.ex. ändrar koordinater på det block som håller på att ramla ner. Du måste också se till att göra på samma sätt i nya metoder du lägger till senare i labben.Ändra
TetrisComponent
så att den implementerar gränssnittetBoardListener
. Implementera metodenboardChanged()
så att den anroparrepaint()
.Se till att timerhandlingen som driver animeringen framåt inte längre anropar
repaint()
själv, som i tidigare lösning. Ändra istället på era testklasser så attTetrisComponent
-objektet du skapar adderas som en lyssnare på spelplanen.
Slutresultatet av denna ändring ska bli att allt på skärmen ser ut precis som tidigare – men att koden har en bättre struktur. Den är mer modulär i och med att vi har minskat den starka kopplingen mellan datalagring och utritning på skärmen. Detta är en mycket viktig ändring.
Bakgrund 4.11.1: Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i Tetris. Vi gör detta i flera steg. I stället för att som tidigare att utnyttja timerhandlingen till att slumpa fram en helt ny spelplan full av kvadrater ska den nu driva spelet ett steg framåt. Det är dock en dålig idé att lägga spelmekanik och spelregler i själva timerhandlingen – då skulle vi återigen blanda ihop två helt olika typer av funktionalitet.
En bättre variant är att du skapar en tick()
-metod i Board
och
anropar den metoden från timerhandlingen. Metoden tick()
kan sedan
veta hur spelet drivs fram ett steg. Då hamnar kunskapen om spelets
regler i Board
, medan timerhandlingen används uteslutande för att
driva spelet framåt i rätt fart. (Ett alternativ kan vara att
separera spelmekaniken ytterligare och placera den i en
TetrisGame
-klass.)
Metoden tick()
behöver titta om spelplanen just nu innehåller ett
block (Poly
) som håller på att ramla ner. I så fall flyttar den det
blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T, L,
...) och placerar ett nytt block av den typen överst på spelplanen,
centrerat. Mer spelmekanik tillkommer senare.
Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!
Att göra 4.11.1: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället för en framslumpad spelplan.
-
Implementera
tick()
enligt beskrivningen ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block slumpas fram och sedan faller nedåt tills det "faller av skärmen" (och då fortsätter det falla i all oändlighet, även om detta inte syns), eller orsakar ett undantag (Exception) beroende på implementationen. Att stoppa blocket kommer i nästa uppgift.
4.12. Ändlig skärmstorlek
Bakgrund 4.12.1: Stoppa fallande block
Nu är det dags att få blocken att stanna när det nått
botten av skärmen / spelplanen. Detta kräver så klart att du tittar
på det fallande blockets form och beräknar när den nedersta
kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går, så
blocket flyttas till nästa position.
tick()
. Ett antal gånger: Vi beräknar om det skulle gå
att flytta blocket ett steg längre ner utan att någon av dess
kvadrater hamnar under nedersta kanten på skärmen. Det går
inte, så blocket är redan på positionen längst ner. Dess
kvadrater kopieras in till rätt koordinater på spelplanen och
själva blocket (Poly
) "tas bort". Nu finns alltså inget
fallande flyttbart block, men motsvarande kvadrater finns
inlagda "permanent" i spelplanen. Utåt ser det ut som att
spelet står still i ett tick – ingen skillnad syns.
tick()
. Metoden ser att det inte finns något block som
håller på att ramla ner, så ett nytt slumpas fram på översta
positionen.
tick()
. Som i första punkten...
Att göra 4.12.1: Stoppa fallande block
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod.
Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med
dess Extract
Method-refactoring.
Resultat
Resultatet i denna uppgift blir att nya block ständigt faller ända
ner till botten. Eftersom vi ännu inte har kollisionshantering
mellan blocken utan bara stoppar block vid botten av skärmen kommer
blocken att helt eller delvis ersätta varandra längst ner.
Kollisionshantering mellan block kommer i nästa uppgift.
Kodinspektion
Glöm inte att inspektera koden igen, om det var ett tag sedan du
gjorde det senast.
4.13. Kollisionshantering och "game over"
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att
ett block inte ska kunna "ramla förbi" ett annat utan att block ska
staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu
kvadrater omedelbart under sig.
tick()
. Blocket kan inte flyttas ett steg ned.
Hanteras som tidigare genom att lägga in kvadrater i arrayen.
tick()
. Det finns inget block som håller på att ramla
ner, så ett nytt slumpas fram. Kollisionshantering upptäcker
att detta block inte ens kan placeras på översta positionen, så
blocket placeras inte ut och gameOver
-flaggan sätts.
tick()
. Eftersom gameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta
blocket ett steg längre ner" enligt förra uppgiften. Nu räcker
det inte att titta om blocket vid en hypotetisk flytt till nästa
position skulle gå under kanten på skärmen. Du måste också
kolla om blocket vid en sådan hypotetisk flytt skulle krocka med
ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt
block slumpas fram måste spelet titta efter om det nya blocket
överhuvudtaget får plats på sin initialposition, centrerat längst
upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras
genom att en flagga sätts som gör att tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Nu är det dags att få blocken att stanna när det nått botten av skärmen / spelplanen. Detta kräver så klart att du tittar på det fallande blockets form och beräknar när den nedersta kvadraten i blocket når botten.
Om ett block (Poly
) redan har fallit ner till botten av skärmen och
försöker falla ett steg till – vilket inte går – ska
dess kvadrater överföras till själva spelplanen i blockets slutliga
läge ("läggas in i den tvådimensionella arrayen"). Spelplanen ska
sedan övergå i ett läge där det inte har något fallande block
(t.ex. genom att pekaren till det fallande blocket återigen blir
null), så att nästa tick()
kan se att det är dags att slumpa fram
ett nytt fallande block. Vi har alltså:
tick()
. Ett antal gånger: Vi beräknar om det skulle gå att flytta blocket ett steg längre ner utan att någon av dess kvadrater hamnar under nedersta kanten på skärmen. Det går, så blocket flyttas till nästa position.tick()
. Ett antal gånger: Vi beräknar om det skulle gå att flytta blocket ett steg längre ner utan att någon av dess kvadrater hamnar under nedersta kanten på skärmen. Det går inte, så blocket är redan på positionen längst ner. Dess kvadrater kopieras in till rätt koordinater på spelplanen och själva blocket (Poly
) "tas bort". Nu finns alltså inget fallande flyttbart block, men motsvarande kvadrater finns inlagda "permanent" i spelplanen. Utåt ser det ut som att spelet står still i ett tick – ingen skillnad syns.tick()
. Metoden ser att det inte finns något block som håller på att ramla ner, så ett nytt slumpas fram på översta positionen.tick()
. Som i första punkten...
-
Implementera beteendet som diskuterats ovan.
Tänk på att inte lägga alltför mycket kod i en och samma metod. Bryt ut kod till hjälpmetoder vid behov. IDEA kan hjälpa till med dess Extract Method-refactoring.
Resultatet i denna uppgift blir att nya block ständigt faller ända ner till botten. Eftersom vi ännu inte har kollisionshantering mellan blocken utan bara stoppar block vid botten av skärmen kommer blocken att helt eller delvis ersätta varandra längst ner. Kollisionshantering mellan block kommer i nästa uppgift.
Glöm inte att inspektera koden igen, om det var ett tag sedan du gjorde det senast.
Bakgrund 4.13.1: Kollisioner
Nu lägger vi till kollisionshantering mellan block. Med detta menas att ett block inte ska kunna "ramla förbi" ett annat utan att block ska staplas ovanpå varandra. Spelets beteende kan t.ex. vara så här:
tick()
. Blocket flyttas ned ett steg och har nu kvadrater omedelbart under sig.tick()
. Blocket kan inte flyttas ett steg ned. Hanteras som tidigare genom att lägga in kvadrater i arrayen.tick()
. Det finns inget block som håller på att ramla ner, så ett nytt slumpas fram. Kollisionshantering upptäcker att detta block inte ens kan placeras på översta positionen, så blocket placeras inte ut ochgameOver
-flaggan sätts.tick()
. EftersomgameOver
-flaggan är satt gör vi inget.
Att göra 4.13.1: Kollisioner
-
Utöka koden som "beräknar om det skulle gå att flytta blocket ett steg längre ner" enligt förra uppgiften. Nu räcker det inte att titta om blocket vid en hypotetisk flytt till nästa position skulle gå under kanten på skärmen. Du måste också kolla om blocket vid en sådan hypotetisk flytt skulle krocka med ett annat block som redan finns i spelplanen.
-
När ett block har fallit ner så långt det kan och ett nytt block slumpas fram måste spelet titta efter om det nya blocket överhuvudtaget får plats på sin initialposition, centrerat längst upp. Om inte, ska spelet vara över. Detta kan t.ex. implementeras genom att en flagga sätts som gör att
tick()
omedelbart returnerar.
4.14. Tangentbordsstyrning
Bakgrund 4.14.1: Styra tetrominos
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan
under samma tick. Eftersom key bindings hanteras asynkront ska
detta fungera automatiskt.
Att göra 4.14.1: Styra tetrominos
-
Implementera metoder i Board
som anropas vid sidledsförflyttning. Dessa
metoder ska flytta det nedfallande blocket ett steg åt sidan om detta
är möjligt (inga kvadrater är i vägen). Precis som alla andra
metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresserade om att något
har ändrats. Däribland finns den grafiska komponenten som därmed
ritas om varje gång ett block har flyttats i sidled.
-
Lägg även till en tills vidare tom metod i Board
som skall hantera rotation.
Info: Synkronisering
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
4.15. Borttagning av rader
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater
"försvinner" om den blir helt full. Implementera detta, så
att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga
spelet att bara skapa kvadrater ("O"), så kan du testa
borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt
höger och vänster. Tangentbordsstyrning kan göras med hjälp av key
bindings kopplade till din TetrisComponent
. Här får du också en
övning i att läsa ut information ur Java Tutorial!
Tänk på att man normalt kan flytta ett block flera steg åt sidan under samma tick. Eftersom key bindings hanteras asynkront ska detta fungera automatiskt.
-
Implementera metoder i
Board
som anropas vid sidledsförflyttning. Dessa metoder ska flytta det nedfallande blocket ett steg åt sidan om detta är möjligt (inga kvadrater är i vägen). Precis som alla andra metoder som ändrar på speltillståndet behöver denna metod anropanotifyListeners()
för att informera alla intresserade om att något har ändrats. Däribland finns den grafiska komponenten som därmed ritas om varje gång ett block har flyttats i sidled. -
Lägg även till en tills vidare tom metod i
Board
som skall hantera rotation.
Den som är bekant med trådad programmering undrar kanske om det
nu kan hända att Board
anropas från flera trådar. Nej,
faktiskt inte. Tangentbordsbindningar behandlas i Swings
händelsehanteringstråd, så det är den tråden som flyttar block i
sidled. Den timer vi valde att använda tidigare
är javax.swing.Timer
, och den anropar också sin
handling (som i sin tur anropar Board.tick()
) från
Swings händelsehanteringstråd. Därför borde vi undvika
trådningsproblem och inte behöva använda synchronized()
.
Att göra 4.15.1: Försvinnande rader
-
En av grundtankarna bakom Tetris är att en rad kvadrater "försvinner" om den blir helt full. Implementera detta, så att du kanske kan spela lite längre innan spelet tar slut!
Blir det svårt att fylla en rad? Testa att tillfälligt tvinga spelet att bara skapa kvadrater ("O"), så kan du testa borttagningen enklare.
4.16. Rotation av tetrisblock
Bakgrund 4.16.1: Rotation
Om man ska ha en chans att fylla rader så de försvinner behöver man
också kunna rotera blocken. Detta kan kännas knepigt, men
är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den
korrekt, eller beror den på underförstådda antaganden som egentligen
inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:
Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't
make a right – but three lefts do, och tvärtom. Med
andra ord, man kan rotera åt vänster genom att rotera åt höger tre
gånger. Alternativt kan man så klart skapa en motsvarande
rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
→
Om man istället låter "balken" vara i en array av storlek 4x4, som i
rotationsstandarden som presenterades tidigare, roterar allt runt
den arrayens centrumpunkt:
→
Att göra 4.16.1: Rotation
-
Implementera rotate()
-funktionen och testa den.
-
Lägg till kod för tangentbordsstyrning så att en
rotationsfunktion i Board
anropas då man trycker
"pil upp"-tangenten.
Innan man "sparar" rotationen måste funktionen testa om det
roterade blocket skulle krocka med någon ruta som redan är
upptagen – i så fall är rotationen förbjuden och måste
avbrytas. Om rotationen går att genomföra måste man ersätta det
gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de
sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17. Poänghantering
4.17.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 vissa finesser som vi inte har implementerat i
den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par
designmönster samt vissa finesser i Collections Framework. Schemat
är lagt så att vi ska komma till detta under
föreläsningarna innan du kommer till denna punkt i
labbarna. Om du har arbetat i förväg och kommit hit innan den
föreläsningen får du själv välja om du vill arbeta vidare med de
beskrivningar som finns här, och få mer utförliga förklaringar på
föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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
spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel,
och att man i alla de spelen vill kunna använda sig av
samma gemensamma highscorelista. Då är frågan hur samtliga spel
ska kunna komma åt denna enda lista. En möjlighet är att det
finns en central plats där alla spel "skapas", och att denna
alltid skickar med highscorelistan till spelen. Det skulle säkert
fungera i det här fallet. En annan möjlighet är att vi använder
oss av ett designmönster som heter Singleton. Där
ser man till att det enbart kan skapas ett enda objekt av en viss
klass, och att detta objekt blir tillgängligt på ett enkelt sätt
för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt,
inte vid första anropet) kan i Java implementeras på det här
sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java.
Viktigt är att det finns flera sätt som verkar rimliga på ytan, men
som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker
att det fungerar! Överkurs: Vissa sätt att
implementera double-checked
locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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.
Skapa en Highscore
-klass som innehåller antal
poäng (ett heltal) plus namn på den som fick poängen (en sträng).
Skapa en HighscoreList
-klass. Den ska vara
en Singleton enligt ovan, och ska innehålla funktionalitet för
att lägga till highscores samt få fram samtliga highscores som
finns i listan. (Den kan t.ex. innehålla
en List<Highscore>
där detta sparas.)
-
Så snart en spelomgång avslutas ska programmet fråga
användaren efter ett namn (vi har tidigare sett hur man
använder inmatningsdialoger för detta).
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 visas
med drawString()
, som en sträng i en
textkomponent, som en sträng i en dialogruta, eller helt
enkelt genom att den skrivs ut i läsbart format
med System.out.println()
. Vi fokuserar ju på
Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska
få ett objekt av en klass. Den ska alltså bara användas
när det verkligen är det man vill, nu och i framtiden! Om vi hade
tänkt implementera grupper av spelare (kanske olika
"divisioner"), där varje grupp ska ha sin egen highscorelista, kan
vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod
för att få tag på klassens unika objekt, behöver man inte skicka
runt det objektet (potentiellt genom flera nivåer av metodanrop och
klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana
har också nackdelar. De kan t.ex. leda till onödiga beroenden
mellan olika delar av ett program, och svårigheter att reda ut vilka
beroendena egentligen är (eftersom olika delar man har tillgång till
samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Om man ska ha en chans att fylla rader så de försvinner behöver man också kunna rotera blocken. Detta kan kännas knepigt, men är viktigt av två anledningar:
Det gör att spelet verkligen blir spelbart...
Det är ett bra test av koden vi har skrivit tidigare: Är den korrekt, eller beror den på underförstådda antaganden som egentligen inte är sanna?
Så hur roterar man ett block? Det enklaste sättet att rotera vår
tetromino åt höger är genom att flytta alla våra SquareType
enligt följande bild:

Vi gör då en funktion rotate()
i Poly
som tar en boolean som inparameter. Här används inparametern för
att bestämma om vi roterar åt vänster eller höger. Rent praktiskt
behöver vi en ny tom array eller en ny Poly
som vi efter hand kan stoppa in de olika
rutornas innehåll i. När vi är klara byter vi till den nya
arrayen/polyn.
För ytterligare ledning kan du se följande metod. Den skapar först
en ny Poly
med samma bredd som den nuvarande Poly
ns höjd och
tvärtom. Därefter kopierar den SquareType
s till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't make a right – but three lefts do, och tvärtom. Med andra ord, man kan rotera åt vänster genom att rotera åt höger tre gånger. Alternativt kan man så klart skapa en motsvarande rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight(){
Poly newPoly = new Poly(height, width);
for (int r = 0; r < height; r++) {
for (int c = 0; c < width; c++){
newPoly.squares[c][height-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om ni bara har använt precis så många rutor som behövs för
att en Poly
ska få plats, kan ni få oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
Om man istället låter "balken" vara i en array av storlek 4x4, som i rotationsstandarden som presenterades tidigare, roterar allt runt den arrayens centrumpunkt:
-
Implementera
rotate()
-funktionen och testa den. -
Lägg till kod för tangentbordsstyrning så att en rotationsfunktion i
Board
anropas då man trycker "pil upp"-tangenten.Innan man "sparar" rotationen måste funktionen testa om det roterade blocket skulle krocka med någon ruta som redan är upptagen – i så fall är rotationen förbjuden och måste avbrytas. Om rotationen går att genomföra måste man ersätta det gamla fallande blocket med det nya, roterade blocket.
Se också till att det inte går att rotera block så att de sticker ut ur spelplanen! Om detta skulle hända ska rotationen avbrytas.
4.17.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 vissa finesser som vi inte har implementerat i den här enkla Tetris-varianten.
4.17.1. Poänghantering
Inför poänghantering i spelet enligt poängsättningen ovan.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att nuvarande poäng hela tiden visas någonstans i spelet.
Info: Designmönster
I resten av labben kommer vi att använda ytterligare ett par designmönster samt vissa finesser i Collections Framework. Schemat är lagt så att vi ska komma till detta under föreläsningarna innan du kommer till denna punkt i labbarna. Om du har arbetat i förväg och kommit hit innan den föreläsningen får du själv välja om du vill arbeta vidare med de beskrivningar som finns här, och få mer utförliga förklaringar på föreläsningarna i efterhand, eller om du vill vänta ett tag.
4.17.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 spel kan se en highscorelista.
Vi tänker oss nu att man vill kunna spela många parallella spel, och att man i alla de spelen vill kunna använda sig av samma gemensamma highscorelista. Då är frågan hur samtliga spel ska kunna komma åt denna enda lista. En möjlighet är att det finns en central plats där alla spel "skapas", och att denna alltid skickar med highscorelistan till spelen. Det skulle säkert fungera i det här fallet. En annan möjlighet är att vi använder oss av ett designmönster som heter Singleton. Där ser man till att det enbart kan skapas ett enda objekt av en viss klass, och att detta objekt blir tillgängligt på ett enkelt sätt för de som behöver det.
En Singleton med "ivrig initialisering" (skapa objektet direkt, inte vid första anropet) kan i Java implementeras på det här sättet:
public class HighscoreList {
// Skapa bara objekt av denna typ EN gång, när klassen laddas.
private static final HighscoreList INSTANCE = new HighscoreList();
// Privat konstruktor, så ingen annan kan skapa fler objekt.
private HighscoreList() {
...
}
// Låt andra komma åt det unika objektet
public static HighscoreList getInstance() {
return INSTANCE;
}
// Flera fält och metoder, precis som i vilken klass som helst
private ...
public ...
}
För att komma åt det unika HighscoreList
-objektet kan
man nu helt enkelt anropa HighscoreList.getInstance()
.
Därefter kan man använda detta objekt som vanligt, men man vet att
alla andra som anropade HighscoreList.getInstance()
också har exakt samma objekt.
Det finns även flera andra sätt att implementera Singletons i Java. Viktigt är att det finns flera sätt som verkar rimliga på ytan, men som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker att det fungerar! Överkurs: Vissa sätt att implementera double-checked locking fungerar inte i multitrådade program.
4.17.2. Highscorelista
För att en highscorelista ska fungera 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. Man kan 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 innehåller antal poäng (ett heltal) plus namn på den som fick poängen (en sträng).Skapa en
HighscoreList
-klass. Den ska vara en Singleton enligt ovan, och ska innehålla funktionalitet för att lägga till highscores samt få fram samtliga highscores som finns i listan. (Den kan t.ex. innehålla enList<Highscore>
där detta sparas.)-
Så snart en spelomgång avslutas ska programmet fråga användaren efter ett namn (vi har tidigare sett hur man använder inmatningsdialoger för detta). 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 visas med
drawString()
, som en sträng i en textkomponent, som en sträng i en dialogruta, eller helt enkelt genom att den skrivs ut i läsbart format medSystem.out.println()
. Vi fokuserar ju på Singleton-mönstret, inte på hur snygg visningen är.
Vi har nu sett hur Singleton-mönstret fungerar.
Viktigt: Poängen med Singleton är att man bara ska få ett objekt av en klass. Den ska alltså bara användas när det verkligen är det man vill, nu och i framtiden! Om vi hade tänkt implementera grupper av spelare (kanske olika "divisioner"), där varje grupp ska ha sin egen highscorelista, kan vi så klart inte använda en Singleton.
Fördel: Eftersom vem som helst kan anropa en statisk metod för att få tag på klassens unika objekt, behöver man inte skicka runt det objektet (potentiellt genom flera nivåer av metodanrop och klasser) för att den som behöver det ska få tag på det.
Nackdel: En Singleton är en sorts global variabel. Sådana har också nackdelar. De kan t.ex. leda till onödiga beroenden mellan olika delar av ett program, och svårigheter att reda ut vilka beroendena egentligen är (eftersom olika delar man har tillgång till samma objekt trots att objekten inte uttryckligen "skickas" dit).
4.17.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. Vad vi jämför (typen T
) är
alltså Highscore
:
public class ScoreComparator implements Comparator<Highscore> {
public int compare(Highscore o1, Highscore o2) {
...
}
}
Vi kan sedan använda den statiska sorteringsmetoden
i Collections
-klassen:
List<Highscore> scores = ...;
...
Collections.sort(scores, new ScoreComparator());
4.17.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!
4.18. Slut – dags att demonstrera!
Avslutning
Du har nu implementerat ett spel, läst om varför vi
implementerar på ett visst sätt, och på det sättet lärt dig mer om
objektorienterad modellering och programmering. Du har också fått
en praktisk användning av flera designmönster:
Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100%
fullständigt spel. Poängen sparas inte i en fil, blocken faller
inte snabbare när tiden går så att spelet blir svårare, och så
vidare. Det finns två anledningar till det: Dels skulle det mest
kräva mer av samma typ av programmering som du redan har provat på,
dels måste du ha gott om tid över till projektet.
Demonstration
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren.
Följ sedan instruktionerna nedan för att lämna in koden.
Kodinlämning
LÄS NOGA!
Syfte
Vi har strikta instruktioner för inlämning. Om vi behöver
lägga ner 10 minuter extra per student "i onödan" tar det över
20 timmar som vi kunde ha lagt på handledning och
kursutveckling! Därför måste vi returnera labbar som inte
följer instruktionerna för komplettering.
Inlämningsprocedur
-
Se till att du har demonstrerat. Handledaren kan ha
kommentarer redan vid demo, så du ska demonstrera
före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan
öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt
enligt instruktionerna
i labb 1. Kom ihåg att ändra More Settings / Project
format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna
igen och se till att koden uppfyller dem. Det här är din
sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i
menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig
skriva kod som inte bara gör vad den ska utan även
är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en
chans att lära dig något nytt, redan innan du lämnar
in koden! Läs IDEAs inbyggda beskrivningar,
läs våra
utökade beskrivningar, och om det inte hjälper, fråga
gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan
alltså finnas varningar som är "felaktiga". Alla sådana måste
kommenteras på plats i koden, med god motivering. Övriga
varningar ska korrigeras. Se
även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen
"kompletteringar.txt" hur varje enskild kommentar från
handledaren har hanterats: Vad som ändrats och var i koden,
hur du har löst problemet, och annan information som är
relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar
och tydligt skilda från nya kommentarer (markera med
kompletteringsdatum). Detta underlättar för oss och är en
del av examinationen där du visar att du förstår varför
kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans
med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste
finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA.
Är du osäker, testa genom att packa upp arkivet på annan
plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i
filnamnet. Vid komplettering använder du versionsnummer "v2",
"v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare.
Brevets ämne ska inledas med kurskoden TDDD78 så det
kan sorteras rätt. Annars kan det dröja innan brevet blir
läst.
Du har nu implementerat ett spel, läst om varför vi implementerar på ett visst sätt, och på det sättet lärt dig mer om objektorienterad modellering och programmering. Du har också fått en praktisk användning av flera designmönster: Model-View-Controller, Observer, Singleton, och Strategy.
Som synes resulterar den här labben inte nödvändigtvis i ett 100% fullständigt spel. Poängen sparas inte i en fil, blocken faller inte snabbare när tiden går så att spelet blir svårare, och så vidare. Det finns två anledningar till det: Dels skulle det mest kräva mer av samma typ av programmering som du redan har provat på, dels måste du ha gott om tid över till projektet.
-
Det är nu dags att demonstrera slutresultatet för labbhandledaren. Följ sedan instruktionerna nedan för att lämna in koden.
LÄS NOGA!
Vi har strikta instruktioner för inlämning. Om vi behöver lägga ner 10 minuter extra per student "i onödan" tar det över 20 timmar som vi kunde ha lagt på handledning och kursutveckling! Därför måste vi returnera labbar som inte följer instruktionerna för komplettering.
-
Se till att du har demonstrerat. Handledaren kan ha kommentarer redan vid demo, så du ska demonstrera före inlämning (och före deadline).
-
Labben måste lämnas in i IDEA-format så att vi snabbt kan öppna den och navigera genom koden.
Har du inte använt IDEA: Skapa ett projekt enligt instruktionerna i labb 1. Kom ihåg att ändra More Settings / Project format till ".ipr (file based)"!
-
Läs genom kvalitetskriterierna igen och se till att koden uppfyller dem. Det här är din sista chans att lära dig mer innan projektet.
-
Kör IDEAs kodinspektioner med profil TDDD78-2015-v1.
- Välj Analyze | Inspect Code i menyn.
-
Välj inspektionsprofil "TDDD78-2015-v1" och tryck OK.
Använd INTE defaultprofilen.
Det här är till stor del ett sätt att lära sig skriva kod som inte bara gör vad den ska utan även är välskriven och strukturerad.
Förstår du inte en varning? Utmärkt! Då har du en chans att lära dig något nytt, redan innan du lämnar in koden! Läs IDEAs inbyggda beskrivningar, läs våra utökade beskrivningar, och om det inte hjälper, fråga gärna assistenten eller examinatorn.
Inget automatiskt inspektionsverktyg är 100% korrekt. Det kan alltså finnas varningar som är "felaktiga". Alla sådana måste kommenteras på plats i koden, med god motivering. Övriga varningar ska korrigeras. Se även kvalitetskriterierna.
-
Är detta en komplettering? Beskriv i så fall i filen "kompletteringar.txt" hur varje enskild kommentar från handledaren har hanterats: Vad som ändrats och var i koden, hur du har löst problemet, och annan information som är relevant för att handledaren lätt ska se vad som gjorts.
Gör du flera kompletteringar ska gamla kommentarer vara kvar och tydligt skilda från nya kommentarer (markera med kompletteringsdatum). Detta underlättar för oss och är en del av examinationen där du visar att du förstår varför kompletteringen behövdes.
-
Packa all källkod och andra filer som krävs tillsammans med ev. kompletteringsbeskrivning i en ZIP- eller TAR.GZ-fil.
IDEAs projektdefinitionsfiler (.ipr, .iml) och liknande måste finnas med så att projektet enkelt kan öppnas i IntelliJ IDEA. Är du osäker, testa genom att packa upp arkivet på annan plats och öppna det i IDEA från denna plats!
Döp filen enligt följande mönster, utan mellanslag i filnamnet. Vid komplettering använder du versionsnummer "v2", "v3" och så vidare.
liuid123-labb4-v1.zip
-
Skicka in filen via epost till din handledare. Brevets ämne ska inledas med kurskoden TDDD78 så det kan sorteras rätt. Annars kan det dröja innan brevet blir läst.
2012–2015 Jonas Kvarnström, Mikael Nilsson
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2015-02-02