Göm menyn

Miniprojekt: Tetris

Syfte

Nu övergår vi från att fokusera på enskilda delar av Java och OO till ett större projekt där du får skriva ett fullständigt program: Ett Tetris-spel. Här får du också testa enklare GUI-programmering i Java och får utforska fler OO-begrepp och modelleringsfrågor. En fördel med Tetris som spel är att det inte är så krävande i fråga om t.ex. animering, kollisionsdetektering och andra knepigheter, så att vi kan fokusera mer på objektorienteringen.

För att leda dig åt rätt håll när du omsätter teori i praktik är inledningen i "tutorial"-form. Mot slutet får du gradvis mer och mer frihet – och färre detaljerade ledtrådar.

Förberedelser

Vi förutsätter att du har tagit del av samtliga föreläsningar om objektorienteringens grunder, som vi enligt schemat ska gå genom under de första tre veckorna. Vi har även en introduktion till Tetrisprojektet under en av dessa föreläsningar.

Om du inte har spelat Tetris tidigare: Läs på lite om det och testa någon variant. Beskrivningen förutsätter att du vet hur Tetris fungerar.

Bra att tänka på

Så hur programmerar man ett lite större sammanhängande projekt såsom ett Tetris-spel? Ett bra tips är att bryta ner det i steg eller milstolpar som kan implementeras i tur och ordning – helst på ett sätt som gör att man kan testa varje steg för sig och känna att man faktiskt har åstadkommit något och kommit vidare. Hela projektet är uppbyggd på detta sätt.

Mer att tänka på:

  1. Arbeta på egen tid! Tetrisprojektet är betydligt större än labbarna och vi räknar med att många behöver betydligt mer tid än det som schemalagts.

  2. Använd gärna referenskortet för IDEA när du programmerar. Många funktioner kan vara mycket användbara här och i projektet.

  3. Läs genom varje deluppgift (t.ex. hela uppgift 2.1) innan du utför den.

  4. En viss mängd dokumentation av era lösningar krävs för detta projekt. Kommentera!

  5. Du får använda andra labbmiljöer från och med nu – men koden måste lämnas in i IDEA-format och kontrolleras med IDEAs kodinspektioner!

5.0. Inledning

Bakgrund 5.0.1: Skapa projekt och paket

Om du vill sätter du nu upp ett nytt IDEA-projekt för Tetris. Det går också bra att fortsätta i det gamla projektet, så länge du separerar de gamla och nya klasserna från varandra genom att lägga dem i olika (under)paket.

Fortsätt i samma git-repo som tidigare.

Att göra 5.0.1: Skapa projekt och paket

  1. Skapa eventuellt ett nytt IDEA-projekt. Skapa isåfall först en egen underkatalog för det projektet i den existerande arbetskopian, så det blir enklare att hålla isär det nya från de tidigare labbarna. Följ sedan instruktionerna, och kom ihåg att ändra More Settings / Project format till ".ipr (file based)"!

  2. Skapa ett paket för Tetris, med lämpligt namn. Se tidigare instruktioner vid behov. Följ samma namngivningsregler för paket.

5.1. Spelplan och "kvadrater"

Syfte: Modellering av spelplan!

Det första steget är att skapa lämpliga datatyper för spelets nuvarande tillstånd – speciellt en typ för själva spelplanen, som vi kan kalla Board. Vi vill speciellt fokusera på hur man tänker när man modellerar. Därför går vi genom steg för steg hur vi har tänkt för att komma fram till de föreslagna datatyperna.

Info: Tetris-begrepp!

Tetris har en spelplan där man steg för steg lägger till tetrisblock som "ramlar ner" uppifrån. Varje tetrisblock är tekniskt sett en tetromino, ett specialfall av en polyomino (tetra=4): Det består av fyra sammanhängande kvadrater i en viss färg och ett visst mönster, där totalt 7 olika mönster är möjliga. Nedan syns ett exempel från Wikipedia. Standardnamnen på dessa block är I, J, L, O, S, T och Z.

Info: Separera information från visning

När vi skapar datatyperna för spelplaner och brickor finns det många olika alternativ, med olika fördelar och nackdelar. Vi kan göra följande viktiga observation:

  • En del information och kod har att göra med spelmekaniken: Hur stort spelbrädet är, vilka bitar av brädet som är upptagna, formen och koordinaterna för den bit som håller på att ramla ner just nu, vad som ska hända om en rad blir full, och så vidare. Utan att veta detta kan man överhuvudtaget inte spela.

  • En del har att göra med visningen. Vi kan visa upp den nuvarande situationen som textgrafik (en bokstav per kvadrat) eller "vanlig" grafik. Vi skulle kunna "visa" spelbrädet som en lista på upptagna positioner [A7,A8,B5,...] – inte så intutivt för oss, men det skulle fungera bra för en AI-spelare som vill "se" spelplanen. Vi skulle kunna visa det på en 3D-display för blinda (se bilden), och så vidare.

  • En del har att göra med styrningen. Här kommer vi att använda tangentbordsstyrning, men man kan också tänka sig styrning med en mus, Wii-spelkontroll, röstkontroll ("högerhögerhöger!!!") eller ett protokoll för styrning från en nätverksklient på en annan dator.

Vi ser att spelmekanik, visning och styrning är tre separata ansvarsområden som till stor del kan hanteras oberoende av varandra. Då är det också en fördel om vi kan separera dem kodmässigt. Detta hänger ihop med Single Responsibility Principle och har lett till ett designmönster som kallas Model-View-Controller (MVC). Förenklat kan detta beskrivas så här:

Det är bra att separera den grundläggande informationen om någonting, modellen, från hur den visas, vilket hanteras av vyn, och hur den kontrolleras via ett användargränssnitt, controllern.

Denna separation är ett ideal som man inte alltid kan nå upp till – det finns ofta andra ideal som pekar i andra riktningen. Ofta görs två eftergifter till dessa andra ideal:

  • Det kan vara svårt att separera vy från controller. Om vi till exempel implementerar ett grafiskt schackspel med musstyrning, måste ju styrningen veta exakt var pjäserna är på skärmen för att man ska kunna "dra" dem på rätt sätt. Alltså kombinerar man ofta vy och controller, men separerar dem från modellen. Detta görs till exempel i Swing.

  • Om man implementerar en vy för hela applikationen, måste den känna till allt som kan visas. I ett plattformsspel skulle vyn till exempel veta allt om att rita ut bakgrunder, spelare, olika typer av "fiender", andra aktiva objekt som powerups, och så vidare. Det blir en centralisering av kunskap som istället strider mot principer om modularisering – man vill kunna lägga till nya sorters objekt utan att ändra i en central vy-klass.

    Därför kan det vara lämpligt att varje "skärmobjekt" har en egen modell och en egen vy – en spelarklass vars objekt håller reda på informationen om enskilda spelare, och en klass för spelarvyn som vet hur man ritar ut olika spelare, och så vidare.

Men i Tetris kan vi se spelplanen som en mer sammanhållen enhet där enskilda block inte har egna beteenden. Vi kan därför se till att spelplanen, vår Board-klass, innehåller all information om vilka block och kvadrater som ligger var inom spelplanens rutnät – inte om hur detta ska visas på skärmen (färger, exakta GUI-koordinater, och så vidare). Senare kan vi välja att "visualisera" modellen i textformat, i 2D-format, med 3D-grafik, eller på många andra sätt. Vi får alltså en relativt ren implementation av Model-View-Controller i detta spel, vilket man inte nödvändigtvis får i projektet.

Bakgrund 5.1.1: Kvadrater

Nu ska vi börja tänka genom alternativ för modellering. Läs genom hela rutan innan du börjar genomföra detta. Vi påminner om att vi vill diskutera hur vi tänker, inte bara slutresultatet!

Hur representerar vi spelplanens nuvarande "utseende"? Vi kunde tänka oss att spelplanen (Board) skulle lagra en lista av de hela tetrisblock som har ramlat ner samt x- och y-positioner för dessa block på planen. Men när ett tetrisblock väl har "fallit på plats" kan vissa av dess kvadrater så småningom försvinna medan andra finns kvar (att fulla rader försvinner är ju själva poängen med spelet). Detta vore det rätt krångligt att representera med en lista över tetrisblock eftersom vi skulle behöva hålla reda på vilka delar av varje tetrisblock som fortfarande finns kvar.

Istället kan vi modellera på följande sätt: För varje position (x,y) på spelplanen, är den tom? Och om inte, vilken typ av tetromino tillhör kvadraten på den positionen? Varje gång ett block faller på plats läggs det då in information om fyra nya kvadrater på lämpliga koordinater i Board-objektet. Vi behöver inte hålla reda på vilket tetrisblock varje enskild kvadrat kom från, och vi behöver inte ens separata objekt för varje kvadrat -- bara separata objekt för varje kvadrattyp.

