Göm menyn

Labb 2: Objektorientering i Java / Tetris

Introduktion

I denna labb ser vi bland annat hur man skapar och använder objekt och klasser.

Vi kommer att hjälpa till med flera handgrepp i programmeringen, men det är som tidigare viktigt att ni inte genomför dem "mekaniskt" utan reflekterar över varför ni gör som ni gör.

Bra att tänka på

  1. Arbeta på egen tid! Man ska behöva betydligt mer tid än det som schemalagts. Hela kursen är ju på 6 hp = 160 timmar.

  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 det stora 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. Kommentera!

Att labba med Tetris

Utöver att titta på enskilda delar av Java och OO vill vi också ge dig möjlighet att skriva ett lite större fullständigt program. Det program vi har valt är ett enklare Tetris-spel. 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å programmering och hur man kan tänka för att skriva ett objektorienterat program.

Hur programmerar man då ett lite större sammanhängande projekt såsom ett Tetris-spel? Ett 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. Instruktionerna för Tetris är uppbyggda på detta sätt, speciellt efter de första inledande stegen.

Ämnen som kommer att ingå i Tetris-delen av labbserien är bland annat följande. Som du ser motiveras alla ämnen både av behov i spelet och av det man behöver lära sig i kursen!

  • Begrepp i objektorientering, och hur man modellerar enskilda objekt:

    • Spelbräde – enklare objektorienterad modellering

    • "Brickor" med form och färg – mer objektorienterad modellering

    • "Visare" för spelbräde – uppdelning av ansvarsområden

  • Modellera sammanhang:

    • Hur vet "visaren" när något har ändrat sig? – hur man kan tänka på dataflöde i ett program

  • Visualisering och GUI:

    • Enkelt, med text – att börja enkelt för att kunna testa koden tidigt

    • Grafiskt – "rita" block att börja enkel GUI-programmering

    • Menyer, tangentbordshantering, ... – begrepp i händelsehantering (events) med mera

  • Spelmekanik:

    • "Driva" spelet med en timer – en introduktion till tidsstyrd programmering

    • Hantera spelets regler – en hel del "ren programmering"

    • Highscorelista – mer OO-modellering

    • Rotation av brickor, ... – implementera algoritmer i Java

    • Powerups – hur programmerar man modulärt?

För att leda dig åt rätt håll när du omsätter teori till praktik är de första delarna i Tetris i "tutorial"-form, där vi ganska detaljerat diskuterar hur man kan tänka för att överföra idéerna till kod. Mot slutet av labbserien får du gradvis mer och mer frihet – och färre detaljerade ledtrådar.

Uppgift: Prova Tetris

  1. Läs gärna på lite om Tetris. Det kan hjälpa dig i resten av labbserien.

  2. Om du inte har spelat Tetris tidigare: Testa någon gratisvariant, t.ex. M-x tetris RET i vissa varianter av Emacs. Uppgifterna kommer att förutsätta att du vet hur Tetris fungerar.

Efter föreläsning 2, typer / allmän OO

Tetris 2.1: Börja programmera Tetris

Syfte: Börja så smått med spelplanen!

Om vi ska implementera Tetris behöver vi bland annat ett sätt att representera spelets nuvarande tillstånd, t.ex. spelplanen. Vi börjar med:

  • Att diskutera hur spelplanen ser ut och hur delar av den kan modelleras.

  • Att skapa en datatyp för "kvadrater" (detta kommer att förklaras snart).

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.

Uppgift: Skapa projekt och paket

Vi kommer att fortsätta i samma Git-arkiv och samma IDEA-projekt som du använder för alla labbar. Vi vill däremot separera Tetris från övriga klasser genom att lägga dem i olika (under)paket.

  1. Skapa ett paket för Tetris, med lämpligt namn -- förslagsvis se.liu.dinadress.tetris, som läggs "bredvid" lab1 (högerklicka mappen motsvarande ditt liuid i IDEA, och välj New / Package).

    Paketnamnet måste innehålla "tetris" någonstans! Namnet kommer att användas av mjukvara som automatiskt separerar Tetris-kod från annan kod vid inlämning.

Bakgrund: Tetris, polyomino, tetromino

Tetris har en spelplan där tetrisblock steg för steg "ramlar ner" uppifrån. Varje block består av exakt fyra sammanhängande kvadrater i en viss färg och ett visst mönster – själva namnet Tetris kommer faktiskt från tetra (4).

Totalt 7 olika blockmönster är möjliga, bortsett från möjligheten att rotera dem. Nedan syns en illustration från Wikipedia. Standardnamnen på dessa block är I, J, L, O, S, T och Z.

Så vad kallas egentligen den här typen av block, mer generellt? Man kan se dem som en variant av domino, som har 2 sammanhängande kvadrater:

Om vi då generaliserar från domino ("di-omino", di=2) får vi namnet polyomino (poly = flera), med specialfallen monomino (1), domino (2), tromino (3), tetromino (4), pentomino (5), hexonimo (6), och så vidare.

Varje tetrisblock är alltså en tetromino.

Modellering av kvadrater

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

Vi vill på något sätt representera den nuvarande spelplanen, det nuvarande läget i spelet. När man har spelat ett tag kan det till exempel se ut så här:

Men hur ska vi alltså representera detta tillstånd i ett program?

