Göm menyn

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.

Blanda inte ihop den med andra klasser som heter Timer, t.ex. 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.

Detta är i princip en objektorienterad motsvarighet till högre ordningens funktioner. I stället för att ge timern en enskild funktion som parameter, får den ett objekt som har en funktion/metod som kan anropas.

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

  1. 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 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.

    (Varför inte i konstruktorn? Jo, som tidigare bör konstruktorer initialisera objekt men inte dra igång längre processer.)

  2. 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

  1. 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.

  2. Skapa en TetrisComponent-klass som är subklass till JComponent.

  3. 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.

  4. 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å 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 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().

    Hur initialiserar man en sådan? Ett bra sätt är private final static EnumMap<SquareType, Color> SQUARE_COLORS = createColorMap(), där den statiska metoden createColorMap() skapar, fyller i och returnerar en EnumMap. Detta är ett bra sätt att direkt initialisera ett fält som ska innehålla en Map.

    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 en EnumMap 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 i paintComponent().

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

  1. Implementera paintComponent() enligt beskrivningen ovan.

  2. Ändra i det tidigare GUIt så att TetrisComponent nu används istället för JTextArea plus BoardToTextConverter.

  3. Ä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 indirekt genom att timerhandlingen direkt anropar repaint() i TetrisComponent-objektet (som din TetrisViewer 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.

Överkurs

Ä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 ActionListeners ä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

  1. Skapa ett interface BoardListener med en metod public void boardChanged();

  2. 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.

  3. Skapa i Board en privat metod private void notifyListeners() som loopar över alla element i lyssnarlistan och anropar deras boardChanged()-metoder.

  4. 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.

  5. Ändra TetrisComponent så att den implementerar gränssnittet BoardListener. Implementera metoden boardChanged() så att den anropar repaint().

  6. 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.

Överkurs

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

  1. Ändra spelet så att det utgår från en tom spelplan istället för en framslumpad spelplan.

  2. 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(...) och moveFalling().

    Överkurs

    Det 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".

  3. 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

  1. Vi vill inte behöva ha en metod för varje riktning. Skapa därför enum-typen Direction med värden LEFT och RIGHT.

  2. Som diskuterats ovan vill vi att Board ska innehålla "logiken" för alla de "drag" man kan göra. Implementera alltså en move-metod i Board som anropas vid "draget" sidledsförflyttning. Metoden ska ha en Direction-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 intresserade BoardListeners 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.

  3. 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 i QuitAction i vanliga lösningar.

  4. Testa. Prova även att skicka blocket utanför spelplanens kanter. Om detta leder till en krasch är det än så länge helt OK.

Överkurs

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

  1. Lägg till OUTSIDE som ett nytt enum-värde i SquareType.

  2. 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ända MARGIN, 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ör 2 * MARGIN?

  3. 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 och height som anges. Tänk på att samma gamla värde ska fortfarande lagras i width och height, eftersom dessa fält representerar spelplanens "egentliga" storlek.

  4. 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ör OUTSIDE för att undvika varningar.

  5. 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.

  6. Skriv en metod hasCollision() som returnerar true om det fallande blockets nuvarande position resulterar i att en icke-tom ruta i blocket överlappar en icke-tom ruta på spelplanen. Bara SquareType.EMPTY räknas som tomt.

  7. 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.

  8. 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ältet falling skall i detta läge sättas till null.

  9. 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 Polyn. Därefter kopierar den SquareTypes till den nya Polyn 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

  1. Lägg till en rotate(Direction dir)-metod i Board 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.

  2. 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

  1. 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 med OUTSIDE-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