Hur representerar vi då en "typ av kvadrat"? Vi vill kunna representera exakt 8 olika värden: "här finns ingen kvadrat", "här finns en kvadrat från ett I-block", "här finns en kvadrat från ett T-block", och så vidare. Java stödjer enum-klasser, som är gjorda just för situationen när en klass har ett fast antal värden. ("Enum" kommer från "enumeration", dvs. "uppräkning". Vi vill räkna upp de 8 värden som finns, och sedan ska man inte kunna skapa nya.)

Vi ska därför skapa en enum-typ med namn SquareType.

En SquareType ska bara indikera vilken typ av tetromino som har resulterat i en viss kvadrat på skärmen. Den ska däremot inte ha några metoder för att faktiskt rita upp en kvadrat till exempel i en viss färg. Den bestämmer inte heller vilken färg kvadraten ska ha, eller om kvadraten ska visualiseras "platt" eller i pseudo-3D som i exemplet ovan (eller med en riktig 3D-motor där man kan rotera spelplanen), och så vidare. SquareType är ju enbart en del av modellen.

En vy kommer vi att skapa senare, och då är vi till och med fria att visualisera i textformat om vi vill, genom att konvertera varje enumkonstant (som det inte finns särskilt många av) till ett specialtecken som "#" eller "%".

Att göra 5.1.1: Kvadrater

  1. Skapa en enum-klass genom att högerklicka på Tetris-paketet i IDEA, välja New | Java Class och sätta "Kind" till Enum. Ge klassen namnet SquareType.

  2. Lägg in följande inom klamrarna:
    EMPTY, I, O, T, S, Z, J, L

    Klassen ser alltså ut så här:

    public enum SquareType
    {
        EMPTY, I, O, T, S, Z, J, L
    }
    

    Detta betyder helt enkelt att typen SquareType har värdena SquareType.EMPTY, SquareType.I, SquareType.O, och så vidare. Eventuellt klagar IDEA på att namnen är för korta. Detta kan du ignorera.

    Här finns mer information om enum-typer om du skulle vara nyfiken på mera detaljer.

Använda arrayer

Vi kommer strax att börja använda arrayer, som kan ses som en sorts listor med fixerad längd. Om du kommer hit innan vi har gått genom hur detta fungerar kan du se följande kortfattade exempel, eller titta närmare i Java Tutorial, en av våra kursböcker.

Första raden skapar en array med plats för 10 element. Andra raden används för att komma åt det fjärde elementet, som har index 3 (index börjar på 0). Tredje raden visar hur man ändrar ett element.

    SquareType[] array = new SquareType[10];
    SquareType pos3 = array[3];
    array[3] = SquareType.EMPTY;

Men nu är det en tvådimensionell array, ett "rutnät", som vi behöver för spelplanen. Första raden nedan skapar en sådan array. Andra raden används för att komma åt en rad i arrayen, tredje raden visar hur man kommer åt en cell, och fjärde raden visar hur man ändrar en cell.

    SquareType[][] array = new SquareType[rows][columns];
    SquareType[] rowZero = array[0];
    SquareType row5col9 = array[5][9];
    array[5][9] = SquareType.EMPTY;

Bakgrund 5.1.2: Spelplan

Nu behöver vi en spelplansklass, Board, som innehåller information om vilken typ av kvadrat som för tillfället finns på varje position på spelplanen.

Ett Board t.ex. innehålla en tvådimensionell array av lämplig storlek, där varje element är en SquareType. Att arrayer har ett fixerat antal element fungerar utmärkt i Tetris, eftersom spelplanens storlek inte ändras under spelets gång.

Board behöver också ha en konstruktor som tar en bredd och höjd på spelplanen som argument och skapar en lämplig tvådimensionell array. Detta verkar vara det enda vi behöver just nu, men fler fält och metoder kommer säkert till när vi kommer längre med spelet.

Att göra 5.1.2: Spelplan

  1. Skapa klassen Board i Tetris-paketet.

  2. Ge klassen fältet private SquareType[][] squares. Detta deklarerar (en pekare till) en tvådimensionell array av SquareType-värden. Lägg även till privata heltalsfält width och height där konstruktorn nedan ska spara undan brädets dimensioner.

  3. Ge klassen en konstruktor som tar parametrarna width och height. Låt konstruktorn sätta squares till new SquareType[height][width]. Detta skapar den faktiska arrayen som fältet ska peka på.

  4. Alla positioner i arrayen är nu null, dvs. vi har en array av pekare som inte pekar på några verkliga objekt. Se till att konstruktorn fyller alla positioner i arrayen (alla kolumner i alla rader) med "tomma kvadraten" istället.

  5. Gör en main-metod som helt enkelt skapar ett Board med valfri storlek. Testkör detta för att se att programmet inte kraschar, t.ex. genom att man försöker skriva utanför arrayens gränser.

Info: Gömma data

Varför skulle den tvådimensionella arrayen vara privat i Board? För att vi vill att spelplansklassen ska ha full koll över hur planen manipuleras – andra klasser får anropa metoder i planen för att be den göra något, men de får inte gå in och ändra direkt i arrayen. Man ska inte heller skapa en metod som returnerar hela arrayen, eftersom det låter utomstående manipulera den direkt.

5.2. Utskrift av spelplan

Syfte 5.2.1: Visualisera snabbt

Vi vill gärna kunna visa spelplanen för att se om vi har gjort rätt. Istället för att gå direkt på grafiken kommer vi i ett första steg att köra textbaserat. Dels är detta enklare och gör att vi snabbt kan komma vidare, dels kan det vara användbart för debuggning av programmet, och dels är det ett steg i att demonstrera användningen av flera vyer i Model-View-Controller-mönstret och användningen av strängar i Java.

Bakgrund 5.2.1: Vy-klass, förberedelser

Vy-klassen som ska skapas behöver kunna få reda på hur stort ett Board är och vilken typ av kvadrat (om någon) som finns på en viss position. Detta ska den så klart inte få reda på genom att själv titta på fälten i Board (de ska ju vara privata). Istället krävs nya metoder i Board. De metoderna ska bland annat returnera information om en specifik position – returnerar du hela arrayen kan den ju ändras utifrån, och du låser också fast dig för hårt vid arrayrepresentationen.

Att göra 5.2.1: Vy-klass, förberedelser

  1. Skapa getters för width och height i Board samt en metod för att ta reda på vilken SquareType som är lagrad i en given cell (x,y) i squares.

    Skapa inte en metod som returnerar hela squares!

Bakgrund 5.2.2: Strängmanipulation

Javas strängar är oföränderliga: Man kan inte ändra på dem när de väl har skapats. För att inkrementellt bygga upp en lång sträng använder man lämpligen klassen StringBuilder och dess append()-metoder. För att lägga till en radbrytning efter varje rad används "\n" (backslash n). När strängen är klar tas den fram som String-objekt med toString(). Här är ett exempel:

   // Create new StringBuilder.
   StringBuilder builder = new StringBuilder();
   
   // Loop and append values.
   for (int i = 0; i < 5; i++) {
       builder.append("abc ");
   }
   // Convert to string.
   String result = builder.toString();
   
   // Print result.
   System.out.println(result);

Givet en SquareType behöver vi veta vilken symbol den ska illustreras med. Man kunde tänka sig att detta skulle läggas in direkt i själva SquareType-objektet men då har vi blandat ihop modellen (SquareType) med ett specifikt sätt att visa modellen på (text). Istället får vi låta vyn känna till vilka SquareTypes som finns och ha en hjälpmetod som returnerar symbolen motsvarande en viss SquareType.

Att göra 5.2.2: Strängmanipulation

  1. Skapa klassen BoardToTextConverter, med metoden convertToText(Board) som returnerar en strängrepresentation av ett modellobjekt – den skriver alltså inte ut på skärmen själv utan returnerar en sträng som anroparen kan skriva ut. Tomma rutor ska då representeras med t.ex. mellanslag eller bindestreck, och olika typer på kvadrater (SquareTypes) ska representeras som textsymboler, t.ex. "#", "%" och "-".

    Du behöver en nästlad loop som itererar över rader och kolumner i lämplig ordning. Inuti loopen kan man använda en switch-sats med ett case för varje SquareType-värde, något som är möjligt på grund av att SquareType är en enum. Det vill säga, om elementet på en viss position är SquareType.EMPTY lägger du till ett mellanslag, om elementet är SquareType.I lägger du till en annan symbol, osv.

    Glöm inte radbrytningarna "\n" mellan raderna.

    Metoden kan vara static eftersom den inte behöver tillgång till tillståndet i en specifik BoardToTextConverter.

  2. Skriv en testklass, BoardTest, som skapar en tom spelplan, konverterar den till en sträng med hjälp av BoardToTextConverter-klassen, och skriver ut resultatet. När det är klart har du åstadkommit ett testbart ramverk och kan se att du har gjort konkreta framsteg!

5.3. Slumpning av spelplan

Bakgrund 5.3.1: Test

För vår testning vill vi också ha en metod för att slumpa fram en spelplan.