Vi kunde tänka oss att 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 ju 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). Ovan ser vi t.ex. ett "helt" grönt L men även 6 partiella L där vissa av kvadraterna från det ursprungliga mönstret är borta. 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 (x,y)-koordinater. Vi behöver inte hålla reda på vilket tetrisblock varje enskild kvadrat kom från. Vi behöver bara något som indikerar vilken typ av kvadrat vi har på en viss position: Kommer kvadraten ursprungligen från ett L, ett J, ...? Det behöver vi så att vi senare vet vilken färg som ska ritas ut.

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-typer, som är gjorda just för situationen när en datatyp 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.

Enum-typer är egentligen klasser, och typens värden är objekt, men det behöver vi egentligen inte tänka så mycket på just nu.

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

Uppgift: Kvadrater

  1. Skapa en enum-typ 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.

Tetris 2.2: Skapa objekt med new + testning

Syfte: Komma igång med objekt; testa testning!

Nu ska vi komma igång och skapa våra första objekt.

Detta är inte helt sant. Vi har skapat objekt förut, men det har då varit "underförstått" i andra operationer vi har utfört. De sju konstanterna i SquareType (L, I, ...) blev till exempel till objekt. Men nu ska vi för första gången uttryckligen välja att skapa nya objekt, med operatorn new.

För att komma dit så snabbt som möjligt skapar vi objekt av en redan existerande klass från Javas klassbibliotek. Då kan vi även prova på att:

  • Skapa en main-metod som används specifikt för testning av en klass / typ såsom SquareType.

  • Använda Javas "inbyggda" dokumentation, Javadoc.

  • Använda typbaserad autokomplettering, som drar nytta av att veta variablernas typer.

För att göra detta behöver vi inte ha lärt oss att skapa egna generella klasser, och det är delvis därför vi börjar med just den här övningen – så att vi kan komma lite längre redan innan den tredje föreläsningen (objektorientering i Java).

Om testning

Ofta är det bra att ha ett sätt att testa en enskild klass (datatyp) för att se att implementationen fungerar som den ska. Då kan man använda testramverk som TestNG och JUnit, men så långt går vi inte i den här kursen. Istället provar vi på att skapa en egen main-metod inuti själva klassen. Då kan man helt enkelt "köra klassen" för att testa den. Vill man istället starta huvudprogrammet (spela Tetris!) gör man detta med hjälp av en huvudklass vars main-metod inte är till för testning utan för programstart.

SquareType har inte så mycket egen funktionalitet, så vi ska helt enkelt testa att slumpa fram några värden, skriva ut dem, och verifiera att utskriften ser ut som den borde.

Om slumptal

Vi behöver alltså kunna skapa slumptal med en (pseudo-)slumptalsgenerator.

En sådan initialiseras normalt med ett "frö" och ger sedan en sekvens av slumptal. Samma frö ger alltid samma sekvens av slumptal (som ska vara svår att förutse och därmed efterlikna "äkta slump"), medan olika frön förhoppningsvis ger olika sekvenser.

Slumptalsgeneratorn måste alltså hålla reda på (bland annat) sitt frö – den har ett eget tillstånd (state, intern information att hålla reda på). Den har också ett beteende, en funktionalitet: Den kan lämna ut nästa slumptal. Med både tillstånd och beteende passar det att modellera den som ett objekt.

Så är det också i Java: Slumptalsgeneratorer är objekt av klassen Random (eller SecureRandom, om man vill ha kryptografiskt säkra pseudoslumptal).

Uppgift: Slumptal

  1. Skapa en main()-metod i SquareType. Låt metoden skapa en slumptalsgenerator: Random rnd = new Random();

    Som alla klasser i Java ligger Random i ett paket. Olika paket kan ha klasser med samma namn. När du skriver in Random vill IDEA därför veta vilken Random du menar. Den ger då en popup som föreslår import av java.util.Random, den enda Random som hittas just nu. Tryck Alt+Enter för att acceptera detta. Detta resulterar i en import-sats, som skiljer sig en del från import i Python (mer om detta på en föreläsning).

    Koden deklarerar att vi vill ha ett objekt av typen Random som vi kommer att referera till med namnet rnd. Vi skickar inte med några initialiseringsparametrar till konstruktorn utan är nöjda med defaultbeteendet hos Random.

Javadoc -- hur får man fram dokumentation?

Javadoc är Javas standardiserade dokumentationsformat. Det byggs av speciella kommentarer som skrivs omedelbart före en klass, metod eller fält. Javadoc-verktyget gör om detta till indexerade HTML-filer. Dokumentationen för Javas standardklasser finns så klart färdigindexerad på nätet.

Uppgift: Visa Javadoc i IDEA

Prova att placera markören i t.ex. Random() och trycka Ctrl-Q. Detta visar javadoc i en liten popup-ruta så att du snabbt får fram information utan att behöva lämna utvecklingsmiljön och navigera genom HTML-filerna.

Så småningom ska du skriva Javadoc-kommentarer även för din egen kod. Även där fungerar Ctrl-Q för att snabbt få fram information.

Exempel från fältet "out" i System.out:

Det går också att få informationen in en popup eller i ett separat fönster som kan flyttas till en annan desktop, precis som för alla Tool Windows i IDEA. See info om hur man arrangerar fönstren och ändrar visningsläge om du är intresserad.

Uppgift: Testa autokomplettering

IDEAs autokomplettering visar vilka valmöjligheter som finns för fortsatt kodskrivning och kan vara mycket användbar för att upptäcka vad man kan göra med ett objekt.

Testa att skriva "rnd." på raden under new Random();. Beroende på dina inställningar kanske rutan med möjliga fortsättningar kommer automatiskt, eller så får du ta fram den själv med Ctrl-Space.

