Labb 4: Ett fungerande Tetris
Nu återgår vi till Tetris – det är dags att vi får ett fungerande spel!
Efter föreläsning 5, pekare, interface, typhierarkier
Tetris 4.1: Timer
Att göra något regelbundet: 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. Just nu kan vi komma en bit på vägen genom att i alla fall slumpa om spelplanen med jämna mellanrum, motsvarande hur ofta ett block skulle flyttas ett steg nedåt.
Hur får man något att hända med jämna mellanrum? 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, CPU-belastning, med mera. Exempel:
Jobba 50 ms, vänta 500 ms
Jobba 120 ms, vänta 500 ms
Jobba 80 ms, vänta 500 ms
Jobba 60 ms, vänta 500 ms
Jobba 200 ms, vänta 500 ms
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.
java.util.Timer
! Det kan
se ut att fungera en del av tiden, men händelser kommer att inträffa i fel tråd vilket kan leda till
mystiska fel som enbart uppstår ibland.
Här ser vi en anledning till att man lägger klasser i olika paket. Om du i senare steg inte får rätt Timer
,
Action
eller ActionEvent
så se till att följande imports finns under
package
i början på filen:
import javax.swing.*;
import java.awt.event.ActionEvent;
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 anropsstarterna, och det som
anropas är den handling som vi kallade doOneStep
. Eller rättare sagt, Timer
vet
att den får någon sorts Action
som parameter, och att alla Action
-objekt har
metoden actionPerformed
som den kan anropa.
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!
Notera att anonyma klasser, som vi använde ovan, bara bör användas för små klasser. Om
implementationen av actionPerformed()
blir lång kan man vilja ge klassen ett namn istället:
class StepMaker extends AbstractAction {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Action doOneStep = new StepMaker();
Uppgift: Timers
-
Skapa någonstans – men inte i konstruktorn – 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.(Varför inte i konstruktorn? Jo, som tidigare bör konstruktorer initialisera objekt men inte dra igång längre processer.)
-
Testkör och se att spelplanen slumpas om.
Efter föreläsning 6, GUI
Tetris 4.2: En grafisk spelplan
Det är nu dags att gå över från en textbaserad visning till en helt grafisk, även i Tetris.
Om du inte är van vid grafikprogrammering med Graphics2D i Java kanske du vill vänta till du har varit på grafikföreläsningen. Se även vanliga lösningar.
Uppgift: Grafik del 1 – komponenten
Vi kommer att förändra
TetrisViewer
så pass mycket att det kan vara av intresse att spara undan den. En version borde så klart vara incheckad i versionshanteringen, men just i det här fallet kan vi också spara den nuvarande versionen som en kopia (så handledaren kan se det gamla). IDEA: Högerklicka påOldTetrisViewer
och välj Refactor -> Copy och döp den till TetrisViewerOld, så har ni lättare att använda den igen om ni skulle vilja.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å kan ta plats.
Snygg grafik är inget vi premierar här! 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!
Grafik del 2 – utritningen
Kom ihåg att 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
. Men den lösningen bygger helt och hållet på att hårdkoda värden. Vi vill återigen hellre lagra data i datastrukturer än att lagra dem i programkod.-
Ett annat sätt är att den har en
EnumMap<SquareType,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()
.Hur initialiserar man en sådan? Ett bra sätt är
private final static EnumMap<SquareType, Color> SQUARE_COLORS = createColorMap()
, där den statiska metodencreateColorMap()
skapar, fyller i och returnerar enEnumMap
. Detta är ett bra sätt att direkt initialisera ett fält som ska innehålla enMap
.Sedan är kanske
createColorMap()
i sig hårdkodad... men vi har kommit ett bra steg på vägen. Om man ville skulle denna lösning vara väldigt lätt att generalisera till att konstruktorn tar in enEnumMap
som parameter, 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()
.
Exempel på paintComponent()
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.
Uppgift: 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 indirekt genom att timerhandlingen direkt anroparrepaint()
iTetrisComponent
-objektet (som dinTetrisViewer
har en pekare till). I en kommande uppgift ska vi använda ett mer principiellt sätt att fåTetrisComponent
att uppdatera sig vid ändringar.
Om 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 man kan använda varje gång man refererar till bredden eller höjden. Då blir det mycket lättare att läsa uttrycken och att förstå hur koden fungerar. Till exempel kan man lägga in:
Fältet
private final static int SQUARE_SIZE = 30;
i klassen, om detta används i flera metoder."Variabeln"
final int squareSize = 30;
i en enskild metod, om värdet bara används där.
I båda fallen ska man använda final
för att markera att detta är tänkt att vara en konstant. I
det första fallet använder vi också static
för att vi inte behöver en kopia av konstanten för
varje TetrisComponent
-objekt som skapas.
Namngivna konstanter är viktigt för läsbar kod, vilket i sin tur är viktigt för betyget på projektet!
Det är speciellt svårt att läsa magiska konstanter 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_SIZE - MARGIN)
" än "if (x > 27)
", även för den som vet att
SQUARE_WIDTH
är 30
och MARGIN
är 3
.
Ännu snyggare än att lägga in en namngiven konstant kan det bli om man (alltså TetrisComponent
)
dynamiskt tar reda på hur stor man är vid uppritningen (i paintComponent
), och anpassar
storleken på kvadraterna till detta. Då kan allt följa med när man ändrar storlek på fönstret under körning.
Tips: this.getSize()
, där this
är din TetrisComponent
.
Tetris 4.3: Observer-mönster / lyssnare
Om lyssnare och notifiering
Tidigare har du själv 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 de objekt som kan vilja veta när något händer ses som observatörer. De objekt där något kan hända håller reda på vilka observatörer som har registrerat sitt intresse, och informerar dem (via ett metodanrop) när något faktiskt händer.
Vi har faktiskt redan sett denna sorts "callback", till exempel i Javas GUI-bibliotek. Alla ActionListener
s
är en sorts observatörer som är intresserade av att veta när någon t.ex. trycker på en knapp. Metoder som
addActionListener()
lägger till en lyssnare 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. Om man vill kan man registrera ett godtyckligt antal lyssnare på
samma knapp.
I Tetris innebär detta att vi ser spelplanen som något "observerbart". 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"
som kan implementera ett passande lyssnargränssnitt. 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.
Uppgift: Lyssnare och notifiering
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.Ä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.
Det går också bra att låta boardChanged
ta parametrar som talar om vad som har ändrats,
och/eller att skapa flera olika metoder i BoardListener som anropas vid olika händelser: Ett block har
flyttats, ett block har "landat", spelet är slut, ...
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.
Tetris 4.4: Fallande block
Om fallande block
Nu är det dags att börja implementera den riktiga spelmekaniken i Tetris. Vi gör detta i flera steg. Till att börja med ska vi inte längre utnyttja timerhandlingen till att slumpa fram en helt ny spelplan full av kvadrater, utan istället använda den för att 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. Vi vill istället att spelmekanik och spelregler
ska hanteras i Board
. (Ett alternativ hade varit att separera spelmekaniken ytterligare och
placera den i en TetrisGame
-klass, medan Board
bara innehåller just lagringen av
kvadrater, men det gör vi inte i just det här spelet.)
Skapa därför en tick()
-metod i Board
och anropa 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.
Uppgift: Fallande block
Ändra spelet så att det utgår från en tom spelplan istället för en framslumpad spelplan.
-
Implementera
Board.tick()
, som 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.)För detta får man gärna skapa hjälpmetoder som
setFalling(...)
ochmoveFalling()
.ÖverkursDet finns många sätt att slumpa fram Tetrisblock. Det enklaste är att ha en uniform fördelning: I varje steg har varje blocktyp exakt samma sannolikhet som alla andra blocktyper. Det är så ni förväntas göra i den här labben!
Det finns dock en hel del mer att läsa om svagheterna med denna enkla slumpmetod, för den som är intresserad. Till exempel kan man få långa sekvenser av samma bit, eller långa sekvenser där en viss önskad bit aldrig kommer, vilket kan göra spelet mindre roligt. Den som vill kan läsa mer på The history of Tetris randomizers. Spendera bara inte för mycket tid på detta, och var medveten om att mer avancerade slumpmetoder inte ger "extrapoäng".
Se till att
tick()
anropas från timerhandlingen enligt ovan.
Resultat
Slutresultatet i denna uppgift blir ett testprogram där ett block slumpas fram och sedan faller nedåt. Beroende på hur ni har implementerat spelet kan blocket "falla av skärmen" och sedan fortsätta falla i all oändlighet (även om detta inte syns), eller så kanske spelet kraschar när ni kommer "för långt ner". Båda varianterna är OK i det här läget. Senare i projektet ska vi få blocket att stoppa när det når botten.
Tetris 4.5: Tangentbordsstyrning
Att 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
. Se även vanliga lösningar!
Tänk på att man normalt kan flytta ett block flera steg åt sidan mellan förflyttningarna nedåt. Eftersom key bindings hanteras asynkront, "i bakgrunden", borde detta fungera automatiskt. Med andra ord, man kan trycka t.ex. "vänster" flera gånger mellan timer-ticken.
Uppgift: Styra tetrominos
Vi vill inte behöva ha en metod för varje riktning. Skapa därför
enum
-typenDirection
med värdenLEFT
ochRIGHT
.Som diskuterats ovan vill vi att
Board
ska innehålla "logiken" för alla de "drag" man kan göra. Implementera alltså enmove
-metod iBoard
som anropas vid "draget" sidledsförflyttning. Metoden ska ha enDirection
-parameter och ska helt enkelt flytta det nedfallande blocket ett steg åt sidan.Än så länge finns inget som kan vara "i vägen", så vi bryr oss inte om kollisionshantering just nu.
Precis som alla andra metoder som ändrar på speltillståndet behöver denna metod anropa
notifyListeners()
för att informera alla intresseradeBoardListener
s 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.-
Använd key bindings (se ovan) för att anropa metoderna för sidledsförflyttning när man trycker pil vänster respektive pil höger. Ni kan även anropa dem från andra tangenter om ni vill, men pilarna måste fungera. Tänk på att det återigen räcker med en MoveAction-klass för att hantera alla riktningar – konstruktorn kan spara undan vilken
Direction
som gäller. Exempel på detta ges iQuitAction
i vanliga lösningar. -
Testa. Prova även att skicka blocket utanför spelplanens kanter. Om detta leder till en krasch är det än så länge helt OK.
Den som är bekant med trådad programmering undrar kanske om det nu kan hända att Board
anropas
från flera trådar samtidigt, dels via tangentbordsbindningen och dels via timerhandlingen. 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 bör vi inte få några trådningsproblem här.
Uppgift: Kodinspektion
Glöm inte att då och då ta en titt på den kodanalys du får i issues i Gitlab! Gå genom de påpekanden du ser. Fråga handledaren om du inte förstår något eller tycker att ett påpekande är omotiverat.
Tetris 4.6: Kollisionshantering och "game over"
Om kollisioner och kollisionshantering
Hittills har vi gjort ett Tetris där ett block faller tills det hamnar utanför spelplanen. Vi har också möjlighet att styra blocket i sidled, men även då kan blocket hamna utanför spelplanen. Vi behöver detektera dessa tillstånd för att spelet skall bete sig som vi önskar. Då kommer vi också att få flera block på planen, eftersom gamla block stannar kvar när de inte längre kan fortsätta falla. Fallande block ska då förhindras från att falla igenom eller flyttas in i befintliga block.
För att se till att block inte överlappar gamla "kvarlämnade" kvadrater på skärmen behöver vi en enkel form av kollisionshantering. Varje gång vi flyttar det fallande blocket ändrar vi först dess koordinater provisoriskt (tillfälligt), utan att rita om skärmen. Vi testar sedan om det flyttade blocket överlappar några existerande kvadrater i spelbrädet. I så fall var förflyttningen förbjuden, och vi flyttar tillbaka blocket till sin gamla position.
Hur ser vi då till att blocket inte kommer för långt till vänster eller höger? Det är ju en helt annan situation, eftersom det inte finns några gamla block där som stoppar oss. Måste vi börja testa blockets koordinater mot spelbrädets höjd och bredd? Det är en möjlighet, men när vi lägger till rotation blir det jobbigt att hålla reda på var varje block verkligen börjar och slutar...
Men om det är en helt annan situation kanske vi kan göra det till samma situation. Med andra ord, vi ser till att det finns något runt spelplanen som stoppar blocken. Alla instruktioner nedan bygger på att man följer den lösningen, men det är även tillåtet att implementera alternativa lösningar så länge man (1) förklarar dem, (2) accepterar att man får lösa de problem som skulle kunna uppstå.
Vi hittar på en ny SquareType
, OUTSIDE
, och lägger sådana i en ram med *minst* två
extra kvadrater på varje sida:
Att stöta mot ramen i sidled eller nedåt blir då lika lätt att detektera som att det fallande blocket stöter emot andra block i dessa riktningar. Detta är en vanlig teknik som vi vill att ni ska lära er.
Det är viktigt att förstå att ramen bara är ett sätt att implementera kollisionshanteringen,
inte en fundamental egenskap hos ett spelbräde. Vi vill alltså inte att ramens existens på något som helst
sätt ska synas utanför Board
: Det är en implementationsdetalj som ska döljas, så att ingen
annan behöver veta om den och så att vi vid behov kan göra om det hela senare utan att påverka andra
klasser!
Uppgift: Kollisioner
-
Lägg till
OUTSIDE
som ett nytt enum-värde iSquareType
. -
Definera en konstant (
private static int MARGIN = 2
) som talar om hur bred marginalen är. Utan den konstanten måste vi skriva "2" på många olika platser i koden, och den som läser vet inte nödvändigtvis var "2" kommer ifrån. Nu kan vi användaMARGIN
, och så blir koden självförklarande!Ibland kan du behöva använda dubbla marginalen... så kanske det också ska finnas en konstant
DOUBLE_MARGIN
för att slippa varningar för2 * MARGIN
? -
Se till att konstruktorn lägger till en ram runt spelplanen enligt bilden ovan. Det ska vara två "OUTSIDE" på varje sida, eftersom detta underlättar när vi senare implementerar rotation av block. Arrayens storlek ska alltså vara 4 större än den
width
ochheight
som anges. Tänk på att samma gamla värde ska fortfarande lagras iwidth
ochheight
, eftersom dessa fält representerar spelplanens "egentliga" storlek. -
Eftersom andra klasser inte ska vara medvetna om ramen måste gettern för
squares
ändras så att den indexerar "förbi" ramen. När någon ber om blocket på logisk position (0,0) ska gettern alltså returnera blocket på position(MARGIN,MARGIN)
i arrayen. Testa att det fungerar precis som tidigare.Om du valt att behålla
BoardToTextConverter
i projektet behöver du eventuellt hantera en symbol förOUTSIDE
för att undvika varningar. -
Testa även nu andra värden på
MARGIN
, som 3 och 4. Fungerar det fortfarande? Annars har du missat någon plats där du fortfarande har en hårdkodad marginal. -
Skriv en metod
hasCollision()
som returnerartrue
om det fallande blockets nuvarande position resulterar i att en icke-tom ruta i blocket överlappar en icke-tom ruta på spelplanen. BaraSquareType.EMPTY
räknas som tomt. Vi kan nu använda
hasCollision()
för att förhindra kollision i sidled. Metoden som förflyttar blocket när spelaren trycker pil vänster eller höger måste testa om detta resulterar i en kollision. I så fall måste metoden flytta tillbaka blocket eftersom förflyttningen var omöjlig. Implementera detta.Lägg till en kontroll i
tick
så att om blocket efter förflyttning nedåt kolliderar skall det flyttas tillbaka upp och stoppas in i brädet. Fältetfalling
skall i detta läge sättas tillnull
.Vi kan nu implementera "game over" genom att direkt göra en kollisionskontroll då ett nytt block slumpats fram. Om det nya blocket omedelbart kolliderade med något, var det omöjligt att lägga till ett nytt block på spelplanen. Implementera detta test samt en flagga som håller koll på om det är "game over" och om så är fallet ser till att
tick
inte gör något.
Resultat
Resultatet i denna uppgift är ett spelbart Tetris som dock saknar rotation av blocket vilket blir nästa steg.
Tetris 4.7: Rotation av tetrisblock
Om rotation
Om man ska ha en chans att fylla rader så de försvinner, vilket ju är målet med spelet, behöver man 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(boolean right)
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
av samma storlek
som den nuvarande Poly
n. 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(new SquareType[size][size]);
for (int r = 0; r < size; r++) {
for (int c = 0; c < size; c++){
newPoly.squares[c][size-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om vi valt att representera tetrominos med precis så många rutor som behövs för att en
Poly
ska få plats, kunde vi fått oväntade resultat. Nedan skulle t.ex. "balken" inte rotera
runt sin mittpunkt utan runt sin övre vänstra kvadrat:
Därför valde vi istället en kvadratisk array av storlek 4x4, som i rotationsstandarden som presenterades tidigare, roterar allt runt den arrayens centrumpunkt:
Uppgift: Rotation
-
Lägg till en
rotate(Direction dir)
-metod iBoard
och se till att den roterar det block som just nu håller på att falla ner, åt det håll som anges – vänster eller höger.Om det inte finns något nedfallande block ska metoden inte krascha utan helt enkelt inte göra något alls.
Innan man "sparar" det roterade blocket måste man testa om detta skulle krocka med någon ruta som redan är upptagen – i så fall är rotationen förbjuden och måste avbrytas, och man måste gå tillbaka till den
Poly
man startade med. Det enklaste sättet att göra detta är troligen att man sparar undan det ursprungliga blocket, gör en roterad kopia, ser om kopian krockar, och därefter väljer vilket av de två blocken man ska behålla. -
Lägg till kod för tangentbordsstyrning så att blocket roteras åt höger när man trycker "pil upp" och åt vänster när man trycker "pil ned". Ni kan även anropa funktionen från andra tangenter om ni vill, men de angivna tangenterna måste också fungera för att handledaren ska kunna testa.
Här är det troligen lämpligt att skapa en (men bara en) ny actionklass,
RotateAction
.
Om ramtjockleken
Vi kan nu se varför ramen i en tidigare uppgift behövde vara två block stor. Om ett I
-block
placeras intill ramen och sedan roteras kan det leda till att den nya positionen hamnar två steg utanför
spelplanen.
Tetris 4.8: Borttagning av rader
Uppgift: 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!
När din nya
Board
-metod tar bort alla fulla rader och flyttar ner övriga rader: Kom ihåg att inte flytta medOUTSIDE
-värdena!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. Då testar du även att spelet kan ta bort två rader samtidigt.
Uppgift: Kodinspektion
Glöm inte att titta på kodanalysen i Gitlab igen, om det var ett tag sedan du gjorde det senast.
Avslutning
Här slutar fjärde laborationen. Det är dags att visa och demonstrera slutresultatet för din handledare – det krävs för att få godkänt!
Du är nu klar med de labbar som hör till moment LABx i Ladok, där man får 3 hp och betyg G (eller komplettering).
För högre betyg på moment PRAx i Ladok, och på kursen, krävs att man får högre betyg på projektet och att man implementerar labb 5 (se kursinfo). Om du vill göra det går du direkt vidare till labb 5 och behöver inte lämna in labb 4 just nu.
Om du nöjer dig med 3/godkänt på moment PRAx och kursen behöver du inte genomföra labb 5. Då kan du omedelbart följa hela inlämningsproceduren. Det räcker inte att bara demonstrera och pusha koden till Gitlab!
Labb av Jonas Kvarnström, Mikael Nilsson 2014–2024.
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2024-02-08