Att göra 5.3.1: Test

  1. Skapa en metod i Board som kan användas för att ersätta det nuvarande innehållet i squares med genererat slumpmässigt innehåll (en slumpmässig SquareType i varje ruta).

    Ett objekt av klassen java.util.Random och metoden nextInt(n) kan användas för att hitta ett pseudoslumptal mellan 0 och n-1. Lagra Random-objektet i ett fält så att du kan skapa det en gång i Board-konstruktorn och anropa det varje gång du behöver ett slumptal! Annars blir det (a) onödigt långsamt, och (b) dålig kvalitet på slumptalen.

    Med hjälp av klassmetoden (statiska metoden) SquareType.values() får vi fram en array som innehåller alla värden i enum-typen SquareType, vilket sedan kan indexeras med slumptalet.

  2. Ändra klassen BoardTest så att den 10 gånger slumpar om spelplanen och skriver ut den. Testkör så att du ser att utskriften fungerar.

5.5. Tetrisblock / tetrominoes

Bakgrund 5.5.1: Polyominos

Efter spelplanen kommer själva Tetrisblocken. Det finns totalt 7 varianter i grundspelet, och vi kan tänkas vilja utöka detta på olika sätt i framtiden – genom nya former (utökning till pentominoes med fem kvadrater per block, penta=5) eller andra varianter (block som exploderar). Frågan är då hur man ska implementera block på bästa sätt för att tillåta sådana utökningar.

  • En generell abstrakt klass + en konkret subklass per blocktyp (L, T, ...)? Det är så klart en möjlighet, men om vi tänker lite närmare på spelet inser vi att blocken egentligen inte beter sig olika. Därför skulle vi egentligen inte ha så mycket nytta av de olika subklasserna – vi skulle till exempel inte behöva "overrida" metoder från den generella klassen för att utnyttja subtypspolymorfism, och alla klasser skulle behöva samma fält för att lagra information. Det enda som skulle skilja sig mellan klasserna är formen på varje block, och detta är data, inte beteende.

    Viktig regel: Skapa bara nya klasser om de verkligen har olika beteende som ger större skillnad i koden. Skapa inte nya klasser när det räcker med att några värden skiljer sig. I projektet ger detta komplettering!

  • En enda klass? Det verkar vara ett bättre val. Och om vi sedan vill implementera exploderande block kan vi göra det med en enda ny subklass, snarare än sju olika subklasser ("exploderande L", "exploderande O", osv) – eller till och med i den ursprungliga klassen.

Vi skapar därför en enda blockklass, som vi kan kalla Poly (kort för Polyomino).

Vi behöver ett sätt för varje Poly-objekt att tala om hur det ser ut. Detta har inte att göra med visualisering (pixlar på skärmen) utan är en del av den fundamentala modellen för ett block (till exempel avgör det hur långt ett block kan falla). Därför ska det definitivt finnas med i Poly-klassen. Eftersom vi ska placera ut Poly-objekt på vårt Board kan vi med fördel representera denna på samma sätt. Vi väljer därför en tvådimensionell array för vår Poly.

Tanken är att klassen Poly ska kunna användas för vilken typ av block som helst, inklusive t.ex. block med 3 eller 5 kvadrater. Därför vill vi inte direkt i Poly hårdkoda kunskapen om exakt vilka blocktyper som finns. I stället låter vi konstruktorn ta in en tvådimensionell array av kvadrater som parameter. Den exakta storleken på arrayen bestäms då av den som anropar konstruktorn.

Att göra 5.5.1: Polyominos

  1. Implementera klassen Poly enligt beskrivningen ovan.

    Klassens konstruktor ska alltså ta in en array som beskriver konfigurationen hos en godtycklig polyomino. Sådana arrayer kommer sedan att skickas in av TetrominoMaker i nästa uppgift – det är där de 7 specifika blocktyperna definieras.

Bakgrund 5.5.2: TetrominoMaker

Någon måste hålla reda på vilka Poly som ska finnas i spelet, hur de ser ut, och hur de skapas. Detta är ett tydligt ansvarsområde som man kan skapa en egen klass för – med en fabriksmetod. Vi skapar alltså klassen TetrominoMaker med dessa metoder:

    public int getNumberOfTypes() { ... }
    public Poly getPoly(int n) { ... }

En anropare kan då skapa en TetrominoMaker och anropa getNumberOfTypes() för att fråga om hur många blocktyper som den kan ge oss (just nu 7).

Anroparen kan sedan slumpa fram ett tal mellan 0 och getNumberOfTypes()-1 och anropa objektets getPoly() med detta som argument. Metoden getPoly() returnerar då ett nytt Poly-objekt av den givna typen.

getPoly() gör detta genom att skapa en 2D-array av lämplig storlek, fylla den med kvadrater motsvarande det begärda blocket, skapa en ny Poly för denna array, och returnera det nya objektet. Om ett ogiltigt nummer skickas in kan ett fel signaleras: throw new IllegalArgumentException("Invalid index: " + n);

Vilken storlek ska då arrayen ha? För att underlätta för senare rotation av blocken är det en fördel att använda en storlek på 2x2, 3x3 eller 4x4, beroende på vilken tetromino som den innehåller. Arrayerna kan då skapas enligt vänstra kolumnen i följande bild (där de övriga kolumnerna visar hur blocken ser ut när de roteras, vilket vi kommer till senare). Blocket O ("kvadraten") ser ut att ligga i en array med storlek 4x3, men för att förenkla för oss själva kan vi helt enkelt lägga det i storleken 2x2 istället.

Bilden kommer från en beskrivning av det standardiserade rotationssystemet.

Att göra 5.5.2: TetrominoMaker

  1. Implementera klassen TetrominoMaker enligt beskrivningen ovan.

    Tänk på att inte skapa för långa metoder – om getPoly() blir för lång när du lägger in skapandet av alla blocktyper i den metoden kan du bryta ut delar till sju olika hjälpmetoder istället.

Info: Fabriker och designmönster

En TetrominoMaker är en fabrik (factory): Ett objekt som kan skapa andra objekt och returnera dem till anroparen. Man kan också bredda detta begrepp och kalla getPoly() för en fabriksmetod (factory method).

Detta ses normalt inte som ett objektorienterat designmönster, även om det finns objektorienterade designmönster med liknande namn, t.ex. factory method pattern och abstract factory pattern. Dessa mönster bygger istället på en specifik och mer komplicerad relation mellan olika objekt och klasser. Att ha ett objekt som kan returnera andra objekt räcker alltså inte för att man ska kunna säga att man använder ett objektorienterat designmönster. (Eller omvänt: Det behövs som tur är inget objektorienterat designmönster för att man ska kunna göra något så enkelt som att returnera ett nytt objekt från en metod!)

Bakgrund 5.5.3: Tetrisblock på spelplanen

Vår spelplan kan representera de block som redan har fallit ner. Detta representeras som en array av SquareTypes, 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? Läs klart innan du börjar!

Alternativ 1 är att de fyra kvadraterna (SquareType) från det fallande blocket läggs in i samma tvådimensionella array som används för att lagra de block som redan har fallit ner. Det har en fördel: Eftersom allt som ska ritas ut ligger lagrat på samma sätt som förut, behöver vi inte behöver ändra uppritningsfunktionen (BoardToTextConverter). Å andra sidan har vi en nackdel: När det fallande blocket flyttar sig ett steg (eller roteras) måste vi komma ihåg var vi hade lagt in det, ta bort dess SquareTypes från dessa positioner i arrayen och lägga till dem igen på nya positioner. Detta kan bli lite omständigt.

Alternativ 2 är att den tvådimensionella arrayen i Board bara lagrar de block som redan har fallit färdigt. Det block som fortfarande faller får Board hålla reda på genom att ha ett fält som pekar på en fallande Poly (eller null om inget block faller just nu) och två fält som anger dess x- och y-koordinater. Då blir det lätt att flytta det fallande blocket genom att helt enkelt ändra dess koordinater, utan att överhuvudtaget peta i Board:s tvådimensionella array. Det underlättar också framtida utökningar där ett block inte nödvändigtvis faller och roterar med ett "hopp" från ett läge till ett annat utan animeras jämnt och mjukt. Å andra sidan behöver vi ändra uppritningsfunktionen så den tar hänsyn till både de block som fallit färdigt och det fallande blocket.

Här ska det andra alternativet användas, eftersom det första ger många studenter problem. Det kräver nya fält och metoder i Board samt utökningar i BoardToTextConverter.

Just nu (utan mjuka animeringar) gäller alltså för varje position (x,y) som ska ritas ut (läggas till i textsträngen): (1) Om positionen täcks av en kvadrat i ett fallande block gäller denna SquareType. (2) Annars gäller den SquareType som anges av Board.

(Just nu kan det finnas positioner (x,y) där det både finns en ruta från spelplanen och en ruta från den fallande tetrominon. I dessa fall är det alltid rutan från den fallande tetrominon, inte rutan från spelplanen, som ska "ritas ut" (läggas till i strängen). Det vill säga, den fallande tetrominon ska aldrig "döljas bakom" de rutor som redan ligger i spelplanen, utan ska alltid synas. När vi har spelmekaniken på plats kommer den här typen av överlapp inte att kunna ske!)