Eftersom IDEA vet vilken typ variabeln har vet den exakt vad som kan stå efter "rnd.", vilket är ett väldigt bra sätt att lära sig mer om tillgänglig funktionalitet.

Du kan välja ett alternativ i listan eller fortsätta skriva. Om du fortsätter skriva kommer IDEA att filtrera listan och försöka hitta rätt metod utifrån det som skrivs. Skriver du t.ex. "on" kommer IDEA att föreslå " nextLong()".

Man kan navigera i alternativlistan med pil upp/ner. Man kan också trycka Ctrl-Q vilket tar fram javadoc-information för den entitet man just har markerat.

Uppgift: Generera slumptal

Vi ska nu skapa och skriva ut slumptal med en hjälp av Random.

  1. Testa först slumptalen genom att låta main() skriva ut 25 slumptal på varsin rad. Använd valfri typ av loop för att iterera 25 gånger. Använd t.ex. nextInt(100) för att generera ett slumptal mellan 0 och 99.

  2. Testkör programmet.

  3. Nu ska vi även skriva ut slumpmässiga SquareType-värden.

    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. Om vi inte har kommit till arrayer än, räcker det att veta att de fungerar ungefär som listor med fixerad längd, och att man indexerar dem som listor i Python: minArray[index], där index startar på 0. Längden får vi ut med minArray.length.

    Utifrån detta kan du fundera på hur man skapar slumptal av rätt storlek. Skriv sedan en loop som 25 gånger skriver ut en slumpmässig SquareType (ett slumpmässigt index i SquareType.values()).

  4. Testkör programmet.

Efter föreläsning 3, OO i Java

Uppgift 2.3: Samverkande klasser – almanackan

Syfte

Nu när vi vet mer om objektorientering i Java (föreläsning 3) ska vi prova på att skapa en större sammanhängande uppsättning klasser för att lösa en specifik uppgift.

Istället för att fortsätta direkt med Tetris kommer vi snabbt att ta ett steg åt sidan och göra en almanacksuppgift som låter oss kontrastera de olika stegen mot den icke objektorienterade och "icke-typade" Python-programmering de flesta av er gjort i en tidigare kurs. Vi kommer även att kunna jämföra hur dataabstraktionen fungerar när vi programmerar objektorienterat. Vi går dock inte lika långt som i Python – bara tillräckligt för att se kontrasterna i modellering.

(Om du inte har gjort motsvarande övning i Python kommer du ändå att kunna lära dig hur man börjar modellera data i Java.)

Om typad OO-programmering

Vi skall nu skapa en klass Month som har motsvarande funktionalitet som i Python-almanackan (TDDE24-2020, med NamedTuple i implementationen).

I Python-almanackan skapade vi många separata funktioner för varje typ, t.ex. för månader. Funktioner som month_name(), month_number() och number_of_days() blir nu metoder i Month. Detta samlar ihop relaterad kod till en sammanhängande enhet.

Den statiska typningen i Java gör också att vi inte måste "manuellt" testa att metoder får parametervärden av förväntad typ, så som vi gjorde med funktionsanrop som ensure_type(mon, Month) i TDDE24. Istället deklarerar vi en parameter av typen Month och förlitar oss på att kompilatorn kontrollerar att bara korrekta parametervärden kan skickas in.

Uppgift: Månadsklassen

  1. Skapa paketet se.liu.dinadress.calendar för almanackan, så den inte blandas ihop med koden för Tetris. Lägg även detta paket "bredvid" lab1 (högerklicka mappen motsvarande ditt liuid i IDEA, och välj New / Package).

  2. Skapa klassen Month i paketet calendar, med fälten:

    • name - månadens namn ("January")
    • number - månadens nummer (1)
    • days - antal dagar i månaden (31)

    Eftersom vi inte vill att någon skall ändra dessa värden utifrån lägger vi attributet "private" framför deklarationerna.

  3. Använd IDEA för att skapa en konstruktor som initialiserar Month åt dig. Tryck Alt+Insert och välj Constructor. I detta fall ska alla fält anges som parametrar till konstruktorn, så markera alla fält innan du trycker OK (använd t.ex. Ctrl-A eller Ctrl-klicka raderna).

  4. Tryck Alt+Insert och välj Getter, välj alla tre fälten och sedan Ok. Nu genererar IDEA funktioner så att andra klasser kan få ut informationen (getName() och andra metoder som skapas är publika) men inte ändra den (själva fälten är privata och kan inte ändras utifrån).

Vi har nu alla de viktigaste funktionerna som fanns i Python-month.

Om månadsdata

Enligt ovan associeras en månad med ett namn, ett månadsnummer, samt antal dagar. I nuläget kan man skapa en månad med vilka parametrar som helst. Detta är inte vad vi vill uppnå. Vi vill ha en klart definierad månad som beter sig så som vi förväntar oss. Därför behövs mer information kring månader. Vi måste kunna testa om en sträng motsvarar ett månadsnamn samt kunna fråga hur många dagar det finns i en månad med ett visst namn. Detta är information som är specifik för månader och den bör alltså ligga i klassen Month.

Vi kommer att bortse från skottår då det inte tillför något av värde ur objektorienteringssynpunkt!

Hur ska man då lagra den här typen av information? Ett sätt är att använda if-satser eller switch-satser. Detta är egentligen inte idealiskt, i och med att vi då hårdkodar data (representerar dem som kod) – men vi har ännu inte kommit så långt att vi kan använda Javas motsvarighet till dict. Därför gör vi på detta sätt tills vidare.

