TDDC69 Objektorienterad prog. och Java
Objektorientering i Java
Efter uppvärmningen i labb 1 är det nu dags att gå in lite djupare på objektorienterad programmering i Java. Vi kommer att fortsätta i "tutorial"-form, särskilt i början av labben där vi gärna vill ge er lite mer ledning till hur den teori som har diskuterats på föreläsningarna ska omsättas i praktik. Mot slutet av labben kommer ni gradvis att få mer och mer frihet – och färre detaljerade ledtrådar.
Som uppgift kommer vi att använda ett enklare grafiskt spel, närmare bestämt Tetris. Om ni inte har spelat Tetris tidigare är det bra om ni läser på lite om det och kanske testar någon variant innan ni fortsätter. Beskrivningen förutsätter att ni vet hur Tetris fungerar.
Som ni kommer att se ger detta spel möjlighet att utforska många objektorienterade begrepp, samtidigt som ni bygger upp något konkret och sammanhängande under en längre tid snarare än att fylla i många små "labbskelett". En fördel med just Tetris jämfört med många andra spel är också 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.
Så hur programmerar man ett Tetris-spel? Ett bra tips, oberoende av vad man ska programmera, är att försöka bryta ner det i steg 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. Vi börjar med några grundläggande datatyper.
Läs genom varje hel uppgift (t.ex. 2.1) innan du börjar, och tänk på att en viss mängd dokumentation av era lösningar krävs även för denna labb!
2.1. Spelplan och "kvadrater"
Det första steget i labben ä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.
Model-View-Controller. När vi skapar datatyperna 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, vyn, och
hur den kontrolleras via ett
användargränssnitt, controllern. Mer specifikt vill vi se
till att vårt Board enbart innehåller information om vilka block
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 vore det jobbigt att lägga till nya
visningssätt, förutom att det kunde vara en sammanblandning av för
mycket funktionalitet.
Tetrisblock. På spelplanen kommer vi steg för steg att lägga till tetrisblock som "ramlar ner" uppifrån. Varje tetrisblock är tekniskt sett en tetromino, ett specialfall av en polyomino: Det består av fyra sammanhängande kvadrater i en viss färg och ett visst mönster – L, T, och så vidare (totalt 7 olika mönster är möjliga). Nedan syns ett exempel från Wikipedia (public domain).
Funderingar om datatyper. Vi kunde tänka oss att
ett Board skulle representeras som en lista av de
tetrisblock som har ramlat ner samt x- och y-positioner för dessa
block på planen. Utifrån den informationen skulle det ju vara lätt
att rita ut spelplanen i ett grafiskt gränssnitt. 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.
Som tur är behöver vi egentligen inte den informationen. Allt vi
behöver veta är: För varje position (x,y) på spelplanen, är
den tom, och om inte, vilken färg har 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. Allt som behövs är en
färgindikation.
Datatyper för spelplanen. Med tanke på ovanstående argument
kan ett Board t.ex. innehålla en tvådimensionell array
av lämplig storlek, där varje element antingen är null
(vilket indikerar en tom/ledig position) eller
en SquareColor (som anger en färg på en kvadrat).
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.
Kanske det också vore bra med en konstruktor utan argument som
skapar en plan av defaultstorlek. 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.
Om ni kommer hit innan vi har gått genom Javas arrayer kan ni se följande kortfattade exempel alternativt titta närmare i Java Tutorial om arrayer, en av våra föreslagna kursböcker.
SquareColor[][] array = new SquareColor[rows][columns];
SquareColor[] rowZero = array[0];
SquareColor row5col9 = array[5][9];
Gömma data. Den tvådimensionella arrayen ska så klart vara
privat i Board, eftersom 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.
Kvadraterna. Om vi antar att relativt få färger kommer att
användas för kvadraterna, och att dessa färger inte kommer att
ändras under programmets körning, kan en SquareColor
lämpligen implementeras som en enum-klass där varje färg motsvaras
av en specifik enum-konstant, t.ex. BLUE, RED och YELLOW.
Separera modell från vy. Vi skulle ju separera modellen från vyn, så varför har vi då färginformation i modellen? Det kan verka underligt, men modellen måste ändå innehålla all den information som ska visualiseras, och där kan färginformation ingå. Annars skulle ju vyn bara kunna rita i en enda färg eller slumpa fram färger. En motsvarighet inom schack skulle vara att pjäsklassen anger en typ av pjäs: Kung, drottning, bonde, och så vidare.
En
SquareColor har däremot inga metoder för att faktiskt
rita upp en kvadrat av en viss färg, den säger inte 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. Vi är till och med fria att visualisera i textformat genom
att konvertera varje enumkonstant (som det inte finns särskilt många
av) som ett specialtecken som "#" eller "%". Inom schack innehåller
pjäsklassen inte heller information om hur en kung ska se ut på
skärmen, men det kan senare visualiseras i text eller med bilder.
Vi kommer att använda begreppet "färg" även om vi i vissa fall istället visar olika typer av kvadrater som ofärgade textsymboler.
Uppgift. Nu är det dags att implementera
klasserna Board och SquareColor enligt ovan. I
denna uppgift har vi tyvärr inte kommit så långt att vi enkelt kan
testa det vi har skrivit, men det kommer i nästa steg.
Genomgång. När du känner att du är klar, gå genom koden ordentligt och se om du är nöjd med allt eller vill göra ändringar. Fråga gärna labbhandledaren.
2.2. Slumpning och utskrift av spelplan
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/view. Vi skapar därför en visare, en "view", som kan visa
vår modell (spelplan). Detta kan implementeras som en separat
klass, TextViewer, med
metoden convertToText( 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 färger på kvadrater
(SquareColors) ska representeras som textsymboler, t.ex. "#" och
"%".
Board)
Tillgång till information från Board. Vyn 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 ni
hela arrayen kan den återigen ändras utifrån, och ni låser fast er
för hårt vid arrayrepresentationen.
Strängmanipulation. 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().
Mappning från SquareColor till symbol. Givet en
SquareColor 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
SquareColor-objektet men då har vi blandat ihop modellen
(SquareColor) med ett specifikt sätt att visa modellen
på. Istället får vi låta vyn känna till
vilka SquareColors som finns och ha en hjälpmetod som
returnerar symbolen motsvarande en viss SquareColor.
Denna metod kan använda en switch med
ett case för varje
SquareColor-värde, något som är möjligt på grund av att SquareColor
är en enum. Den kan också vara static eftersom den
inte behöver tillgång till tillståndet i en specifik TextViewer.
Testklass. Prova att skriva en
testklass, BoardTest, som skapar en tom
spelplan, konverterar den till en sträng med hjälp
av TextViewer-klassen, och skriver ut resultatet. När
det är klart har ni åstadkommit ett testbart ramverk och kan se att
ni har gjort konkreta framsteg!
2.3. Slumpning av spelplan
Slumpning. 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.
Slumpa fram en SquareColor. Med hjälp av klassmetoden
SquareColor.values() får vi fram en array som innehåller alla värden
i enum-typen SquareColor, vilket kan indexeras med ett slumptal.
Klassen java.util.Random och metoden nextInt(n) kan användas för att
hitta ett pseudoslumptal mellan 0 och n-1. Läs i klassens API-dokumentation
för att se hur man skapar ett Random-objekt och använder detta objekt för
att generera slumptal.
Testklass. Ändra klassen BoardTest så att den
slumpar fram och skriver ut 10 spelplaner. Testkör.
2.4. Tetrisblock / tetrominoes
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) eller andra varianter (block som exploderar). Frågan är då hur man ska implementera dem på bästa sätt.
-
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.
-
En enda klass? Det verkar vara ett bättre val. Om vi sedan vill implementera exploderande block kan vi göra det med en enda ny subklass, snarare än sju olika subklasser till olika blockklasser.
Vi väljer alltså att skapa en enda blockklass, som vi kan kalla Poly
(kort för Polyomino). Då behöver vi ett sätt för
varje Poly-objekt att tala om hur det ser ut. Detta är
inte en visualiseringsaspekt utan en del av den fundamentala
modellen för ett block, så det ska definitivt finnas med i
Poly-klassen. Förutom själva formen på blocket behöver vi också
veta runt vilken punkt det ska roteras. Hur representerar vi då
detta?
-
En tvådimensionell array liknande den i spelplanen? Den skulle i så fall innehålla null för "tomt" kombinerat med
SquareColor-objekt för fyllda positioner. Rotationspunkten kunde då anges med x/y-koordinater relativt t.ex. övre vänstra hörnet av kvadraten i array[0][0]. -
En lista av SquareColors + koordinater? Detta är ett annat alternativ. Man behöver i så fall en hjälpklass som innehåller både en SquareColor och ett par av koordinater relativt rotationspunkten. Vi kan kalla den klassen
SquarePos. Sedan innehållerPoly-klassen en lista av 4SquarePos-objekt.
Att skapa tetrisblock. 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 skapar vi
en separat klass, TetrominoMaker, som kan skapa just de sju
Tetrominoblocken. Klassen kan t.ex. ha dessa metoder:
public int getNumberOfTypes();
public Poly getPoly(int n);
En anropare kan då fråga om hur många blocktyper som finns (just nu
7) och be om ett block av typ 0 till antal-1. Metoden getPoly()
returnerar då ett nytt Poly-objekt av den givna typen. Tänk på att
inte skapa för långa metoder – om getPoly() blir för lång när
ni lägger in skapandet av alla blocktyper i den metoden kan ni bryta
ut delar till sju olika hjälpmetoder istället.
Tetrisblock på spelplanen. Vår spelplan kan representera de block som redan har fallit ner. Detta representeras som en array av SquareColors, 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?
Ett alternativ är att det fallande blocket också representeras i vår
nuvarande datastruktur som en uppsättning kvadrater / SquareColors i
den tvådimensionella arrayen. Det gör att vi inte behöver ändra
uppritningsfunktionen (TextViewer). Å andra sidan måste
vi i varje steg, när blocket faller eller roteras, ta bort dess
SquareColors från en position i arrayen och lägga till dem i en ny
position.
Det andra alternativet är att ha en särskild representation för det
fallande blocket, med ytterligare fält i Board som
representerar det fallande blocket (eller null om inget block
faller), dess position, och (i ett framtida steg!) dess rotation.
Då behöver vi ändra uppritningsfunktionen, men vi förbereder oss
samtidigt för tänkbara utökningar där 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.
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 TextViewer.
I denna del av labben ska ni implementera Poly- och
Tetromino-klasserna, utöka Board
och TextViewer enligt ovan, och utöka testklassen så
att den kan slumpa fram en spelplan som både har SquareColors på
slumpmässiga platser (som tidigare) och ett block på en slumpmässig
position. I det här läget behöver ni bara implementera stöd för
"icke-roterade" block. (Om ni slumpar fram ett block som täcker en
position där spelplanen redan är "upptagen" av en kvadrat (dess
SquareColor är inte null) är det blocket som ska synas: Den ligger
"ovanför" spelplanen. Det problemet kommer inte att uppstå när
spelmekaniken är implementerad.)
2.5. Ett enkelt GUI
Det är nu dags att gå över från rent textformat till ett grafiskt
gränssnitt. För att förenkla den uppgiften kommer ni att börja med
att skriva ett grafiskt gränssnitt som använder
en JTextArea för att visa textrepresentationen av en
"spelplan". Det verkar kanske som ett lite underligt sätt, men det
gör dels att ni kan börja med GUIt redan innan ni lär er hur man
skapar egna komponenter, dels att ni får ett enklare "mellansteg" så
att ni 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 ni 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.
Skapa den nya klassen
TetrisFramesom en subklass tillJFrame.På något sätt ska
TetrisFramefå tillgång till ett Board-objekt. Man kunde tänka sig attTetrisFramesjä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!TetrisFrames uppgift är att visa ett spelbräde, inte nödvändigtvis att skapa det.När ni skapar en subklass måste ni alltid tänka på vilka parametrar som ni måste – eller vill – skicka med till superklassens konstruktor.
JFramehar 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ånTetrisFrames 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 subklassenTetrisFramebehö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 ni sedan bygga upp ett lämpligt användargränssnitt. Den viktigaste delen just nu är denJTextAreasom ska användas till att visa själva "spelplanen". Ange i konstruktorn tillJTextAreahur många kolumner och rader som ska visas – detta avgör vilken preferred size textarean ska ha. Antalet kolumner och rader får ni 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 ni se alla varianter på parametrar. Välj en av dem och tryck Ctrl-Q så får ni dokumentation för parametrarna.Redan när en
TetrisFrameskapas måste textarean ges sitt första innehåll. Här förutsätter vi att ni har skrivit en TextViewer med en metod som returnerar en sträng, inte en metod som skriver ut en spelplan direkt till t.ex. System.out. Då kan ni använda den metoden för att få en sträng motsvarande ett Board, och sedan använda textareanssetText()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. Se GUI-föreläsningen för mer information!
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 ni ser en lagom storTetrisFramemed en framslumpad spelplan (inte 10 spelplaner, som tidigare). Just nu finns ingen händelsehantering, så ni får använda "stoppknappen" i utvecklingsmiljön för att stänga av testprogrammet.
2.6. Timer för spelloop
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!
I den här uppgiften ska vi bara göra en enkel utforskning av Timerklassen. 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 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!
I denna uppgift:
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 ni gjort tidigare, och visa resultatet i textarean som ni 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 ni ser en enkel "animering" på skärmen medan programmet körs. Ni behöver inte kunna stoppa timern.
2.7. Mer GUI-programmering: 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.
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
ActionListenereller 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 av JOptionPane, där man får bekräfta att man vill sluta. Det innebär att ni behöver Vid bekräftelse ska spelet avslutas med hjälp avSystem.exit(0), vilket avslutar Javas virtuella maskin.
2.8. En grafisk spelplan
Det är nu dags att gå över från en textbaserad visning av spelplanen till en helt grafisk.
Skapa en
-klass som är subklass tillGraphicalViewerJComponent.Låt
GraphicalViewerha 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 methoden getPreferredSize() så att den returnerar den storlek ni helst vill ha för den grafiska visaren. Enheten är pixlar. Om ni 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
GraphicalViewer-komponenten inte kan vara riktigt så stor, eftersom menyer, knappar, ramar och annat också tar plats.En JComponent behöver kunna rita upp sig själv när som helst. Implementera därför metoden paintComponent() så att den ritar upp spelbrädet 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 SquareColor. Ett sätt är att använda en switch-sats med en gren för varje SquareColor. Ett annat sätt är att den har en EnumMap<SquareColor,java.awt.Color> som lagrar mappningen. Då kan den till och med ta denna EnumMap som konstruktorparameter, så kan anroparen lätt konfigurera färgerna utifrån – betydligt mer flexibelt än att hårdkoda en switchsats i paintComponent().
Ändra i det tidigare GUIt så att
GraphicalViewernu används istället för TextViewer.Ändra i testklassen, eller skriv en ny testklass, så att den Timer som introducerades i förra uppgiften ber er
GraphicalVieweratt 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 iGraphicalViewer-komponenten som finns i en JFrame. För tillfället kan ni be erGraphicalVieweratt rita om sig genom att timerhandlingen direkt anropar repaint() iGraphicalViewer-objekten. I en kommande uppgift ska vi använda ett mer principiellt sätt att fåGraphicalVieweratt uppdatera sig vid ändringar.
Snygg grafik? Detta ä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!
Magiska konstanter. Här är det läge att påpeka att ni 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 ni 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 GraphicalViewer som
skapas.)
Speciellt svårt att läsa magiska konstanter är det om ni 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.
2.9. Observer / Observable
I det program ni nu har skrivit har ni 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 GraphicalViewer 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 spelbrädet ändrar sig. Vi får därmed en alldeles för stark koppling mellan två klasser (timerhandling och GraphicalViewer) 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 timerhändelsen kan fokusera helt på att driva spelet framåt ett steg. När spelet drivs framåt ett steg är det sedan spelbrädet som talar om för alla intresserade att något har hänt. En av dessa intresserade råkar vara GraphicalViewer, 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 händelse. 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
BoardListenermed 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 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. Ni måste också se till att göra på samma sätt i nya metoder ni lägger till senare i labben.
Ändra GraphicalViewer så att den implementerar gränssnittet BoardListener. Implementera metoden boardChanged() så att den anropar repaint().
Se till att timerhändelsen 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 GraphicalViewer-objektet ni skapar adderas som en lyssnare på spelbrädet.
Slutresultatet av denna ändring ska bli att allt ser ut precis som tidigare (men att koden har en bättre struktur).
2.10. Fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i Tetris. Vi gör detta i flera steg. Eftersom ni nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite mindre detaljerat. Fråga gärna assistenten om ni vill ha hjälp!
Ändra spelet så att det utgår från en tom spelplan istället för en framslumpad spelplan.
I stället för att timerhandlingen slumpar 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 ni 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.
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). Att stoppa blocket kommer i nästa uppgift.
2.11. Ändlig skärmstorlek
Implementera nu ett sätt att få ett block att stanna när det nått botten av skärmen / spelplanen. Detta kräver så klart att ni 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(). Blocket flyttas och hamnar näst längst ner.
tick(). Blocket flyttas och hamnar längst ner.
tick(). Blocket kan inte flyttas så dess kvadrater överförs till spelplanen och själva blocket "tas bort". Spelet står i någon mening still i ett tick – ingen skillnad syns.
tick(). Det finns inget block som håller på att ramla ner, så ett nytt slumpas fram på översta positionen.
tick(). Blocket flyttas ner en position.
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 och helt eller delvis ersätter varandra. Kollisionshantering mellan block kommer i nästa uppgift.
2.12. Kollisionshantering och "game over"
Implementera kollisionshantering mellan block. Med detta menas att ett block inte ska kunna "ramla förbi" ett annat utan att block ska staplas ovanpå varandra.
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. Vi kommer inte i denna labb att implementera poängvisning, omstart av spel, eller liknande funktioner.
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 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.
2.13. Tangentbordsstyrning
Implementera tangentbordsstyrning av block så att de kan flyttas åt höger och vänster. Detta kan göras med hjälp av key bindings kopplade till er GraphicalViewer. Här får ni 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.
För varje steg åt sidan ska en lämplig metod i Board anropas. Denna metod 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.
Kan Board nu 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 förhoppningsvis undvika
trådningsproblem och inte behöva använda synchronized() i Board.
2.14. Borttagning av rader
En av grundtankarna bakom Tetris är att en rad kvadrater "försvinner" om den blir helt full. Implementera detta, så att du kan spela lite längre innan spelet tar slut!
2.15. Rotation av block – frivilligt!
Tetris brukar stödja rotation av block. Att implementera det i denna labb är dock frivilligt, eftersom det mest är fokuserat på geometri och inte ger så mycket mer erfarenhet av objektorienterad programmering.
2.16. Slut – dags att demonstrera!
Som synes resulterar den här labben inte nödvändigtvis i ett fullständigt spel. Vi har ingen poängräkning, vi kan inte börja om efter att spelet tar slut annat än genom att stoppa och starta om hela programmet, brickorna snabbas inte upp 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 ni redan har provat på, dels har vi inte så mycket tid i kursen, och ni måste ha gott om tid över till projektet.
Det är nu dags att demonstrera slutresultatet för labbhandledaren. Följ sedan instruktionerna för att lämna in koden.
Det kan vara frestande att använda den här labben som bas för projektet. Det är tillåtet, men tänk då på att vi kommer att se projektet som det nya ni lägger till efter den här inlämningen. Till exempel krävs i så fall ett visst antal nya designmönster utöver de ni redan har implementerat i de här övningarna. Vi rekommenderar att ni istället väljer ett annat program till projektet.
Copyright (c) 2012 Jonas Kvarnström
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2012-09-11