Hur åstadkommer vi det? Ett sätt är att BoardToTextConverter känner till fallande block, och vet hur de ska ritas ut. Ett annat är att implementera en ny metod i Board som vet vilken kvadrat som ska synas på en viss position (enligt ovan) och returnerar detta. Då kan BoardToTextConverter anropa den metoden utan speciell kunskap om fallande block. Välj själv!

Att göra 5.5.3: Tetrisblock på spelplanen

  1. Lägg till ett fält Poly falling i Board. Där ska du lagra en pekare till "den poly som just nu håller på att ramla ner", eller null om ingen poly faller just nu. Du behöver också ha koll på tetrominons nuvarande position (x,y), så lägg till fält för detta. Lägg slutligen till getters så att BoardToTextConverter kan få ut den fallande tetrominon och dess koordinater.

  2. Ändra så att även den fallande tetrominon "ritas" ut (i textsträngen). Se "Hur åstadkommer vi det?" ovan.

5.5. Ett enkelt GUI

Bakgrund 5.5.1: Textgrafik

Det är nu dags att gå över från rent textformat till ett grafiskt gränssnitt.

Om du inte är van vid GUI-programmering med Swing i Java: Vänta till du har varit på GUI-föreläsningen. Se även vanliga lösningar.

För att förenkla steget in till ett GUI-program kommer du att börja med att skriva ett grafiskt gränssnitt som använder en grafisk textkomponent, en JTextArea, för att visa den gamla textrepresentationen av en "spelplan". Det verkar kanske som ett lite underligt sätt, men det gör dels att du kan börja med GUIt redan innan du lär dig hur man skapar egna komponenter, dels att du får ett enklare "mellansteg" så att du inte behöver göra om hela användargränssnittet på en gång. Det gör det också möjligt att göra en väldigt enkel animering av t.ex. nedfallande block genom att du i JTextArea byter ut texten i varje steg, istället för att som med System.out.println() skriva ut varje ny spelplan under den förra.

Detta är något vi gör i just Tetris-projektet men är inte ett krav för det senare projektet!

Att göra 5.5.1: Textgrafik

  1. Skapa en nya klassen TetrisFrame och gör den till en subklass till Javas fönsterklass JFrame.

  2. På något sätt ska TetrisFrame få tillgång till ett Board-objekt. Man kunde tänka sig att TetrisFrame själv skulle skapa ett Board, men det är bättre 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 en spelplan och låta oss interagera med den. Spelstartarens uppgift är (bland annat) att skapa ett spelbräde och ge det till ett fönster.

  3. När du skapar en subklass måste du alltid tänka på vilka parametrar som du måste – eller vill – skicka med till superklassens konstruktor. JFrame har i och för sig en konstruktor som saknar argument, så det går att låta bli att göra något explicit anrop till superklassens konstruktor från TetrisFrames konstruktor, men då får vi ingen fönstertitel. Det är bättre att anropa den JFrame-konstruktor som tar emot en fönstertitel som parameter. Konstruktorn till subklassen TetrisFrame behöver då ange den parametern genom att först av allt anropa t.ex. super("MyWindowTitle"). Se mer information om detta i föreläsningsbildernas diskussion om konstruktorer i subklasser.

  4. I konstruktorn till TetrisFrame (eller i metoder som anropas från konstruktorn) ska du sedan bygga upp ett lämpligt användargränssnitt. Den viktigaste delen just nu är den JTextArea som ska användas till att visa själva "spelplanen". Ange i konstruktorn till JTextArea hur många kolumner och rader som ska visas – detta avgör vilken preferred size textarean ska ha. Antalet kolumner och rader får du reda på genom att fråga det Board som konstruktorn har fått som parameter, inte genom att hårdkoda!

    Hur hittar man vilka konstruktorer som finns? I IDEA, skriv "new JTextArea(" och tryck Ctrl-P. Då får du se alla varianter på parametrar. Välj en av dem och tryck Ctrl-Q så får du dokumentation för parametrarna.

  5. Redan när en TetrisFrame skapas måste textarean ges sitt första innehåll. Här förutsätter vi att du har skrivit en BoardToTextConverter med en metod som returnerar en sträng, inte en metod som skriver ut en spelplan direkt till t.ex. System.out. Då kan du använda den metoden för att få en sträng motsvarande ett Board, och sedan använda textareans metod setText() för att sätta rätt text. Att uppdatera textarean när spelplanen ändras blir en senare uppgift.

  6. I övrigt behövs en del kod i konstruktorn för att definiera layouten (inklusive att sätta fönstrets layouthanterare), se till att fönstret visas, osv. Om du kommer hit innan GUI-föreläsningen kan vi ge följande tips, utöver läroböckerna:

    • Layouten för ett fönster hanteras av en layouthanterare, inte genom att man anger koordinater för komponenter. En lämplig start kan vara att fönstret sätter sin layouthanterare med this.setLayout(new BorderLayout()).

    • Fönstret kan sedan lägga till textarean med this.add(textarea, BorderLayout.CENTER). Detta placerar textarean "i mitten" i fönstret. Eftersom vi inte har lagt till några andra komponenter i fönstret kommer textarean att ta upp hela utrymmet i fönstret.

    • För att rutor i tetrisbrädet skall visas lika stora oavsett vilka tecken man valt att representera olika SquareTypes lägger vi till textarea.setFont(new Font("Monospaced",Font.PLAIN,20));. Det sätter fonten till en känd monospaced font.

    • För att ge fönstret en storlek och göra det synligt: this.pack(); this.setVisible(true);

  7. Testklassen behöver ändras så att den skapar ett Board och öppnar ett TetrisFrame-fönster. När testet körs ska slutresultatet vara att du ser en lagom stor TetrisFrame med en framslumpad spelplan (inte 10 spelplaner, som tidigare). Just nu finns inga menyer eller knappar för att avsluta, så du får använda "stoppknappen" i utvecklingsmiljön för att stänga av testprogrammet.

5.6. Timer för spelloop

Bakgrund 5.6.1: Timer

Så småningom behöver vi ha något sätt att driva spelet framåt, att få block att falla ner i lagom takt och så vidare.

Ett inte alltför ovanligt misstag är att man lägger in en loop som "stegar fram" ett steg, gör en paus av konstant längd, "stegar fram" nästa steg, och så vidare. Problemet är att när pausen är av konstant längd kommer tiden från starten av ett steg till starten av nästa att variera, beroende på hur lång tid själva steget tar, vilket är olika beroende på dator och CPU-belastning på datorn. Vi får göra på något annat sätt.

Som tur är finns en klass som heter javax.swing.Timer som vi kan använda för att få ett "steg" i spelet att köras regelbundet. Blanda inte ihop den med andra klasser som heter Timer, t.ex. java.util.Timer! Här ser vi en anledning till att man lägger klasser i olika paket. Om ni 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.*;
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 anropen, och det som anropas är den handling som vi kallade doOneStep. Genom att anropa setCoalesce(true) ser vi till att anropen inte "köas upp" om ett visst anrop till doOneStep() skulle ta för lång tid. Vi kan starta timern när spelet börjar och stoppa den när spelet är över.

I övrigt kan även clockTimer.setLogTimers() vara intressant för felsökning. Se även Javadoc-dokumentationen som vi länkar till ovan!

Att göra 5.6.1: Timers

  1. Skapa någonstans en Timer som kör en handling med regelbundna mellanrum, en gång i sekunden. Handlingen ska slumpa om spelplanen (inte byta ut Board-objektet utan ändra i det!) på motsvarande sätt som du gjort tidigare, och visa resultatet i textarean som du skapade i en tidigare uppgift. Handlingen behöver alltså på något sätt få tillgång till både spelplan och textarea. Slutresultatet ska helt enkelt bli att du ser en enkel "animering" på skärmen medan programmet körs. Du behöver inte kunna stoppa timern.

5.7. Kodinspektion!

Bakgrund 5.7.1: Kodinspektion

Det är viktigt att man granskar sin egen kod då och då, för att eventuella problem inte ska bli för långlivade. Ju tidigare man kan förbättra koden, desto längre tid har man att utnyttja de förbättringarna. Det gäller ännu mer i den här kursen, när ni själva håller på att lära er objektorientering och Java.

Det är ofta en bra idé att stanna till ett tag och gå genom koden man har skrivit "för hand". Men nu ska vi för första gången också börja titta närmare på vilken hjälp man kan få från automatiska verktyg – i vårt fall IDEAs kodvarningar.