Uppgift: Månadsdata

  1. Skapa i klassen Month metoderna getMonthNumber(String name) och getMonthDays(String name) som med hjälp av switch-satser (se labb 1) rapporterar månadsnummer och antal dagar i en månad utifrån det givna månadsnamnet. Låt default: returnera -1. Då kan vi använda någon av funktionerna för att testa om en sträng faktiskt representerar en existerande månad.

    Detta ska motsvara MONTH_NUMBERS och MONTH_DAYS i Python-almanackan, men eftersom vi ännu inte är redo att använda Javas motsvarighet till en dictionary blir vi tvungna att implementera det som metoder istället.

    I just detta fall vill vi använda statiska metoder (deklarerade static). Då behöver vi inte skapa ett Month-objekt för att kunna ta reda på antal dagar i en månad, utan anropar metoderna direkt i klassen istället. Metoderna anropas därmed som Month.getMonthNumber(...). Detta ska vi bara göra för metoder som verkligen är "globala" och oberoende av information om enskilda månadsobjekt. Mer om detta kommer på senare föreläsningar.

Uppgift: Klasser för tid och datum

Vi kommer inte att skapa speciella typer för timmar, minuter och dagar. Eftersom vi inte ska gå vidare och ge dessa tidsenheter mer funktionalitet än att just innehålla ett heltal, använder vi i denna labb helt enkelt int för att representera dessa. Däremot behöver vi representera datum som är sammansatta av år, dag och månad. Vi kommer också att behöva representera tidpunkter samt tidsintervall.

I och med att det redan finns en klass som heter Date i Java döper vi vår nya klass till SimpleDate, för att undvika sammanväxlingar. (Java kan hålla ordning på flera klasser med samma namn, så länge man importerar rätt klass, men i våra diskussioner och beskrivningar kan det vara svårare.)

  1. Skapa klassen SimpleDate med fälten year (int), month (Month) och day (int). Lägg till en konstruktor som tar in dessa som parametrar. Lägg också till getters för dem. Skriv en toString()-metod presenterar ett datum i ett format som du tycker är lämpligt. Där kan du bl.a. anropa month.getName() eller month.getNumber() för att få reda på information som du behöver.

  2. Skapa klassen TimePoint som innehåller ett klockslag för att representera start/slut på en aktivitet. Klassen skall ha fälten hour och minute.

    I konstruktorn skall hour och minute tilldelas värden. Till skillnad från Python-almanackan tar vi in dessa som separata heltalsparametrar till konstruktorn istället för att dela upp en tidssträng i delar.

    Generera också getters för hour och minute.

    De fält och metoder vi skapar nu ska anropas i ett objekt, inte i en klass. De ska alltså inte vara statiska! Vi frågar till exempel ett specifikt SimpleDate-objekt, inte datumklassen, vilket år det har.

  3. Skriv en toString()-metod i TimePoint. Använd hour och minute för att skapa en lämplig sträng att returnera. Här kan man t.ex. utnyttja String.format(...) med formatsträngen "%02d:%02d" – vi lämnar medvetet några detaljer till er att utforska!

    Överkurs

    Skapa en int compareTo(TimePoint other)-metod som returnerar ett negativt tal om den aktuella TimePoint kommer före other, 0 om de representerar samma tid och ett positivt tal om other kommer före.

    (Om du går så långt att du även implementerar Comparable kommer även en equals()-metod att behövas. Detta är också överkurs.)

  4. Skapa till sist klassen TimeSpan. Den behöver två fält av typen TimePoint, start och end. Gör getters och en konstruktor som tar in dessa. Skriv sedan en toString()-metod som skapar utskrifter av typen "12:15 - 13:15". Detta görs bland annat genom anrop till toString() i start och end. På detta sätt bygger man rekursivt upp en textrepresentation av ett sammansatt objekt.

Om listor i Java

Till skillnad från Python har Java ingen speciell syntax för listor. Istället är de klasser som alla andra, och det finns flera olika listklasser med olika egenskaper. Den vanligaste och mest använda är ArrayList.

Listklasserna är generiska typer, något vi ska diskutera senare i kursen. Detta innebär i princip att man kan ange en parameter som talar om vilken typ av element de innehåller, så att kompilatorn kan utföra bättre typkontroller. Om vi vill ha en lista som bara innehåller strängar:

   ArrayList<String> myList = new ArrayList<String>();

Som vi nyss har sagt har Java olika listklasser med olika egenskaper. Alla de listklasserna är specialfall av den mer generella list-typen List, och det kan hända att IDEA klagar lite på att man är onödigt specifik i sin deklaration. Även detta ska vi diskutera mer om senare under föreläsningarna. Just nu kan man helt enkelt skriva om deklarationen på det här sättet:

   List<String> myList = new ArrayList<String>();

Vi kan dessutom förenkla detta: Vi behöver inte skriva med elementtypen på högersidan, utan kan använda <> istället – en förkortning som betyder "samma typ som på vänstersidan":

   List<String> myList = new ArrayList<>();

Vi kan därefter stoppa in och plocka ut element:

   myList.add("foo");  // Lägg till sist i listan
   String x = myList.get(0);    // Hämta första elementet
   String y = myList.get(3);    // Hämta fjärde elementet
   int antal = myList.size();   // Antal element i listan

Uppgift: Möten och kalendern

Allt som återstår nu är att lägga till en klass för möten och att skapa själva kalendern. Vi skapar en simpel kalender där alla möten lagras i en lista. En stor del av koden i metoden book() som används för att lägga möten i kalendern utgörs av parameterkontroll. Att kontrollera användarens inparametrar är ofta en stor del av den kod som ligger i ett API. Vi kommer att generera ett undantag (exception) om man försöker skapa ett otillåtet möte.

  1. Skapa klassen Appointment med tre fält, subject (String), date (typ SimpleDate) och timeSpan (typ TimeSpan). Skapa getters och en konstruktor som tar samtliga som parametrar. Skapa även en toString()-metod som använder sig av SimpleDate.toString() och TimeSpan.toString() för att formattera en fin utskrift.

  2. Skapa klassen Cal. Den skall ha en privat lista av appointments som realiseras av en ArrayList, vilket är en collection som implementerar list-gränssnittet, i en konstruktor utan inparametrar. Detta sker genom raderna

       private List<Appointment> appointments;

    i klassen och

       appointments = new ArrayList<>();

    i konstruktorn.

    Varför inte Calendar? Jo, det finns redan en sådan klass i Java (fast den representerar en tidpunkt), och även om det skulle fungera att ha en till, kan det vara förvirrande att ha namnkrockar med välkända klasser.
  3. Implementera metoden show() som går igenom listan och skriver ut alla appointments. Detta kan med fördel göras genom att använda Live-Template (Ctrl+J) iter som leder till en så kallad for-each-loop där varje element i t.ex. en lista besöks. Denna loop har formatet "for (element : behållare)".
  4. Implementera metoden
       public void book(int year, String month, int day,
          int startHour, int startMinute, int endHour,
          int endMinute, String subject)

    Notera att vi här tar in ett månadsnamn som en sträng. Att skapa ett SimpleDate-objekt kräver däremot att vi har ett månadsobjekt. Det är upp till book() att anropa lämpliga statiska metoder i Month för att ta reda på bl.a. vilket nummer månaden har och sedan skapa ett lämpligt månadsobjekt.

    Metoden skall skall kontrollera alla inparametrar. Detta betyder att

    • year > 1970
    • För start och end är 0 <= hour <= 23 och 0 <= minute <= 59
    • month skall vara ett namn som motsvarar en existerande månad
    • månaden skall ha tillräckligt många dagar för att day skall vara tillåten

    Om någon av parametrarna är fel ska detta signaleras till anroparen. Detta gör man normalt med exceptions, "undantag". Exakt hur detta fungerar kommer vi att gå genom i en senare del av kursen. För tillfället räcker det med att ni vet att ett IllegalArgumentException ska kastas, och att detta görs med:

       throw new IllegalArgumentException("felmeddelande");

    När parametrarna är kontrollerade skall ett SimpleDate-objekt skapas utifrån datuminformationen och två TimePoint-objekt skapas som start/end. Dessa två används sedan för att skapa ett TimeSpan och slutligen ett Appointment som läggs till i listan.

  5. Kalendern är nu färdig och du skall skriva ett testprogram som skapar en kalender, bokar 5-10 appointments och sedan skriver ut kalendern.

    Överkurs
    Skriv ut appointments i sorterad ordning.

Sammanfattning

Du har nu byggt ett objektorienterat program med flera klasser. Du är bekväm med Setters/Getters, har testat for-each och listor i Java, inklusive en första titt på generics, samt har kastat Exceptions.

Tetris 2.4: Spelplanen

Syfte: Modellering av spelplan

Nu återvänder vi till Tetris genom att skapa fler datatyper för spelets nuvarande tillstånd – speciellt en typ för själva spelplanen, som vi kan kalla Board. Som tidigare vill vi speciellt fokusera på hur man tänker när man modellerar. Därför diskuterar vi hur vi har tänkt för att komma fram till de föreslagna datatyperna.

Om spelplaner – vilken information behövs?

När vi tänker på Tetris är det lätt att vi fokuserar på det visuella: Ett spelbräde visas upp på skärmen med ritade rutor i olika färger. Just nu ska vi dock fokusera på den information som behövs för att hålla reda på spelet och spelmekaniken. Till exempel måste man veta:

  • Hur stor spelplanen är

  • Vilka rutor som är upptagna, och vad de innehåller

  • Formen och koordinaterna för den bit som håller på att ramla ner just nu

Den här informationen är nödvändig: Utan den kan man överhuvudtaget inte spela. Koden som ritar upp ett spelbräde är jämförelsevis mindre central – den skulle till exempel inte alls behövas om man vill programmera en AI-baserad spelare (bot), eller om man vill spara informationen om ett spel i en fil så att man kan återuppta den senare.

Om spelplaner – hur representerar vi informationen?

Nu behöver vi alltså en spelplansklass, Board, som till att börja med innehåller information om vilken typ av kvadrat som för tillfället finns på varje position på spelplanen. Hur ska vi lagra detta?

Ett sätt är att ha en lista på kvadrater, där varje kvadrat känner till sin egen position. Då skulle en spelplan t.ex. kunna innehålla 25 olika L-kvadrater, var och en med sina egna koordinater. Det skulle fungera utmärkt när vi ritar ut spelplanen men blir kanske mindre bra när vi vill undersöka om en viss rad är full. I sådana fall är det ju bra om vi snabbt kan hitta alla kvadrater på en viss rad, vilket vi inte får om vi måste leta genom en lista på alla kvadrater.

Ett annat sätt är att lagra ett rutnät som man kan indexera med x- och y-koordinater för att se vad som finns på en viss position. Det kan man göra med en nästlad lista, som i Python. Ett alternativ är att använda en tvådimensionell array. Arrayer är i princip som listor, men har (i Java) en fast längd som inte kan ändras efter att arrayen skapades. Den begränsningen är inget problem för oss eftersom vi inte vill ändra storleken på spelbrädet.