Att göra 5.7.1: Kodinspektion

  1. Om du normalt använder en annan miljö: Importera ditt projekt till IDEA. Det behöver du ändå kunna göra inför inlämningen.

  2. Kör IDEAs kodinspektioner med profil TDDD78-2017-v1.

    1. Välj Analyze | Inspect Code i menyn.
    2. Välj inspektionsprofil "TDDD78-2017-v1" och tryck OK.
      Använd INTE defaultprofilen.

    Det här är till stor del ett sätt att lära sig skriva kod som inte bara gör vad den ska utan även är välskriven och strukturerad.

    Förstår du inte en varning? Utmärkt! Då har du en chans att lära dig något nytt, redan innan du lämnar in koden! Läs IDEAs inbyggda beskrivningar, läs våra utökade beskrivningar, och om det inte hjälper, fråga gärna assistenten eller examinatorn.

    Inget automatiskt inspektionsverktyg är 100% korrekt. Varningen ska alltså oftast tolkas som "har du tänkt på det här?" snarare än "du har fel!". Så småningom måste sådana kommenteras på plats i koden, med god motivering till varför varningen var "ogiltig" i detta fall. Vissa varningar kanske du vill kommentera redan nu.

    Det kan även finnas varningar som kommer från att du helt enkelt inte är klar än, t.ex. varningar för kod som inte används. De varningarna kan du så klart ignorera i det här läget.

    Saknar du inspektionsprofilen? Om du kör på universitetets datorer, och installerade IDEAs konfigurationsfiler korrekt, borde profilen finnas där. Felanmäl till oss! Om du kör hemma behöver du ladda ner konfigurationsfilerna (se "arbeta hemma"-sidan).

5.8. Mer GUI-programmering: Menyer!

Bakgrund 5.8.1: Menyer

Innan vi går vidare med själva spelmekaniken är det dags att vidareutveckla resten av det grafiska gränssnittet för spelet en liten aning.

Att göra 5.8.1: Menyer

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

  2. Implementera en händelsehanterare – en ActionListener eller en Action, enligt vad vi har diskuterat under föreläsningarna. Se på detta sätt till att valet "avsluta" gör att en dialogruta visas med hjälp av JOptionPane, där man får bekräfta att man vill sluta. Vid bekräftelse ska spelet avslutas med hjälp av System.exit(0), som "stänger av" programmet med felkod 0 = "inget fel".

Att göra: Kodinspektion

Glöm inte att inspektera koden igen, om det var ett tag sedan du gjorde det senast. Använd Analyze | Inspect Code i menyn. Se till att inspektionsprofilen "TDDD78-2017-v1" är vald och tryck OK. Gå genom de varningar du ser. Fråga handledaren om du inte förstår en varning eller tycker att den är omotiverad.

5.9. En grafisk spelplan

Bakgrund 5.9.1: Grafik del 1 – komponenten

Det är nu dags att gå över från en textbaserad visning till en helt grafisk.

Om du inte är van vid grafikprogrammering med Graphics2D i Java: Vänta till du har varit på grafik-föreläsningen. Se även vanliga lösningar.

Att göra 5.9.1: Grafik del 1 – komponenten

  1. Vi kommer att förändra TetrisFrame 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. Högerklicka på TetrisFrame och välj Refactor -> Copy och döp den till TetrisFrame_v1, 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å tar plats.

Grafik

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!

Bakgrund 5.9.2: Grafik del 2 – utritningen

En JComponent behöver kunna rita upp sig själv när som helst, när Swings bakgrundstråd anropar den. Vi skall därför implementera metoden paintComponent() så att den ritar upp spelplanen som det ser ut just nu. Om metoden blir stor kanske den behöver delas upp i delmetoder / hjälpmetoder för att förbättra läsbarheten.

Tänk på att paintComponent() behöver rita upp både bakgrunden, de kvadrater som har ramlat ner på spelplanen, och det block (om något) som håller på att ramla ner.

Fundera på hur paintComponent() ska veta vilken färg (java.awt.Color) den ska använda för varje SquareType.

  • Ett sätt är att använda en switch-sats med en gren för varje SquareType.

  • Ett annat sätt är att den har en EnumMap<SquareType,java.awt.Color> som lagrar mappningen. En EnumMap är en mappning, en uppslagningstabell liknande det som kallas dictionaries i Python – i detta fall med en SquareType som nyckel och en Color som värde. I Java finns dock ingen specialsyntax för mappningar, utan det är en klass som alla andra, och objekten manipuleras med metoder som put() och get().

    Med denna lösning kan din komponent till och med ta in en EnumMap som konstruktorparameter, så kan den som skapar komponenten lätt konfigurera uppslagstabellen uppslagningstabellen (och därmed färgerna) utifrån – betydligt mer flexibelt än att hårdkoda en switchsats i paintComponent().

paintComponent() exempel

För att rita ut grafik används override på funktionen paintComponent() som ärvs från JComponent. Det kan t.ex. se ut så här:

    @Override
    protected void paintComponent(Graphics g) {
	super.paintComponent(g);
	final Graphics2D g2d = (Graphics2D) g;

	g2d.setColor(Color.GREEN);
	g2d.drawRect(a, b, c, d);
    }

Här anropas super.paintComponent() för att rensa bakgrunden i komponenten. I exemplet vill vi rita ut en grön rektangel med hjälp av ett Graphics2D-objekt, men får ett Graphics-objekt som inparameter. Därför castas detta först till Graphics2D innan vi ritar ut rektangeln. Det finns många olika sätt att rita i en komponent och detta är bara ett exempel. Mer information finner du på föreläsningarna samt nätet.

Att göra 5.9.2: 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 TetrisFrame 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.

Magiska konstanter

Här är det läge att påpeka att du ska undvika magiska konstanter, i betydelsen "konstanter som används i koden utan någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas ut i en konstant storlek av 30x30 pixel är det bra att lägga in namngivna konstanter som private final static int SQUARE_WIDTH = 30; i klassen och sedan använda denna konstant varje gång du refererar till bredden. Då blir det mycket lättare att läsa uttrycken och att förstå hur koden fungerar. (Vi använder "final" för att konstanten inte ändras, "static" för att vi inte behöver en kopia av konstanten för varje TetrisComponent som skapas.)

Speciellt svårt att läsa magiska konstanter är det om du gör beräkningar som utgår från dem och sedan stoppar in de uträknade värdena i koden. Till exempel är det oftare lättare att förstå betydelsen hos "if (x > SQUARE_WIDTH - MARGIN)" än "if (x > 27)", även för den som vet att SQUARE_WIDTH är 30 och MARGIN är 3.

5.10. Observer / Observable

Bakgrund 5.10.1: Lyssnare och notifiering

I det program du nu har skrivit har du själva fått hitta på ett sätt att få spelplanen att ritas om när något ändras. Det föreslagna sättet har varit att en timerhandling som utförs med regelbundna mellanrum både ser till att spelmodellen uppdateras (ett block faller nedåt) och att TetrisComponent därefter ritar om sig. Problemet med den lösningen är att timerhandlingen måste känna till precis vilka som vill veta när spelplanen ändrar sig. Vi får därmed en alldeles för stark koppling mellan två klasser (timerhandling och TetrisComponent) som egentligen inte alls borde behöva känna till varandra.

Ett annat sätt är att använda designmönstret Observer. I korthet innebär detta att vi ser spelplanen som något "observerbart" (Observable). Varje gång planen förändras på något sätt (t.ex. genom att ett block faller ner ett steg) kan den själv informera en eller flera "observatörer" (Observer). Varje observatör kan då själv registrera sig som intresserad av vad som händer med ett visst Board.

Fördelen med detta tankesätt är bland annat att timerhandlingen kan fokusera helt på att driva spelet framåt ett steg. När spelet drivs framåt ett steg är det sedan spelplanen som talar om för alla intresserade att något har hänt. En av dessa intresserade råkar vara TetrisComponent, som tidigare har registrerat sitt intresse.

Vi har faktiskt redan sett en användning av detta mönster i Javas GUI-bibliotek. Alla komponenter är observerbara, och alla lyssnare är observatörer. Metoder som addActionListener() lägger till en lyssnare, en observatör, som är intresserad av en specifik typ av handling. Klasser som JButton känner inte till era specifika lyssnare i förväg, men de har ett sätt för lyssnarna att registrera sitt intresse.

Att göra 5.10.1: 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.

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.

5.11. Fallande block

Bakgrund 5.11.1: 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.

Eftersom du nu har programmerat en del i Java börjar vi också gradvis beskriva uppgifterna lite mindre detaljerat. Fråga gärna assistenten om du vill ha hjälp!

Att göra 5.11.1: 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.)

  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.

5.12. Tangentbordsstyrning

Bakgrund 5.12.1: Styra tetrominos

Dags att lägga till tangentbordsstyrning av block så att de kan flyttas åt höger och vänster. Tangentbordsstyrning kan göras med hjälp av key bindings kopplade till din TetrisComponent. Se även vanliga lösningar!

Tänk på att man normalt kan flytta ett block flera steg åt sidan under samma tick. Eftersom key bindings hanteras asynkront, "i bakgrunden" ska detta fungera automatiskt.

Att göra 5.12.1: Styra tetrominos

  1. Som diskuterats ovan vill vi att Board ska innehålla "logiken" för alla de "drag" man kan göra. Implementera alltså metoder i Board som anropas vid "draget" sidledsförflyttning. Dessa metoder 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.

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

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

Info: Synkronisering