Om arrayer i Java

Arraysyntaxen i Java liknar i vissa delar Pythons listsyntax, men det finns skillnader.

Första raden nedan skapar en array med plats för 10 element. Arrayen innehåller 10 st null, vilket på vissa sätt motsvarar Pythons None: "Det finns inget här". Andra raden stoppar in ett värde på den fjärde positionen, som har index 3 (index börjar på 0). Tredje raden visar hur man hämtar ut ett element.

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

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; 

Mer information finns t.ex. i Java Tutorial, en av våra kursböcker.

Om spelplaner lagrade som arrayer

Ett Board kan alltså innehålla en tvådimensionell array av lämplig storlek, där varje element är en SquareType som identifierar typen av kvadrat.

Detta fält bör vara private, eftersom andra klasser inte ska kunna komma åt det. Detta kommer att diskuteras i mer detalj på föreläsningarna, men just nu kan du bara följa instruktionerna.

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 tillkommer säkert senare.

Uppgift: Skapa klass för spelplan

  1. Skapa klassen Board i Tetris-paketet.

  2. Ge klassen fältet private SquareType[][] squares. Detta deklarerar 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.

Om att 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. Detta kommer som sagt att diskuteras mer under föreläsningarna.

Tetris 2.5: Visualisering av spelplan

Syfte: Visualisera... innan vi lär oss grafiska gränssnitt

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 strängar i Java.

Om modellering: Vems ansvar är visualiseringen?

I all programmering är det viktigt att man delar upp koden i olika moduler med tydliga ansvarsområden. Detta gör koden enklare att läsa, skriva, förstå, och strukturera. Java delar t.ex. upp koden i paket och klasser.

Vems ansvarsområde är det då att visualisera något på skärmen? Det kan man argumentera om. Det finns som vanligt många sätt att dela upp ansvarsområdena, alla med sina fördelar och nackdelar. Vilket som blir bäst beror ofta på situationen.


Tänk dig att vi programmerar ett plattformsspel med många olika sorters objekt på skärmen: Bakgrunder, spelare, fiender, bonusar att plocka upp, med mera. Då kanske vi tycker att varje objekt borde hålla reda på hur det själv ska se ut. Då får vi en klar och tydlig uppdelning:

  • En klass håller reda på allt om spelaren, inklusive hur den rör sig, hur den ser ut på skärmen, hur många poäng spelaren har, och så vidare

  • En annan klass håller reda på allt om fiender av typ 1, inklusive hur de ser ut, hur de beter sig, hur starka de är, och så vidare

  • En tredje klass håller reda på allt om fiender av typ 2, inklusive hur de ser ut, hur de rör sig på skärmen, hur starka de är, och så vidare

  • ...

Om det görs på rätt sätt kan detta bli en bra och modulär uppdelning och ansvarsfördelning. Vill man lägga till en ny typ av fiende lägger man till en ny klass, och behöver inte ändra i de andra.


Men nu programmerar vi en annan typ av spel, där det inte kommer att finnas så många olika sorters objekt på skärmen. Vi har en bakgrund, kvadrater från block som har ramlat ner, och eventuellt en fallande tetromino. Då kan vi göra en annan typ av uppdelning, som också är klar och tydlig:

  • En klass (Board) håller reda på all grundläggande information om spelplanen, inklusive hur stor den är, var kvadraterna finns, och så vidare. Den vet också hur spelmekaniken fungerar (hur saker faller, hur rader försvinner).

  • En annan klass vet hur man ritar upp spelplanen.

Här har vi delat upp funktionaliteten åt ett annat håll, genom att samla hela modellen på en plats och hela visualiseringen, vyn, på en annan plats. Detta är en del av ett designmönster som i sin helhet kallas Model-View-Controller.


Så vilket ska vi välja?

  • I många projekt tror vi att den första lösningen kommer att fungera bäst, eftersom man annars får en alltför stor centralisering av "hur man ritar" för många olika typer av skärmobjekt. Detta skulle strida mot principer om modularisering.

  • Men i Tetris har vi inte så många olika typer av skärmobjekt, och vi tjänar antagligen mer på att separera utritning från datamodellen än på att separera utritning av kvadrater från utritning av bakgrunden.

Därför bestämmer vi att vi ska skapa en separat vy-klass som är ansvarig för att rita ut hela spelbrädet.

Om "getters" och "setters"

Vy-klassen kommer att behöva information från Board för att veta vad den ska rita ut.

Om man tillåter objekt från en klass att direkt använda fälten i objekt som tillhör en annan klass, låser man fast sig i en viss representation som sedan blir svår att ändra. Därför vill vi till exempel inte att klassen som visar ett spelbräde på skärmen ska komma åt den tvådimensionella squares-arrayen i Board direkt. Istället ska denna representation vara Board:s hemlighet.

Men på något sätt måste ju visaren få reda på informationen. Hur gör man då? Jo, vi skapar ett mellanlager av metoder som gör informationen tillgänglig. Som vi ska diskutera på föreläsningarna kan det mellanlagret exponera informationen utan att tala om exakt hur och var den lagras. En av fördelarna är att den interna representationen kan ändras utan att andra klasser behöver modifiera sin kod.

Metoderna som hämtar och ändrar information kan kallas för getters respektive setters. Java har en konvention att dessa metoder kallas för getXYZ() respektive setXYZ(), där XYZ är någon egenskap man är intresserad av.

Om getters och setters i spelbrädet