Den som är bekant med trådad programmering undrar kanske om det nu kan hända att Board anropas från flera trådar 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.

Att göra: Kodinspektion

Glöm inte att inspektera koden igen, om det var ett tag sedan du gjorde det senast. Använd Analyze | Inspect Code i menyn. Se till att inspektionsprofilen "TDDD78-2017-v1" är vald och tryck OK. Gå genom de varningar du ser. Fråga handledaren om du inte förstår en varning eller tycker att den är omotiverad.

5.13. Kollisionshantering och "game over"

Bakgrund 5.13.1: Kollisioner

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:

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!

Att göra 5.13.1: Kollisioner

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

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

  3. Eftersom andra klasser inte ska vara medvetna om ramen måste gettern för squares som skrevs i 5.2.1 ä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 (2,2) 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.

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

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

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

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

Att göra: Kodinspektion

Glöm inte att inspektera koden igen, om det var ett tag sedan du gjorde det senast. Använd Analyze | Inspect Code i menyn. Se till att inspektionsprofilen "TDDD78-2017-v1" är vald och tryck OK. Gå genom de varningar du ser. Fråga handledaren om du inte förstår en varning eller tycker att den är omotiverad.

5.15. Rotation av tetrisblock

Bakgrund 5.15.1: 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() 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:

Att göra 5.15.1: Rotation

  1. Lägg även till en rotate()-metod i Board och se till att den roterar det block som just nu håller på att falla ner.

    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 rotationsfunktionen i Board anropas då man trycker "pil upp"-tangenten. Ni kan även anropa funktionen från andra tangenter om ni vill, men pil upp måste också fungera.

Ramtjocklek

Vi kan nu se varför ramen i uppgift 5.13 behöver 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.

5.15. Borttagning av rader

Att göra 5.15.1: 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.

5.17. Poänghantering

5.17.1. Poänghantering

Nu är det dags att lägga till poänghantering i spelet. Vi kan använda en enkel poängsättning där man får:

  • 100 poäng om 1 rad försvinner
  • 300 poäng om 2 rader försvinner på samma gång
  • 500 poäng om 3 rader försvinner på samma gång
  • 800 poäng om 4 rader försvinner på samma gång

Detta är en förenkling av ett vanligt poängsystem som även tar hänsyn till olika spelnivåer (hastigheter) och andra finesser som vi inte har implementerat i den här enkla Tetris-varianten.

5.17.1. Poänghantering

Inför poänghantering i spelet enligt poängsättningen ovan.

  1. Bestäm en lämplig plats där nuvarande poäng kan lagras.

  2. Se till att nuvarande poäng hela tiden visas någonstans i spelet.

5.17.2. Highscorelista

Nu fungerar poängen, men man kan bara se dem medan man spelar. Det vore bra om man kunde lagra poängen så att man efter varje spel kan se en highscorelista.

Vi tänker oss nu att man vill kunna spela många parallella spel, och att man i alla de spelen vill kunna använda sig av samma gemensamma highscorelista. Då är frågan hur samtliga spel ska kunna komma åt denna enda lista. En möjlighet är att det finns en central plats där alla spel "skapas", och att denna alltid skickar med highscorelistan till spelen. Det skulle säkert fungera i det här fallet. En annan möjlighet är att vi använder oss av ett designmönster som heter Singleton. Där ser man till att det enbart kan skapas ett enda objekt av en viss klass, och att detta objekt blir tillgängligt på ett enkelt sätt för de som behöver det.

En Singleton med "ivrig initialisering" (skapa objektet direkt, inte vid första anropet) kan i Java implementeras på det här sättet:

public class HighscoreList {
    // Skapa bara objekt av denna typ EN gång, när klassen laddas.
    private static final HighscoreList INSTANCE = new HighscoreList();

    // Privat konstruktor, så ingen annan kan skapa fler objekt.
    private HighscoreList() {
        ...
    }
 
    // Låt andra komma åt det unika objektet
    public static HighscoreList getInstance() {
        return INSTANCE;
    }

    // Flera fält och metoder, precis som i vilken klass som helst
    private ...
    public ... 
}

För att komma åt det unika HighscoreList-objektet kan man nu helt enkelt anropa HighscoreList.getInstance(). Därefter kan man använda detta objekt som vanligt, men man vet att alla andra som anropade HighscoreList.getInstance() också har exakt samma objekt.

Detta är en helt annan sak än att göra alla fält och metoder i HighscoreList statiska! Den "lösningen" gör att vi helt undviker att använda objekt och går över till procedurell programmering med globala procedurer och globala variabler. Med Singleton är en HighscoreList verkligen ett objekt, som följer objektorienteringens principer. Den statiska variabeln (INSTANCE) och metoden (getInstance) används bara för att på ett enkelt sätt komma åt detta objekt.

Vi visar Singleton för att ge er en större repertoar av tekniker, men som alla tekniker är Singleton inte alltid bäst. Ett alternativ till Singleton är att någon skapar ett objekt, och sedan "skickar med" det till alla som behöver det. Detta förenklar också om man någon gång skulle kunna vilja ha fler än ett objekt av den givna typen. Singleton är alltså främst användbart där man (1) vet att man absolut aldrig någonsin skulle kunna vilja ha mer än ett objekt av den typen, och (2) tycker att nackdelen med att göra det objektet globalt tillgängligt överväger nackdelen med att istället skicka runt det till de som behöver det.

Det finns även flera andra sätt att implementera Singletons i Java. Viktigt är att det finns flera sätt som verkar rimliga på ytan, men som inte alls fungerar. Slå alltid upp korrekt sätt så du är säker att det fungerar! Överkurs: Vissa sätt att implementera double-checked locking fungerar inte i multitrådade program.

5.17.2. Highscorelista

  1. För att en highscorelista ska fungera måste man kunna fortsätta spela en ny omgång när ett spel är över. Man kan t.ex. skapa ett nytt Board och en ny GUI-komponent som visar denna. Eftersom GUI-programmering inte är ett fokus kan man till och med (för enkelhetens skull) skapa ett helt nytt fönster som visar upp det nya spelet (och helst ta bort det gamla genom att anropa dess dispose()-metod). Se till att detta fungerar.

  2. Skapa en Highscore-klass som innehåller antal poäng (ett heltal) plus namn på den som fick poängen (en sträng).

  3. Skapa en HighscoreList-klass. Den ska vara en Singleton enligt ovan, och ska innehålla funktionalitet för att lägga till highscores samt få fram samtliga highscores som finns i listan. (Den kan t.ex. innehålla en List<Highscore> där detta sparas.)

  4. Så snart en spelomgång avslutas ska programmet fråga användaren efter ett namn (vi har tidigare sett hur man använder inmatningsdialoger för detta). Ett Highscore-objekt med rätt namn och poäng ska skapas och läggas till i highscorelistan. För tillfället behöver listan inte sorteras i rätt ordning.

  5. Därefter ska programmet visa åtminstone de 10 första personerna i highscorelistan, och vänta på en knapptryckning eller liknande innan nästa spel börjar. Listan kan visas med drawString(), som en sträng i en textkomponent, som en sträng i en dialogruta, eller helt enkelt genom att den skrivs ut i läsbart format med System.out.println(). Vi fokuserar ju på Singleton-mönstret, inte på hur snygg visningen är.

Vi har nu sett hur Singleton-mönstret fungerar.

Viktigt: Poängen med Singleton är att man bara ska få ett objekt av en klass. Den ska alltså bara användas när det verkligen är det man vill, nu och i framtiden! Om vi hade tänkt implementera grupper av spelare (kanske olika "divisioner"), där varje grupp ska ha sin egen highscorelista, kan vi så klart inte använda en Singleton.

Fördel: Eftersom vem som helst kan anropa en statisk metod för att få tag på klassens unika objekt, behöver man inte skicka runt det objektet (potentiellt genom flera nivåer av metodanrop och klasser) för att den som behöver det ska få tag på det.

Nackdel: En Singleton är en sorts global variabel. Sådana har också nackdelar. De kan t.ex. leda till onödiga beroenden mellan olika delar av ett program, och svårigheter att reda ut vilka beroendena egentligen är (eftersom olika delar man har tillgång till samma objekt trots att objekten inte uttryckligen "skickas" dit).

5.17.3. Sorterade highscores

Nu kan vi se highscorelistor, men de hamnar i godtycklig ordning. Vi vill se den högsta poängen först!

För att åstadkomma detta behöver vi kunna sortera listorna. Den större delen av en sorteringsalgoritm brukar vara generell och fungera för godtyckliga sorters element, och den delen finns så klart "inbyggd" i Java. Men sortering bygger oftast på att man kan jämföra två godtyckliga element i en lista och tala om vilket av dem som borde vara först, och just denna del är helt och hållet specifik för varje elementtyp. I vårt fall handlar det alltså om att tala om för sorteringsalgoritmen hur man tar reda på vilket av två Highscore-objekt som ska vara först i den sorterade listan.

Det finns flera olika sätt att göra detta på. Ett sätt är genom designmönstret Strategy. Detta låter oss "plugga in" jämförelser genom att först skapa en jämförare, ett objekt som vet hur man jämför Highscore-objekt, och därefter skicka med denna jämförare som parameter till sorteringsmetoden. Jämförarobjektet är alltså ett sätt att implementera en sorteringsstrategi som sorteraren kan använda sig av.

Vilken typ ska "jämförar-parametern" till sorteringsmetoden ha? I Javas standardsortering används gränssnittet Comparator. Detta är ett "generiskt" gränssnitt, som också talar om vilken typ <T> man kan jämföra ett objekt med. Vi diskuterar detta i detalj under föreläsningen om datatyper. Har du inte läst om detta än kan du ändå följa med i instruktionerna.

public interface Comparator<T> {

    /** Compares its two arguments for order. 
    Returns a negative integer, zero, or a positive integer 
    as the first argument is less than, equal to, or greater than
    the second.  */
    public int compareTo(T o1, T o2);
}

Detta gränssnitt finns redan i Java. Vi behöver nu implementera det i en poängjämförare. Vad vi jämför (typen T) är alltså Highscore:

public class ScoreComparator implements Comparator<Highscore> {

    public int compare(Highscore o1, Highscore o2) {
        ...
    }
}

Vi kan sedan sortera listan:

List<Highscore> scores = ...;
...

scores.sort(new ScoreComparator());

5.17.3. Sorterade highscores

  1. Skapa en jämförarklass, en ScoreComparator, enligt ovan.

  2. Använd denna för att se till att highscores sorteras (antingen vid visning eller varje gång en highscore läggs till i listan).

  3. Testa!

5.18. Powerups

Info: State-mönstret

Det är nu dags att titta på en teknik för att "plugga in" olika beteenden i en klass.

Grundtanken är att vi har en klass som har ett stabilt grundbeteende, men där vissa delar av beteendet kan ändras då och då, kanske ganska radikalt. Till exempel kanske vi skriver ett spel där spelaren kan vara i olika lägen beroende på om man har hittat vissa "powerups" som ger extra krafter. Det kan man ju ordna via vanliga villkorssatser:

public enum PlayerMode {
    NORMAL,	// Standard mode
    STRONGER,	// Stronger and less vulnerable
    FASTER,	// Runs faster
    GHOST, ...	// Can walk, run or jump through solid matter
}
public class Player {
    private PlayerMode mode;
    public void jump() {
        if (mode == PlayerMode.NORMAL) {
            // Ordinary jump
        } else if (mode == PlayerMode.STRONGER) { 
            // Jump a bit higher
        } else if (mode == PlayerMode.GHOST) {
            // Can jump through platforms from
            // below, land on platforms from above
        } ...
    }
    public void run() {
        ...
    }
}

Men att använda villkorssatser har vissa nackdelar:

  • Informationen om vad det innebär att vara STRONGER sprids ut i många metoder (jump, run, ...).

  • Varje gång man lägger till en ny typ av powerup måste man ändra Player-klassen!

  • Alltså går det inte heller att lägga till nya typer av powerups om man inte har källkoden till Player...

En idé kanske kunde vara att göra Player abstrakt och skapa nya konkreta subklasser: GhostPlayer, StrongPlayer, FastPlayer, och så vidare. Grundbeteendet skulle då ligga kvar i Player, medan varje subklass skulle ha sin egen implementation av jump() och run(). Men då måste vi byta ut spelarobjektet varje gång spelaren får en ny förmåga! Det går ju inte att byta klass på ett existerande objekt. Det vi vill ha är en spelare, vars beteende ändras.

Därför kan man ibland använda den alternativa lösningen att plugga in ett beteende.

public interface PowerupState {
    public void jump(Player p);
    public void run(Player p);
}
public class NormalState implements PowerupState  {
    public void jump(Player p) {
        // Make p jump a short distance
    }
    ...
}
public class GhostState implements PowerupState  {
    public void jump(Player p) {
        // Significant difference in code:
        // Jump through other objects
    }
    ...
}
public class Player {
    private PowerupState pstate;
    public Player() {
        this.pstate = new NormalState();
    }
    public void jump() {
       ...
       // Ask the state object to do the actual jump – delegate!
       pstate.jump(this);
       ...
    }
    ...
}

Det vill säga:

  • Vi vill kunna byta ut delar av beteendet hos ett objekt. Man kan säga att objektet är i flera olika tillstånd (state), och att varje tillstånd ger ett eget beteende.

  • Skillnaderna i beteende är inte sådana som enkelt representeras med ett enda värde – om bara hopplängden skulle vara olika, skulle vi helt enkelt ha ett fält "int hopplängd;".

    Det är inte heller så att vi bara vill ändra en eller ett par rader kod för att implementera skillnaden. Vi behöver en hel del separat kod för varje tillstånd.

  • Därför skapar vi ett gränssnitt som beskriver just det beteende vi vill byta ut: Hoppandet och springandet. Sedan skapar vi en klass för varje konkret variation av beteendet: Hoppa och spring på normalt sätt, hoppa och spring som ett spöke, och så vidare.

  • Player har ett objekt av den givna typen (PowerupState). Detta sätts från början till ett objekt som representerar någon form av standardbeteende (new NormalState()).

  • När man dynamiskt vill byta beteende: this.pstate = new FasterState();

På det sättet använder man objektorientering med sen bindning (dynamisk dispatch) för att variera beteendet, istället för en villkorssats (if / switch): När man anropar pstate.jump() är det den för tillfället aktuella tillståndsklassens kod som körs.

Viktigt: Detta används alltså om man dynamiskt vill byta ut lite komplexare beteenden. Om koden verkar bli mer komplicerad av att använda detta mönster, är det antagligen bättre att låta bli!

5.18.1. Gränssnitt och defaultimplementation

I Tetrisprojektet vill vi att man i vissa lägen ska kunna få "extra krafter" som hjälper till i spelet. Detta kallas ofta powerups.

Varje powerup påverkar spelmekaniken på ett specifikt sätt. Till exempel kan det finnas olika powerups som påverkar kollisionshanteringen. I standardläget stannar ju en fallande Poly så snart den når ner till en kvadrat som redan finns på spelplanen. Vi kan också tänka oss att en Poly skulle falla rakt genom existerande kvadrater, så att den rensar en väg ner till ett hål som var svårt att fylla, eller att den skulle falla tungt så att de underliggande kvadraterna lossnar och ramlar så långt ner de kan.

Här vill vi alltså kunna plugga in olika beteenden när en Poly når ner till en existerande kvadrat. Vi skulle kunna göra detta med en enum-variabel som räknar upp alla tänkbara beteenden (NORMAL, FALLTHROUGH, HEAVY) tillsammans med en switch-sats som gör att koden beter sig olika beroende på nuvarande värdet på denna variabel. I så fall skulle vi centralisera kunskapen om alla existerande beteenden. Nu väljer vi istället att modularisera detta via State-mönstret som diskuterades ovan, så att varje beteende blir en egen klass.

Vi kommer därför att behöva ett gränssnitt för kollisionshanterare. Vi ska ge vissa tips om hur detta ska implementeras, men låter er i övrigt basera implementationen på vad ni har lärt er av föreläsningen om designmönster.

Första steget blir att flytta ut koden för kollisionshantering till en egen klass.

5.18.1. Flytta kod för kollisionshantering

  1. Skapa ett gränssnitt som heter CollisionHandler. Detta ska ta över en del av funktionaliteten som just nu ligger i Board och bör därför ligga nära den klassen.

  2. Lägg till en hasCollision()-metod motsvarande den du nu har i Board (men utan själva implementationen, eftersom detta är ett gränssnitt).

  3. Låt din nya hasCollision()-metod få en ny parameter, av typ Board. Kollisionshanteraren måste ju veta vilket spelbräde den ska leta efter kollisioner i!

  4. Skapa en klass som heter DefaultCollisionHandler. Flytta implementationen av hasCollision() från Board till den nya klassen. Lägg även här till den nya parametern.

    Den kod du just flyttade försöker titta på spelbrädesinformation i this, men this är ju numera en kollisionshanterare, inte ett Board. Därför behöver koden skrivas om en del så att den nya hasCollision() tittar inuti det Board som den fick som parameter.

    Om du tidigare har implementerat hasCollision() så att den direkt använder sig av den interna representationen (SquareType[][]-arrayen) kan detta bli lite knepigt, eftersom vi inte vill att externa klasser som DefaultCollisionHandler ska ha tillgång till den representationen. Då får man istället skriva om den nya hasCollision() så att den använder sig av existerande, och kanske nya, getter-metoder för att hämta ut den information som krävs.

    Förtydligande: Skapa inte en metod som plockar ut den interna SquareType[][]-arrayen, utan använd mer finkorniga metoder såsom getWidth(), getHeight() och getSquareAt()!

  5. Ge Board ett privat fält av typ CollisionHandler. Sätt detta fält till ett objekt av typ DefaultCollisionHandler. Se till att Board anropar hasCollision() i detta fält, istället för att försöka anropa hasCollision() i "sig själv".

  6. Testa! Om allt är korrekt ska spelet fortfarande fungera exakt som tidigare.