Vy-klassen kommer bland annat att vara intresserad av spelbrädets bredd och höjd. Dessa lagras just nu i fälten width och height, och vi vill skriva metoderna getWidth() och getHeight(). Här ser vi att metodnamnen direkt motsvarar fältnamnen, och att funktionaliteten blir att direkt returnera ett värde.

Vi kommer också att vara intresserade av vilken typ av kvadrat (om någon) som finns på en viss position. Detta kunde man rent tekniskt göra tillgängligt genom att returnera hela squares-arrayen, men då exponerar vi lite för mycket av den interna representationen. Det blir bättre att istället skapa en metod som bara returnerar information om en specifik position. En sådan metod blir lättare att ändra om man senare vill ändra Board för att t.ex. lagra spelbrädet i nästlade listor eller någon annan representation.

Uppgift: Ge vy-klassen tillgång till information

  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! Vårt spelbräde vill ha mer kontroll över sin matris av kvadrater och vill inte att andra ska kunna peta i matrisen/arrayen direkt.

Att skriva ut ett spelbräde

Vi ska nu skapa en klass vars ansvarsområde är att visa upp ett spelbräde. Men hur ska den nuvarande situationen egentligen visas?

Just nu kommer vi att använda textgrafik, men så länge som vi designar våra (objektorienterade) gränssnitt på ett bra sätt kommer vi senare att kunna byta ut detta mot "vanlig" grafik. Vi skulle också kunna "visa" spelplanen 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.

Om strängmanipulation i Java

Om vi nu ska använda textgrafik är det bra att veta att Javas strängar är oföränderliga: Man kan inte ändra på dem när de väl har skapats. Man kan visserligen göra så här:

   String str = "Hello ";
   str = str + "World";

Men det gör att man först skapar en sträng, och sedan kopierar den för att skapa en ny sträng. Det är inte så farligt om man gör det någon enstaka gång, men om vi ska göra många tillägg blir det väldigt långsamt.

För att inkrementellt bygga upp en lång sträng använder man istället 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 ");
   }
   // Create a string with the same contents.
   String result = builder.toString();

   // Print result.
   System.out.println(result);

Om SquareType och textsymboler

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, grafik, ...). Istället kan vi låta vyn känna till vilka SquareTypes som finns och ha en hjälpmetod som returnerar symbolen motsvarande en viss SquareType.

Uppgift: Skriv ut ett spelbräde

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

    Metoden ska inte vara static. Vi vill ha ett BoardToTextConverter-objekt vars metoder t.ex. kan override:as i subklasser (diskuteras senare).

    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.

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

Tetris 2.6: Slumpning av spelplan

Uppgift: Test

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

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

    Som tidigare använder du ett objekt av klassen java.util.Random. Nu ska vi använda samma slumptalsgenerator flera gånger i olika anrop, så lagra Random-objektet i ett statiskt fält i Board och ge det ett värde direkt vid fältet: private final static Random RND = new Random();.

    Glöm inte final, eftersom vi vill ha en konstant och inte behöver byta ut vår slumptalsgenerator. Om vi skapar en ny generator varje gång vi vill ha ett slumptal blir det (a) onödigt långsamt eftersom vi måste skapa många objekt i onödan, och (b) dålig kvalitet på slumptalen eftersom vi inte får en chans att utnyttja serien av slumptal.

    Anropa sedan generatorn varje gång du behöver ett slumptal!

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

Tetris 2.7: Tetrisblock / tetrominoes

Att modellera polyominos

Efter spelplanen kommer själva Tetrisblocken. Det finns som sagt 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 klass per blocktyp (L, T, ...)? Det är så klart en möjlighet, men en grundregel är att vi bara ska ha olika klasser om objekten faktiskt beter sig annorlunda. Här beter de sig blocken egentligen inte olika, utan de har bara olika utseende. Det kan vi enkelt representera som data (information om blockens form) istället för olika klasser.

    Vi upprepar regeln: 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 klass, 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 veta vilken "form" den har. 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.

Uppgift: Polyomino-klass

  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.

Att skapa olika typer av Polyomino: 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). Notera att metoderna inte ska vara static: Vi vill programmera objektorienterat och t.ex. bevara möjligheten att skapa underklasser till TetrominoMaker med egna beteenden för metoderna (mer om det senare).

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 nedan kommer från en beskrivning av det standardiserade rotationssystemet.

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

Om fabriker och fabriksmetoder

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

Överkurs
Det finns också 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!)

Att representera Tetrisblock på spelplanen -- olika alternativ

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.

Att representera Tetrisblock -- vilket alternativ väljer vi?

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):

  • Om positionen täcks av en kvadrat i ett fallande block gäller denna SquareType

  • 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!)

Att representera Tetrisblock -- hur implementerar vi?

Ett sätt är åstadkomma det vi vill är att BoardToTextConverter känner till fallande block, och vet hur de ska ritas ut.

Ett annat är att implementera en ny metod SquareType getSquareAt(x,y) i Board som vet vilken kvadrat som ska synas på en viss position (enligt ovan) och returnerar detta:

  • Blocket falling har sitt övre vänstra hörn på vissa koordinater, som vi kan kalla (x1,y1). Utifrån detta och Poly:ns storlek kan vi beräkna vilka kvadrater som täcks av denna Poly: Från (x1,y1) till (x2,y2).

  • Om (x,y) inte ligger mellan (x1,y1) och (x2,y2) kan falling definitivt inte täcka över (x,y). Då är det bara att titta i Board-arrayen.

  • Annars täcker falling över positionen (x,y) – men den kan göra det med en "genomskinlig" ruta (EMPTY) på sin interna position (x-x1,y-y1). I så fall ska vi också titta i Board-arrayen.

  • Slutligen finns fallet där falling täcker över positionen (x,y) och faktiskt har en annan SquareType än EMPTY där. Då måste vi returnera rätt SquareType enligt falling.

Då kan BoardToTextConverter anropa getSquareAt() utan speciell kunskap om fallande block. Välj själv!

Uppgift: Tetrisblock på spelplanen

  1. Lägg till ett fält Poly falling i Board (som vanligt privat). Där ska du lagra "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å polyns 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 polyn och dess koordinater

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

  3. Förtydligande 2021-01-27: Testa detta genom att ändra Board-konstruktorn så att den skapar en Poly som läggs in i falling och sätter en lämplig position för denna Poly, t.ex. överst i mitten av spelplanen. Låt BoardTester skapa en spelplan och skriva ut resultatet. Ser du det "fallande" (men stillastående) blocket på den position där du vill ha det?

    Senare ska vi se till att slumpa fram ett block (falling), att blocket faktiskt faller ner ett steg i taget, och att man kan styra blocket. Just nu testar vi bara att "utskriften" av blocket fungerar när det ligger stilla på en fast position.

Tetris 2.8: Textgrafik i ett fönster

Om textgrafik

Det är nu dags att visa upp textgrafiken i ett fönster istället för i terminalen. Om du arbetar enligt det föreslagna schemat kommer du hit innan vi har diskuterat hur grafiska gränssnitt programmeras i Java. Vår nuvarande version av gränssnittet kommer därför att vara väldigt simpelt och vi kommer att ge extra tips om de viktigaste sakerna man behöver veta.

Du kommer alltså 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.

Denna flerstegsimplementation är något vi gör i just Tetris-projektet men är absolut inte något man behöver göra för det senare projektet!

Uppgift: Textgrafik

  1. Skapa den nya klassen TetrisViewer.

  2. På något sätt ska TetrisViewer få tillgång till ett Board-objekt. Man kunde tänka sig att TetrisViewer 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!

    TetrisViewers uppgift är att visa en spelplan och låta oss interagera med den. Spelstartarens uppgift är (bland annat) att skapa en spelplan och ge den till ett fönster och till alla andra som kan behöva tillgång till den.

  3. Vi vill nu att TetrisViewer ska kunna öppna ett fönster för att visa upp spelet. Var gör vi det?

    En möjlighet vore att lägga detta i konstruktorn. Å andra sidan bör en konstruktor initialisera objekt men inte göra särskilt mycket "riktigt arbete". Den som anropar konstruktorn kan vänta sig att snabbt få tillbaka ett nytt objekt och sedan be det objektet att göra något, som att öppna ett fönster eller påbörja något annat arbete.

    Därför skapar vi istället en ny metod show() i TetrisViewer. Denna metod ska skapa ett fönster, en JFrame, som den ska använda för att visa upp spelet – både själva spelplanen och annan information runt omkring, till exempel poängräkning.

    JFrame representerar alltså ett GUI-fönster som kan innehålla ett antal olika komponenter såsom knappar, menyer och textrutor. Klassen har en konstruktor som saknar argument, 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.

    Nedan antar vi att fönsterobjektet lagras i en variabel (eller ett fält) som heter frame.

  4. I show() (eller i metoder som denna anropar) ska du sedan bygga upp ett lämpligt användargränssnitt. Detta sker bland annat genom att show() lägger till komponenter i fönstret som den har skapat.

    Den viktigaste delen just nu är den JTextArea som ska användas till att visa själva "spelplanen". Skapa alltså en sådan och ange i konstruktorn till JTextArea hur många kolumner och rader som ska visas – detta avgör vilken preferred size textarean ska ha och därmed hur stor den blir på skärmen. 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. Textarean behöver också ges sitt första innehåll. Här förutsätter vi att du har följt instruktionerna och 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 show() för att definiera fönsterlayouten, se till att fönstret visas, osv.:

    • Layouten för ett fönster hanteras av en layouthanterare som applicerar en layoutalgoritm för att placera ut komponenterna (inte genom att man själv anger koordinater för komponenterna). En lämplig start för just detta enkla fönster kan vara att TetrisViewer sätter fönstrets layouthanterare med frame.setLayout(new BorderLayout()).

    • Vi kan sedan lägga till textarean i fönstret med frame.add(textarea, BorderLayout.CENTER). CENTER placerar textarean "i mitten" i fönstret (givet att vi har just en BorderLayout som layouthanterare). 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 monospaced font.

    • För att utföra layouten och göra fönstret synligt: frame.pack(); frame.setVisible(true);

  7. Ändra en existerande testklass, eller skapa en ny, så att den skapar ett Board och en TetrisViewer, och som ber denna TetrisViewer att öppna ett fönster. När testet körs ska slutresultatet vara att du ser ett lagom stort fönster 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 (eller ctrl-c i terminalfönstret).

Avslutning

Här slutar andra laborationen. Det är dags att visa och demonstrera slutresultatet för din handledare – det krävs för att få godkänt!

Du behöver inte skicka in din kod just nu, utan handledaren tittar på det viktigaste vid demonstrationen. Det du har skrivit kommer däremot att följa med i en senare inlämning, och då kan handledaren göra en övergripande genomgång av allt du har gjort.

Passa gärna på att fråga om det är något du undrar över, och be om återkoppling på det du har skrivit!

Fortsätt direkt med nästa labb om handledaren är upptagen.

Labb av Jonas Kvarnström, Mikael Nilsson 2014–2021.


Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2022-01-20