5.18.2. En första powerup

Nu är det dags att skapa en första powerup – ett första alternativ till DefaultCollisionHandler.

5.18.2. En första powerup

Er första powerup ska låta en Poly falla rakt ner, genom existerande kvadrater, ända till den når botten.

  1. Skapa klassen Fallthrough som implementerar CollisionHandler.

  2. Kopiera hasCollision()-koden från DefaultCollisionHandler.

  3. Modifiera koden så att:

    • Om den fallande brickan överlappar OUTSIDE, detekteras en kollision.

    • Om den fallande brickan överlappar någon annan kvadrat, tas denna kvadrat bort från spelplanen. Detta kan kräva nya metoder i Board för att ta bort existerande kvadrater.

  4. Hitta på ett sätt att trigga denna Powerup. Till exempel kan man få en Fallthrough var tionde Poly, eller varannan gång man har tagit bort en rad, eller något annat villkor. (Ofta får man powerups när man "plockar upp" dem någonstans på skärmen, men detta kan vara onödigt komplicerat att implementera.)

    När Board triggar en Powerup ska den alltså helt enkelt sätta om kollisionshanteraren till en new Fallthrough(). När nästa Poly har fallit ner ska Board sätta tillbaka kollisionshanteraren till en new DefaultCollisionHandler().

    Se till att skärmen hela tiden visar om man har en powerup, och i så fall vilken. Man kan t.ex. lägga till en metod getDescription() i CollisionHandler, så att varje CollisionHandler (inklusive default) kan ge en egen beskrivning av sig själv.

  5. Testa!

  6. Fick DefaultCollisionHandler och Fallthrough onödigt mycket gemensam, repeterad kod? Öva gärna på att skapa en gemensam (abstrakt) superklass där det gemensamma kan representeras.

5.18.3. En andra powerup

För att verkligen utforska det här sättet att programmera borde vi skapa en powerup till.

5.18.3. En andra powerup

Skapa en powerup där den Poly som faller är extremt tung, så att den knackar loss underliggande kvadrater och trycker dem nedåt. Detta kan vara en väldigt kraftfull powerup, eftersom ett tryck på rätt plats kan få många rader att bli fulla på samma gång.

  1. Skapa en ny kollisionshanterare: Heavy. Se till att den "knackar loss" kvadrater enligt ovan. Exakt vilka regler den följer kan du själv bestämma. Man kan knacka ner kvadraterna ett steg i taget, eller låta dem falla ner så långt det går på en gång. Man kan ta noggrann hänsyn till vilka delar av den fallande brickan som faktiskt får kontakt med underliggande kvadrater, eller låta kvadrater falla längs hela brickans bredd. Huvudsaken är att du (1) implementerar en alternativ powerup, (2) skapar en powerup som verkar någorlunda rimlig, och (3) inte lägger alltför mycket tid på att få den perfekta spelmekaniken.

    Denna hanterare kommer att behöva kollapsa rader, om de kvadrater som "lossnar" ramlar ner och fyller rader. Detta kan göras genom att man inför en särskild metod i Board för att kollapsa en given rad.

  2. Se till att Heavy kan triggas på något sätt.

  3. Testa och iterera till du är nöjd!

5.19. Kodgenomgång

Info: Kodgenomgång

Nu är det dags att gå genom koden innan inlämning och att demonstrera resultatet för handledaren. Du kan göra detta i vilken ordning du vill.

5.19.1: Allmän kodgenomgång

Även om spelet nu fungerar som det ska, är det inte säkert att koden ser ut riktigt som du vill. Gör en ordentlig genomgång, nu när du har lärt dig mer! Det är viktigt att verkligen göra detta, och inte bara säga att "jag vet att jag borde göra annorlunda här". Som vi har sagt på andra ställen lär man sig ofta mer på att göra rätt än på att veta rätt.

5.19.2: Inspektioner

Missa inte!

Ett krav är att köra IDEAs kodinspektioner med profil TDDD78-2017-v1. Har du inte använt IDEA måste du importera projektet till IDEA för att köra inspektionerna.

  1. Välj Analyze | Inspect Code i menyn.

  2. Välj inspektionsprofil "TDDD78-2017-v1" och tryck OK.
    Använd INTE defaultprofilen. Saknar du "TDDD78-2017-v1" jobbar du antagligen hemma och har glömt att importera kursens inställningsfil.

Det här är till stor del ett sätt att lära sig skriva kod som inte bara gör vad den ska utan även är välskriven och strukturerad. Därför är detta en egen uppgift i Tetrisprojektet, inte bara ett inlämningskrav.

Förstår du inte en varning? Utmärkt! Då har du en chans att lära dig något nytt, redan innan du lämnar in koden (och innan du börjar med projektet)! Läs IDEAs inbyggda beskrivningar, läs våra utökade beskrivningar, och fråga gärna handledaren eller (i sista hand) examinatorn.

Inget automatiskt inspektionsverktyg är 100% korrekt. Det kommer nästan alltid att finnas några varningar som är "felaktiga", där du i själva verket har gjort rätt. Alla sådana måste kommenteras på plats i koden, och som en del av examinationen måste du ge en god motivering till varför du vet att du har rätt och IDEA har fel. Du får som sagt gärna fråga handledaren, men svaret måste ändå skrivas in där, för att demonstrera vad du har lärt dig!

5.20. Dags att demonstrera!

Avslutning

Du har nu implementerat ett spel, läst om varför vi implementerar på ett visst sätt, och på det sättet lärt dig mer om objektorienterad modellering och programmering. Du har också fått en praktisk introduktion till flera designmönster: Model-View-Controller, Observer, Singleton, och Strategy.

Som synes resulterar Tetrisprojektet inte nödvändigtvis i ett 100% fullständigt spel. Poängen sparas inte i en fil, blocken faller inte snabbare när tiden går så att spelet blir svårare, och så vidare. Det finns två anledningar till det: Dels skulle det mest kräva mer av samma typ av programmering som du redan har provat på, dels måste du ha gott om tid över till projektet.

Demonstration

  1. Det är nu dags att demonstrera slutresultatet för labbhandledaren. Följ sedan instruktionerna nedan för att lämna in koden.

5.21. Kodinlämning

LÄS NOGA!

Syfte

Vi har strikta instruktioner för inlämning. Om vi behöver lägga ner 10 minuter extra per student "i onödan" tar det över 20 timmar som vi kunde ha lagt på handledning och kursutveckling! Därför måste vi returnera labbar som inte följer instruktionerna för komplettering.

Inlämningsprocedur

  1. Se till att du har demonstrerat. Handledaren kan ha kommentarer redan vid demo, så du ska demonstrera före inlämning (och före deadline).

  2. Projektet måste lämnas in i IDEA-format med en .ipr-fil så att vi snabbt kan öppna den och navigera genom koden.

    Har du inte använt IDEA: Skapa ett projekt enligt instruktionerna i labb 1. Kom ihåg att ändra More Settings / Project format till ".ipr (file based)"! Se till att projektfilerna checkas in.

  3. Se till att du verkligen har kört IDEAs kodinspektioner!

    Har du kvar varningar som varken har fixats eller motiverats kommer du att få komplettering. Har du använt fel inspektionsprofil kan det finnas varningar kvar.

  4. Är detta en komplettering? Beskriv i så fall i filen "kompletteringar.txt", i rotkatalogen för projektet, hur varje enskild kommentar från handledaren har hanterats: Vad som ändrats och var i koden, hur du har löst problemet, och annan information som är relevant för att handledaren lätt ska se vad som gjorts.

    Gör du flera kompletteringar ska gamla kommentarer vara kvar och tydligt skilda från nya kommentarer (markera med kompletteringsdatum). Detta underlättar för oss och är en del av examinationen där du visar att du förstår varför kompletteringen behövdes.

  5. Checka in all kod och dokumentation, och pusha till GitLab. Kontrollera noga att allt verkligen är incheckat, inklusive .ipr-fil och eventuell kompletteringsfil.

  6. Skapa en tagg (etikett) i Gitlab. Detta visar oss vilken version av koden som lämnades in vid inlämningsdatumet.

    1. Logga in på gitlab.

    2. Gå till ditt projekt och välj "tags".

    3. Välj "New tag".

    4. Sätt taggnamnet till "t1" om detta är första gången du lämnar in Tetris. Om du får komplettering får du nästa gång sätta en ny tagg "t2", och så vidare.

    5. Låt "Create from" vara kvar på "master".

    6. Meddelanden och "release notes" behövs inte.

    7. "Create Tag"!

  7. Skicka ett brev till din handledare.

    Brevets ämne ska inledas med kurskoden TDDD78 så det kan sorteras rätt. Annars kan det dröja innan brevet blir läst.

    Ange en URL till ditt projekt i gitlab och den tagg du skapade ovan (alltså t1 första gången).

2012–2017 Jonas Kvarnström, Mikael Nilsson


Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2017-03-07