Göm menyn

Labb 3: Ärvning, hierarkier, ...

Introduktion

Syfte

I denna labb kommer vi att experimentera med typhierarkier med hjälp av arvsmekanismen (inheritance) i Java. Med arv kan vi modellera att en klass är ett specialfall av en annan klass. En lastbil är t.ex. en sorts bil, och en länkad lista är en sorts lista. Detta kommer vi att kontrastera med sammansättning (composition), som vi använder för att modellera det faktum att ett objekt har något: En kö har en lista där den lagrar sina element, men den är inte en lista.

Vi introducerar också fler funktioner i IDEA som underlättar programmering. Eftersom man ofta inte modellerar perfekt från början fokuserar vi på funktionalitet som hjälper till när man upptäcker problem och felaktigheter. Därför inför vi till en början också vissa sådana problem och felaktigheter i koden. Se det inte som ineffektivitet utan ett sätt att lära sig effektivt underhåll av kod! Ni kommer säkert att få jobba en hel del med kod som skrivits av programmerare som inte är lika bra som ni, t.ex. av er själva för några veckor sedan...

Efter föreläsning 4, inkapsling, livstid, organisation
Efter föreläsning 5, pekare, interface, typhierarkier

Uppgift 3.1: Ett grafikprogram – vår första form

Genomgående uppgift: Grafikprogram

Vi tar nu åter ett steg bort från Tetris för att undersöka en programtyp där typhierarkier har en mer framträdande roll.

Föreställ dig att du ska skapa ett vektorbaserat grafikprogram så som Inkscape eller Adobe Illustrator. Då kommer du att behöva ett antal klasser som representerar vektorbaserade former, t.ex. cirklar och rektanglar.

Olika former har olika egenskaper, men också många likheter. De har en position där de ska målas ut, en färg, och kanske också beskrivning av dess ram och innehåll. Kanske har formen en bild som ska visas inuti, kanske ska den vara fylld med ett mönster, osv.

Vi kommer nu att modellera sådana former för att undersöka hur ärvning kan vara till hjälp för att representera både likheter och skillnader.

Att göra 3.1.1: Circle

Vi vill komma igång snabbt! Otåliga som vi är bestämmer vi oss för att skapa en cirkelklass, utan att tänka så mycket på framtiden eller hur den ska kopplas till övriga formklasser.

  1. Tidigare klasser låg i paketet se.liu.ida.dinadress.tddd78.lab2. Högerklicka på paketet tddd78, välj New | Package och skapa lab3. Detta ska hamna på samma nivå som lab1 och lab2 i klassträdet.

  2. Skapa klassen Circle i paketet lab3.

  3. Lägg till de publika (public!) int-fälten x, y och radius.

  4. Lägg till ett publikt fält color av typen java.awt.Color.

    Abstract Window Toolkit (AWT) är Javas ursprungliga grafiksystem. Anledningen till att vi skriver hela java.awt.Color är att det finns många Color-klasser i olika paket och vi vill testa ett alternativt sätt att få fram exakt rätt klass. Om du ställer markören i java.awt.Color så kommer IDEA att visa en glödlampa till vänster. Klicka på den eller tryck Alt+Enter, och välj Replace with import, så kommer IDEA att istället importera klassen och vi kan använda namnet utan att ange paketdelen (java.awt).

  5. Skapa en konstruktor som tar in parametrar och tilldelar värden till alla fält.

Att göra 3.1.2: Test

Vi gör nu ett första test av klassen. Även om vi är otåliga ska detta göras tidigt för att vi inte ska låta eventuella fel få nya följdfel när vi fortsätter programmera.

  1. Skapa klassen TestCircle och en main()-metod i den.

  2. I main() ska vi skapa en lista av Circle. Skriv new ArrayList<Circle>(), låt IDEA importera ArrayList, och testa IDEAs Extract Variable enligt nedan.

    Info: Extract Variable

    Genom att markera ett uttryck eller stå i en variabel kan man snabbt få IDEA att introducera en variabel som representerar detta. Man gör det med Refactor | Extract | Variable: Ctrl-Alt-V. Det är användbart t.ex. då man vill skapa en variabel snabbt. Anta att vi har skrivit i koden:

       new ArrayList<Circle>()

    Efter att vi trycker Ctrl-Alt-V och väljer namn på variabeln får vi:

       final ArrayList<Circle> circles = new ArrayList<>();

    IDEA har själv hittat på ett variabelnamn, som man givetvis kan byta ut. Notera att "<>" står för "samma typparametrar som tidigare på raden", dvs. "<Circle>".

    Extract Variable är också användbart om man har ett långt uttryck som man vill dela upp, t.ex. en if-sats där villkoret är väldigt långt. Genom att markera en del av den och ta extract variable skapas en variabel som tilldelas värdet av deluttrycket och sedan ersätts detta i if-satsen med variabeln.

  3. Skapa ett par cirklar och lägg dem i listan (med listmetoden add()).

  4. Använd live-template iter (iterate collection) för att skapa en for-loop som går igenom varje Circle i listan och för varje cirkel skriver ut dess x- och y-koordinater. När man fyller i en live-template kan man använda Tab för att navigera mellan variabler som namnges.

    Notera att iter använder en for-loop-variant som är mer lik Pythons:
    for (Type element : collection), som itererar en gång för varje element i en samling eller array.

  5. Kör programmet och verifiera att du får ut rätt utskrifter.

Att förstå: Klassdiagram

Nu kan vi använda ett klassdiagram i UML för att se strukturen hos de klasser vi har skapat. Här nedan syns att TestCircle skapar instanser av klassen Circle. Vi kommer att se fler UML-diagram senare.

Uppgift 3.2: Defensiv programmering

Att förstå 3.2.1: Kontrollera parametrar

Hittills har vi låtit anropare skicka in vilka värden som helst när ett objekt skapas. På detta sätt kan vi till exempel skapa och rita ut en Circle med negativ radie:

   	Circle cir = new Circle(10, 10, -1, Color.BLACK);

Men hur ska en sådan cirkel kunna ritas ut på skärmen, när vi kommer till den grafiska delen av labben? Vi borde förbjuda sådana cirklar från att skapas, eller rättare sagt, se till att cirklar vägrar att skapas med sådana radier. Det är en del av ansvarsfördelningen i objektorienterad programmering, där klasser själva kan "bestämma över" sina objekt.

Att göra 3.2.1: Kontrollera parametrar

  1. Hoppa från TestCircle till Circle genom att använda Navigate | Class: Ctrl-N och skriva "Cir", som expanderas automatiskt när du trycker Enter. Detta är en mycket användbar genväg.

    Eftersom vi redan har en referens till Circle i närheten kan vi även ställa markören i Circle och trycka Ctrl-B för att snabbt hoppa till definitionen. Detta fungerar för alla klasser, metoder och fält, inklusive de från "språkets egna" klasser (t.ex. System).

  2. Lägg till följande rader överst i Circle-konstruktorn:

        if (radius < 0) {
            throw new IllegalArgumentException("Negativ radie!");
        }
    

    Detta är en exception, ett undantag. Vi kommer att gå genom mer om exceptions i kursen, men hittills räcker det att veta att denna kod avbryter konstruktorn och signalerar ett fel till anroparen. Eftersom konstruktorn avbröts skapas heller inget cirkelobjekt!

Detta exemplifierar två viktiga programmeringsbegrepp.

  • En klass har kontroll över sina objekt. Nu kan ingen skapa en cirkel med negativ radie.

  • Defensiv programmering. Vi ska inte tänka att "ingen galning skulle få för sig att ge en negativ radie, så det fallet kan vi ignorera". Man vet aldrig hur radien räknas ut eller vad anroparen har missat att tänka på (eller hur galen någon är, för den delen). Vi tar hand om alla tänkbara och otänkbara fall, så får vi färre svårhittade buggar.

Att förstå 3.2.2: Kapsla in information

Vi har kommit en bit, men inte hela vägen. Vi gjorde ett misstag i förra uppgiften och lät informationen i cirkelklassen vara public (alla kan komma åt den och ändra den). Därför kan någon fortfarande skriva:

   	Circle cir = new Circle(10, 10, 1, Color.BLACK);
	cir.radius = -1;

Detta skapar en Circle med positiv radie, som vi sedan ändrar på "utifrån". Att göra en kontroll i konstruktorn räckte alltså inte! Problemet är att fältet har publik åtkomst så det kan ändras när som helst, varifrån som helst.

En bra lösning på detta är att ändra åtkomsten till privat. Då skyddar vi klassen så att Circle själv kan bestämma att inga felaktiga värden kan tilldelas redan i konstruktorn. Med IDEAs hjälp kan detta åtgärdas snabbt.

(Varför gjorde vi inte rätt från början? Att man gör den här typen av misstag ibland är oundvikligt. Vi vill inte bara ge en tillrättalagd instruktion utan visa hur man fixar problem så att de inte behöver leva kvar i koden.)

Att göra 3.2.2: Kapsla in information

  1. I klassen Circle, välj Refactor | Encapsulate Fields. Bocka för alla fält samt avmarkera Set Access och Use accessors even when field is accessible.

Att förstå: Vad hände?

All kod som hämtade värden med t.ex. minCirkel.x har nu automatiskt skrivits om till att hämta detta med minCirkel.getX() istället. Detta kan vi till exempel se i TestCircle.

Eftersom fälten nu är privata är det bara kod i Circle själv som kan ändra på dem efter att en cirkel skapas. Eftersom Circle aldrig gör det, är alla cirklar skyddade ifrån att deras fält ändras efter att de skapas.

Hade vi bockat för Set Access hade även setter-metoder skapats. Då hade man kunnat ändra värden "i efterhand", men bara genom dessa metoder, som då kunde innehålla samma typ av säkerhetskontroller som konstruktorn.

Uppgift 3.3: Skapa ett eget gränssnitt – Shape

Att förstå 3.3.1: Gränssnittet Shape

Vi kommer inte att nöja oss med cirklar utan kommer att skapa många olika typer av former. På ytan liknar det situationen för de olika blocktyperna i Tetris, men där var det väldigt enkelt att representera alla de olika varianterna som data – en tvådimensionell array där vi lagrade blockets form. Här är det jobbigare att skapa en intern datastruktur som låter samma klass representera både linjer, rektanglar, cirklar, text och många andra saker som vi kan vilja rita upp på skärmen (även om det så klart är teoretiskt möjligt). Därför föredrar vi i detta fall att skapa olika klasser för de olika formerna, där varje klass kan ha sin egen utritningskod som kan skilja sig radikalt från koden i andra klasser.

Samtidigt vill vi ju kunna hantera dessa på ett gemensamt sätt. Till exempel ska man kunna ha en "lista av former", där vi inte behöver ange om det är kvadrater, cirklar, romber eller 18-hörningar som finns i listan.

Vi åstadkommer detta genom att skapa gränssnittet Shape, som ska implementeras av samtliga formklasser. Då kan vi helt enkelt använda en "lista av Shape", och denna lista accepterar alla former – men till skillnad från generella listor i Python accepterar den enbart former, inte t.ex. strängar. När man plockar en form ur listan vet man att det finns en metod för att rita upp den, men det är subtypspolymorfism som används för att se till att rätt implementation anropas.

Att göra 3.3.1: Gränssnittet Shape

  1. Skapa gränssnittet Shape i paketet lab3. Som förut högerklickar vi på paketet och väljer New | Java Class, men nu väljer vi Interface i drop-down-menyn som finns i fönstret Create New Class som kommer fram.

  2. Nu ska vi se till att Circle implementerar Shape. På class-raden lägger vi därför till implements Shape.

    Eftersom Shape ännu inte har några metoder är detta tillräckligt för tillfället.

Att förstå: Klassdiagram

Nu kan vi även illustrera att Circle realiserar (implementerar) gränssnittet Shape.

Att förstå 3.3.2: Rita ut en Shape

Det gränssnitt vi har skapat kan användas för att koppla ihop klasser i en hierarki, men den säger absolut ingenting om vad de klasserna behöver kunna göra. Har vi en List<Shape> kan vi alltså plocka ut formerna ur listan, men sedan kan vi inte be dem göra något.

Vi behöver ha några metoder i Shape. Vi vill till exempel kunna säga till en godtycklig Shape att måla ut sig på skärmen. Vi åstadkommer detta genom att skapa metoden draw() som deklareras redan i Shape. Detta ses som ett "krav" att alla klasser som implementerar Shape måste tillhandahålla en sådan metod. Då behöver inte utomstående kod känna till några detaljer om hur t.ex. en 18-hörning ska ritas ut, eller ens att formen är en 18-hörning. Det räcker att veta att man har en Shape och att varje Shape kan rita ut sig själv med draw().

Att göra 3.3.2: Rita ut en Shape

  1. Lägg till en public metod som heter draw(). Den ska inte ha några inparametrar och inte heller lämna något returvärde (vad skriver man för att indikera detta?). Som alla metoder i gränssnitt ska den också sakna "kodkropp" ({...}).

  2. Vi ser att draw() blir gul. Det finns nämligen klasser som säger sig implementera Shape men som saknar denna metod! Motsvarande varning kan ses när man går till Circle: IDEA stryker under "implements Shape" med röd färg.

    Placera markören någonstans i Circle där du vill ha de metoder som krävs för Shape. Tryck Alt+Insert och välj Implement Methods och sedan draw(). Då fyller IDEA i rätt metodbeskrivning.

    För enkelhets skull kommer vi inte att måla ut något än, utan bara skriva ut "innehållet" i de former som ska målas ut. Låt därför metoden skriva ut "Ritar: " + this.

    (Vad är "@Override", som IDEA genererar? Detta är en annotering som visar att detta implementerar eller override:ar en metod som är definierad längre upp i klasshierarkin. Ni får veta mer om detta under en senare föreläsning.)

  3. Nu minns vi att standardutskriften för ett objekt inte var särskilt användbar. IDEA varnar till och med för detta genom att "this" blir gul ("Call to default 'toString' on 'this'").

    Använd IDEA för att generera en toString() (Alt+Insert toString()) som innehåller värdet på alla fält.

Att förstå 3.3.3: Flera metoder i Shape

Vad gör vi med de metoder som redan fanns i Circle? Är det några av dessa som även hör hemma i Shape?

Ja, om vi tänker på betydelsen hos Shape kan vi inse att alla formklasser borde ha x- och y-koordinater samt en färg. Därmed borde även Shape ha metoderna getX(), getY() och getColor()! Då kommer Java att låta oss anropa dem även när vi bara vet att vi har en Shape, utan att vi behöver veta vilken konkret typ av Shape vi har.

Att göra 3.3.3: Flera metoder i Shape

  1. Alternativ 1 av 2: Lägg manuellt till metoddeklarationer för getX(), getY() och getColor() i Shape. Se till att de har samma returvärden och parametrar som i Circle, men saknar metodkropp. Vi kan ju inte implementera en fullständig getX() här uppe i Shape, eftersom gränssnittet själv inte har något x att returnera!

  2. Alternativ 2: Gå till Circle. Använd Refactor | Pull Members Up. Se till att Shape är vald i den översta väljaren. Markera getX(), getY() och getColor() i medlemslistan.

    Men vad är det som ska flyttas upp? Om Shape hade varit en klass hade man kunnat välja att flytta hela implementationen av dessa metoder till Shape, om man till exempel ansåg att implementationen skulle vara samma för alla underklasser till Shape. Men nu är Shape ett gränssnitt, som inte ska innehålla någon kod – bara metodsignaturer. Därför är "Make abstract" automatiskt vald. Därmed gör IDEA en metodsignatur i Shape och låter själva implementationen finnas kvar i den ursprungliga klassen. Metoder i gränssnitt är alltid abstrakta == har ingen implementation.

    Vilken ordning metoderna hamnar i kan bero på i vilken ordning man väljer dem i listan!

    Välj Refactor. Detta skapar rätt metoddeklarationer i Shape och lägger också automatiskt till @Override på metoderna i Circle.

Nu har vi deklarerat att alla Shape-klasser måste ha dessa metoder.

Att göra 3.3.4: Test

Vi gör nu ett första test av gränssnittet och klassen tillsammans. Detta gör vi i en ny testklass.

  1. Gå till TestCircle. Klona klassen med hjälp av F5. Kalla den nya klassen TestShapes. På det sättet får ni med strukturen i det gamla testprogrammet.

  2. I TestShapes.main() ska vi inte skapa en lista av Circle, utan en lista av Shape där vi så småningom även ska kunna lägga in rektanglar och andra former.

    Vi måste också ändra loopen på motsvarande sätt, så att den plockar ut godtyckliga former och inte kräver att få cirklar.

    Variabler bör så klart också döpas om, så vi inte har en lista av former som heter "circles". Ställ markören i ett variabelnamn och välj Refactor | Rename:Shift+F6. Skriv in det nya namnet på variabeln (eller välj ett av förslagen som kommer upp) och tryck enter. IDEA byter automatiskt alla förekomster av variabelnamnet.

  3. Nu bör slutet av koden se ut ungefär så här:

    	for (Shape shape : shapes) {
    	    System.out.println(shape.getX() + " " + shape.getY());
    	}

    Men nu har vi ju en riktig "ritmetod", så vi ersätter utskriftsraden med ett anrop till shape.draw().

  4. Kör TestShapes och verifiera att du får ut rätt utskrifter. Även det gamla testprogrammet TestCircle ska fortfarande fungera!

Att förstå: Klassdiagram

Nu vill vi illustrera att det finns två olika klasser som skapar cirklar.

Uppgift 3.4: Implementera ett gränssnitt

Att göra 3.4.1: Rectangle

Vår nästa form är en rektangel. Även den ska implementera gränssnittet Shape.

  1. Skapa klassen Rectangle med de privata fälten x, y, width, height och color. Typerna vet ni sedan tidigare.

    Ge klassen en lämplig konstruktor.

    Skapa en toString()-metod.

  2. Implementera gränssnittet Shape och de metoder som krävs i detta gränssnitt.

  3. Utöka testprogrammet så det också lägger några rektanglar i listan. Testa.

Att göra 3.4.2: Text

Vår sista form är en text. Även den ska implementera gränssnittet Shape.

  1. Skapa klassen Text med de privata fälten x, y, size (storleken i punkter), color och text (en String) samt en lämplig konstruktor och toString()-metod.

  2. Implementera gränssnittet Shape och de metoder som ingår i gränssnittet.

  3. Utöka testprogrammet på lämpligt sätt. Testa.

Att förstå: Klassdiagram

Nu kan vi illustrera att en av testklasserna skapar cirklar, medan den andra skapar både cirklar, texter och rektanglar. Vi kan också visa att de olika formklasserna implementerar Shape.

Uppgift 3.5: Dela på kod med en abstrakt klass

När du kommer hit kan det hända att vi inte har gått genom abstrakta klasser på föreläsningarna. Detta blir i så fall ett av tillfällena där vi provar först och diskuterar teorin efteråt.

Att förstå 3.5.1: Kodlukter och upprepad kod

Vissa delar av de klasser som just har skapats är väldigt lika. Vi har upprepat deklarationer av till exempel x, y, color och motsvarande getters, och upprepning är sällan ett gott tecken. Det är inte direkt en bugg, eftersom programmet fortfarande kan fungera utmärkt. Men vi har en kodlukt, vilket kan definieras som ett symptom som tyder på ett djupare problem i koden. Koden "luktar lite konstigt", och då måste vi se om det beror på att vi har gjort något fel.

Vi vet ju att de klasser vi har implementerat har ett naturligt sammanhang: De är specialfall av Shape. Då borde de så klart också dela på den kod som är gemensam: Den härstammar ju från att de faktiskt begreppsmässigt hänger ihop. Det måste vi fixa! (Återigen kunde vi så klart ha gjort rätt från början istället, men eftersom man inte alltid vet exakt vart man är på väg när man börjar skriva koden, behöver man lära sig hur man fixar problem på ett effektivt sätt!)


Det hade kanske varit smidigt om man kunde lägga den gemensamma delen av implementationen i Shape, men ett gränssnitt är inte tänkt för detta: Gränssnitt anger vilka metoder som måste finnas men vi ska inte implementera dem direkt där. Lösningen är att vi använder oss av en abstrakt klass istället, som vi kallar AbstractShape.

Vi kommer att diskutera abstrakta klasser i mer detalj under den andra av de två föreläsningarna om typhierarkier. Just nu räcker det att veta att abstrakta klasser tillåter oss att implementera vissa metoder men lämna vissa "ofärdiga". Detta är alltså ett ett mellansteg mellan gränssnitt och vanliga konkreta klasser, och precis som med gränssnitt kan man inte skapa nya objekt genom att direkt anropa en konstruktor i den abstrakta klassen. Vi kan ju inte skapa "new List()" eftersom metoderna i List saknar implementation, men vi kan skapa "new ArrayList()" där ArrayList är en konkret implementation av List. På samma sätt kan vi inte skapa objekt med new AbstractShape(), men vi kommer att kunna skapa objekt av dess konkreta subtyper.

Att ha både ett gränssnitt och en abstrakt klass ger flexibilitet: Man kan välja att ärva från den abstrakta klassen eller att implementera gränssnittet direkt. Detta diskuteras mer under föreläsningarna.


Vi skapar därför en ny klass AbstractShape som kommer att kunna innehålla även fält och metoder. Sedan kan formerna ärva sitt beteende ifrån denna. En abstrakt klass har även den egenskapen att man inte behöver implementera alla metoder i den. Eftersom vi inte kan implementera en "generell" draw() för godtyckliga former (vad skulle det betyda?) kan vi lämna till subklasser att implementera denna. Detta får alltså som effekt att man inte kan skapa objekt av klassen AbstractShape eftersom den inte är fullständigt specificerad.

Överkurs
Java 8 och senare tillåter faktiskt vissa metoder att implementeras i ett gränssnitt/interface. Detta är tänkt att användas i specialfall när man behöver lägga till funktionalitet utan att kunna påverka de implementerande klasserna, inte som ett generellt programmeringsverktyg. Det leder också till mer komplicerade regler för overriding / prioritering av metoder, och vi hoppar därför över detta i den här grundkursen.

Att göra 3.5.1: AbstractShape

  1. Skapa en ny "tom" klass public abstract class AbstractShape. Se till att den implementerar Shape – alla subklasser till AbstractShape ska ju vara Shapes.

  2. Byt ut implements Shape mot extends AbstractShape i klasserna Circle, Rectangle och Text. Detta innebär att klasserna blir AbstractShape, men de kommer fortfarande också att vara Shapes eftersom detta gränssnitt implementeras av AbstractShape.

  3. Nu är vi redo att flytta ut vissa definitioner till den abstrakta klassen.

    Navigera till Circle och välj Refactor | Pull Members Up. Välj att medlemmar ska dras upp till AbstractShape, inte Shape. Markera fälten x,y,color, som är gemensamma för alla former. Markera även motsvarande getters. Den här gången görs metoderna inte abstrakta: Implementationen ska följa med upp.

    Vi markerar inte radius eller getRadius(), eftersom bara cirklar har radier. Inte heller draw() eller toString(): De ska visserligen finnas i alla former men behöver ha en egen implementation i varje klass.

    Vilken ordning medlemmarna och konstruktorparametrarna hamnar i kan bero på i vilken ordning man väljer dem i listan! Blir det fel kan ni göra "undo" och göra om denna refactoring.

    Välj Refactor. IDEA gör nu några ändringar för att koden fortfarande ska fungera.

    1. De valda fälten blir protected i AbstractShape istället för private, så att subklasser fortfarande kan komma åt dem.

    2. IDEA skapar också automatiskt en konstruktor för AbstractShape.

    3. Eftersom fälten nu är flyttade är det upp till AbstractShape att hantera initialisering av dem. Vissa tilldelningar i konstruktorn för Circle ersätts därför med anropet super(x,y,color), som vidarebefordrar parametrarna från Circle upp till AbstractShape:s konstruktor. (Ett anrop till super() ligger alltid först i en konstruktor.)

  4. Tyvärr vet inte IDEA att den ska göra motsvarande ändringar i de andra klasserna.

    Navigera till Rectangle. Ta bort fälten x/y/color, ta bort motsvarande getters, och ta bort initialiseringen ur konstruktorn.

    Lägg sedan till super()-anrop motsvarande det i Circle-konstruktorn. Ta bort de tre getter-implementationer som nu blev onödiga eftersom samma kod redan ärvs ner från AbstractShape.

    Gör samma sak för Text.

Som ni märker kan det krävas lite jobb för att hålla koden i god form. Det är lätt hänt att man undviker det, men det förlorar man ofta på i det långa loppet. I projektet kommer vi att ge komplettering för duplicerad kod av den här typen.

Vi vill poängtera att vi inte införde den abstrakta klassen bara för att "spara kod", utan att det faktiskt fanns ett naturligt sammanhang mellan klasserna också: De är olika sorters former.

Att förstå: Varför både Shape och AbstractShape?

Varför behåller vi både Shape och AbstractShape? Jo, om vi gör på det sättet får vi mer flexibilitet:

  • I Circle kan vi välja att ärva ner användbar kod från AbstractShape

  • I OtherShape kan vi välja att implementera hela Shape själva, kanske för att klassen redan ärver från OtherBase och alltså inte kan ärva från någon annan klass.

Uppgift 3.6: Delegering eller arv – Stack och Queue

Syfte

Nu har vi testat typhierarkier och ärvning, två begrepp som ofta associeras med just objektorienterade språk. Dessa begrepp ger oss bra och användbara verktyg, men risken är att man då fastnar för just verktyget och försöker anpassa alla problem till detta.

If all you have is a hammer, everything looks like a nail.

Vi ska inte använda ärvning bara för att det finns och "verkar så objektorienterat". Ofta finns det andra sätt att programmera som passar bättre – t.ex. komposition med delegering. Vi ska nu prova detta.

Att förstå 3.6.1: Kö och stack

Vi skall nu skapa två enkla datastrukturer: kö och stack. För enkelhets skull går vi inte in närmare på generiska typer (som ArrayList<Elementtyp>), utan håller oss till Person som element.

I en kan man bara lägga till element längst bak (enqueue) och ta bort längst fram (dequeue). Detta är enligt mottot "först in, först ut" (FIFO):

I en stack ("trave", som en trave tallrikar) påverkar man däremot alltid det översta elementet, med metoderna push och pop. Mottot är här "sist in, först ut" (LIFO):


Vi kommer att behöva någonstans att lagra de element som skall ligga i datastrukturerna. För att göra det enkelt för oss använder vi en redan existerande datastruktur, ArrayList från förra övningen. På så vis lämnar vi t.ex. över problemet att allokera lagom mycket lagringsutrymme till denna färdiga klass.

Men hur skall vi använda en ArrayList för att implementera detta?

Ett sätt är genom arv. Vi skulle då göra t.ex. Queue till en underklass till ArrayList. Genom arv skulle våra klasser få alla metoder från ArrayList. Smidigt och bra?

Ett av problemet med den här lösningen är att man får en datastruktur som kan modifieras på "fel" sätt. Till exempel kan man i en lista lägga till och ta bort element var som helst, inte bara i början som vi vill med en kö. Detta går delvis att arbeta sig runt, men det finns bättre alternativ. Det är ju inte så att en Queue är en ArrayList med extra funktionalitet, utan den har faktiskt mindre funktionalitet!

På ungefär samma sätt är en bil inte en ratt, även om den har en ratt.

Därför vill vi istället använda sammansättning (composition). Det betyder helt enkelt att vi låter vår Queue ha och använda en ArrayList istället för att vara en.

Uppgift 3.6.1: Kö och stack

  1. Skapa klassen Queue.

  2. Skapa ett privat fält List<Person> elements och initialisera detta till en ny ArrayList<Person>. Eftersom varje Queue har sina egna element får detta fält inte vara statiskt.

  3. Det finns metoder i ArrayList som vi vill göra tillgängliga för användaren av Queue. size() är en sådan metod. Eftersom elements håller koll på antalet element den innehåller, skriver vi helt enkelt bara en size()-metod i Queue som returnerar resultatet av elements.size(). Vi delegerar alltså det egentliga arbetet till elements-listan. (IDEA kan hjälpa till – läs vidare!)

    Vi delegerar arbetet till elements även för metoderna isEmpty(), clear() och contains() på precis samma sätt, genom att ha en metod som direkt anropar och returnerar svaret från motsvarande metod i elements.

    Delegering är så pass vanligt att IDEA kan automatisera det åt oss. Genom att välja Code | Delegate Methods och sedan välja elements kan man därefter välja precis vilka metoder man vill delegera.

  4. Vi behöver även några "egna" metoder. Lägg till:

    • enqueue(), som tar en person som parameter och lägger till den sist i kön, och
    • dequeue(), som hämtar personen som är först i kön, tar bort den ur kön (listan), och returnerar den.

    Dessa ska använda sig av metoderna elements.add() och elements.remove(int index) för att ta ut första elementet i elements och stoppa in ett element sist i listan.

    Tänk på att t.ex. enqueue ska lägga till ett element i en specifik kö (ett specifikt Queue-objekt), inte i kö-klassen i sin helhet. Metoderna du skriver här ska alltså inte vara statiska.

  5. Nu är Queue färdig och det är dags att skapa Stack. Klasserna är lika på alla punkter utom var objekt läggs till/tas ut. Därför kan man använda IDEAs funktion clone class som man kommer åt genom att trycka F5 när markören står i klassnamnet. Ange bara namnet Stack så skapas en ny klass som ser precis ut som Queue. Byt namn på enqueue/dequeue till push/pop och ändra koden i dem så att Stack beter sig som en stack.

  6. Skriv ett testprogram.

    Skapa en stack, lägg i tur och ordning in 5 olika personer i denna, och plocka sedan ut och skriv ut element i den ordning de kommer. Vi kunde använda en for-loop, men vi har egentligen inget behov av att veta vilken position vi är på – bara om det finns fler element kvar eller inte. Därför använder vi istället en while-loop som itererar så länge stacken inte är tom.

    Gör även samma sak med en kö.

  7. När vi nu tar en sista titt på vad vi har gjort upptäcker vi ett par saker:

    • Stack och Queue har något gemensamt: De är en sorts listmanipulatorer som behandlar saker i listor. Då kan det kanske finnas anledning för detta att synas även i typhierarkin, genom att klasserna implementerar ett gränssnitt eller ärver från en gemensam abstrakt klass.

    • De har också en del gemensam kod, för att lagra saker i listor och vidarebefordra anrop till listor. Detta skulle man kunna flytta upp till en gemensam abstrakt klass.

    Gör detta! Skapa en superklass med något lämpligt namn, t.ex. ListManipulator. Flytta de gemensamma implementationerna dit. Här kan IDEAs Extract Superclass vara till hjälp.

    (Var detta nödvändigt? Kanske inte till 100 procent i just detta fall. Men det är bra att lära sig hitta förbättringsmöjligheter och att öva på att göra förbättringarna. I projektet är det inte ovanligt att bedömningar dras ner av strukturella problem där man t.ex. bör införa lämpliga klasser som representerar delade egenskaper.

Uppgift 3.7: Likhet, identitet och namn

Att förstå 3.7.1: equals() och hashCode()

I Java används operatorn "==" för att jämföra värden, och värdet av en "objektvariabel" är en pekare. Därför skulle följande kod skriva ut false:

    Circle c1 = new Circle(1,1,1,Color.BLACK);
    Circle c2 = new Circle(1,1,1,Color.BLACK);
    if (c1 == c2) System.out.println("true");
    else System.out.println("false");

Eftersom c1 och c2 pekar på olika objekt har de olika värde, trots att de två objekten har har identiskt "innehåll" / state.

Om man istället vill jämföra objekten som pekas på, används i Java metoden equals() som jämför ett objekt med ett annat godtyckligt objekt.

Överkurs
Javas equals() motsvaras av Pythons ==.
Javas == motsvaras av Pythons is.

Så här kunde det se ut innan vi införde AbstractShape:

public class Circle implements Shape {
    ...
    public boolean equals(Object other) {
        // I am an object and can't be equal to "no object"!
        if (other == null) return false;

        // Does the other one have exactly the same class?
        // Otherwise we're not equal!
        if (other.getClass() != this.getClass())
            return false;

        // Use casting to get a pointer of type Circle
        Circle that = (Circle) other;

        // Check if all fields are equal.
        // For primitive types we use ==,
        // and for objects we use equals().
        return this.x == that.x && 
               this.y == that.y &&
               this.color.equals(that.color) && 
               this.radius == that.radius;
    }
}

Men nu när vi har AbstractShape har vi ju delat upp fälten i två metoder. Då måste det istället se ut ungefär så här, så att varje klass kan ta hand om sina egna fält. Läs kommentarerna!

public class AbstractShape implements Shape {
    public boolean equals(Object other) {
        // I am an object and can't be equal to "no object"!
        if (other == null) return false;

        // Does the other one have exactly the same class?
        // Otherwise we're not equal!
        if (other.getClass() != this.getClass())
            return false;

        AbstractShape that = (AbstractShape) other;

        return this.x == that.x && 
               this.y == that.y &&
               this.color.equals(that.color);
    }
}

public class Circle extends AbstractShape {
    public boolean equals(Object other) {
        // Are we equal according to the superclass?
        // Using "super.method(...)" calls the implementation that
        // was defined in the superclass: AbstractShape (above),
        // which tests that o!=null, that o has the same class,
        // and that x/y/color are equivalent.
        if (!super.equals(o)) return false;

        // Are all of my own "new" fields equal as well?
        return this.radius == that.radius;
    }
}

Då skulle följande kod skriva ut true:

    Circle c1 = new Circle(1,1,1);
    Circle c2 = new Circle(1,1,1);
    if (c1.equals(c2)) System.out.println("true");
    else System.out.println("false");

Metoden hashCode returnerar en hashkod för ett objekt, något som behövs om man t.ex. ska använda objektet som nyckel i en HashMap (Javas motsvarighet till Pythons dict). Detta kommer ni att lära er mer om i senare kurser.

Överkurs
Att implementera hashCode() i Java motsvaras av att implementera __hash__() i Python.
Överkurs
Tanken med hashkoder, som är heltal, är att:
  • Om två objekt är lika (equals), MÅSTE de ha samma hashkod. Ekvivalent: Om två objekt har olika hashkod, måste de vara olika.

    Om man vill leta efter ett visst objekt i en samling, räcker det alltså att leta bland de objekt som har samma hashkod. Alla andra objekt kan man ignorera. Det kan man använda till att t.ex. lägga alla objekt vars hashkod slutar på 00 i en delsamling (som brukar kallas "hink"), alla som slutar på 01 i en annan hink, och så vidare. Vill man se om ett visst objekt finns med någonstans, räcker det att titta i hinken som innehåller objekt med samma slutsiffror. Objekten i de andra hinkarna har ju andra slutsiffror, och alltså andra hashkoder, och är alltså garanterat olika.

  • Om två objekt är olika (!equals), VILL vi gärna att de har olika hashkod, men det är OK att det råkar bli samma.

    Alla objekt kunde t.ex. ha hashkod 012345678, men då hamnar alla objekt i hink 78 och man får ingen nytta av hashkoden.

    Men så länge som hashkoderna blir ganska jämnt fördelade är det helt OK att det ibland blir krockar. Oavsett vad hashkoden är kommer vi att leta bland alla objekt i den valda hinken, och kommer att hitta rätt. Det tar bara mer tid om det inte är en jämn fördelning.

    En bra implementation av hashCode() tar hänsyn till många olika aspekter av objektet för att räkna ut en hashkod som är väl fördelad över alla 4 miljarder int.

Att göra 3.7.1: equals() och hashCode()

  1. Skapa equals()-metoder i AbstractShape. Använd IDEA som hjälp via Code | Generate | equals() and hashCode(): Alt+Insert. Vi är egentligen bara intresserade av equals(), men IDEA skapar också hashCode(). Denna metod används för vissa datastrukturer men är inte relevant för oss just nu.

    IDEA frågar: "Accept subclasses as parameter to equals() method?". Kryssa inte i den rutan. Vi vill bara att två objekt ska kunna anses lika om de har exakt samma klass.

    Därefter får du välja vilka fält du anser vara relevanta för att två objekt ska vara lika. Om två AbstractShape-objekt har samma koordinater men olika färg, ska de då anses vara likadana eller inte? Det avgör du själv utifrån programmets behov. Normalt anger man exakt samma fält när IDEA frågar vad som ska vara med i hashCode().

    Om det finns icke-primitiva fält, t.ex. color som är en objektpekare, kommer IDEA att fråga om de kan garanteras att inte vara null. Detta är för att inte generera onödiga null-jämförelser som kan ta någon extra nanosekund. Om du är osäker så ange att fälten kan vara null (detta kan vara något mindre effektivt men ger alltid rätt resultat).

  2. Titta på de skapade equals()-metoderna. Verkar de rimliga? Man kan aldrig vara säker på att automatgenererad kod helt överensstämmer med vad man själv tänkte.

Tetris 3.8: Timer

Att förstå 3.8.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. Just nu kan vi komma en bit på vägen genom att i alla fall slumpa om spelplanen med jämna mellanrum, motsvarande hur ofta ett block skulle flyttas ett steg nedåt.

Hur får man något att hända med jämna mellanrum? Ett inte alltför ovanligt misstag är att man lägger in en loop som "stegar fram" ett steg, gör en paus av konstant längd, "stegar fram" nästa steg, och så vidare. Problemet är att när pausen är av konstant längd kommer tiden från starten av ett steg till starten av nästa att variera, beroende på hur lång tid själva steget tar, vilket är olika beroende på dator, CPU-belastning, med mera. Exempel:

      Jobba  50 ms, vänta 500 ms
      Jobba 120 ms, vänta 500 ms
      Jobba  80 ms, vänta 500 ms
      Jobba  60 ms, vänta 500 ms
      Jobba 200 ms, vänta 500 ms
    

Vi får göra på något annat sätt. Som tur är finns en klass som heter javax.swing.Timer som vi kan använda för att få ett "steg" i spelet att köras regelbundet.

Blanda inte ihop den med andra klasser som heter Timer, t.ex. java.util.Timer! Det kan se ut att fungera en del av tiden, men händelser kommer att inträffa i fel tråd vilket kan leda till mystiska fel som enbart uppstår ibland.

Här ser vi en anledning till att man lägger klasser i olika paket. Om du i senare steg inte får rätt Timer, Action eller ActionEvent så se till att följande imports finns under package i början på filen:

import javax.swing.*;
import java.awt.event.ActionEvent;

I den här uppgiften ska vi bara göra en enkel utforskning av Timer. Senare, när vi har gått vidare till en fullständig grafisk komponent, kommer vi att titta närmare på lämplig modellering av spelet som helhet och var spelloopen egentligen ska placeras.

Det vi behöver veta kan vi då lära oss av följande exempel och beskrivningen under.

    final Action doOneStep = new AbstractAction() {
        public void actionPerformed(ActionEvent e) {
            // Gå ett steg i spelet!
        }
    };
    
    final Timer clockTimer = new Timer(500, doOneStep);
    clockTimer.setCoalesce(true);
    clockTimer.start();
    ...
    clockTimer.stop();

En Timer ser alltså till att Swings egna händelsehanteringstråd anropar en viss handling ("action") med jämna mellanrum. I detta fall är det 500 millisekunder mellan anropsstarterna, och det som anropas är den handling som vi kallade doOneStep. Eller rättare sagt, Timer vet att den får någon sorts Action som parameter, och att alla Action-objekt har metoden actionPerformed som den kan anropa.

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

Genom att anropa setCoalesce(true) ser vi till att anropen inte "köas upp" om ett visst anrop till doOneStep() skulle ta för lång tid. Vi kan starta timern när spelet börjar och stoppa den när spelet är över.

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

Notera att anonyma klasser, som vi använde ovan, bara bör användas för små klasser. Om implementationen av actionPerformed() blir lång kan man vilja ge klassen ett namn istället:

    class StepMaker extends AbstractAction {
        public void actionPerformed(ActionEvent e) {
            // Gå ett steg i spelet!
        }
    };

    final Action doOneStep = new StepMaker();

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

  2. Testkör och se att spelplanen slumpas om.

Efter föreläsning 6, GUI

Uppgift 3.9: Grafiskt gränssnitt för former

Nu är det dags att se till att ritprogrammet faktiskt kan rita ut sina former. Vi ska också titta mer på refactoring i IDEA.

Om du är snabb och hinner hit innan GUI-föreläsningen kan du ändå fortsätta om du vill: Vi ska inte gå vidare och skriva ett fullfjädrat ritprogram, utan nöjer oss med att implementera ett mycket enkelt program som visar upp ett fönster, plus att skriva om utritningsmetoderna så att de faktiskt ritar i detta fönster. För detta ger vi ett enkelt kodskelett.

Att göra 3.9.1: Grunden till en diagramkomponent

  1. Vi kallar en uppsättning former för ett diagram. Skapa därför ny klass, DiagramComponent, enligt följande mönster. Detta ska bli en grafisk komponent som vet hur man ritar upp ett helt diagram på skärmen.

    import javax.swing.*;
    import java.awt.*;
    
    public class DiagramComponent extends JComponent
    {   
        @Override protected void paintComponent(final Graphics g) {
    	super.paintComponent(g);
    
    	// Senare ska vi rita upp alla former här!
        }
    }
  2. Lägg till ett privat fält shapes som innehåller en lista av former.

    Skapa en konstruktor som sätter shapes till en ny lista (detta har ni gjort förr!).

    Lägg till metoden public void addShape(Shape s), som ska addera en ny form till listan.

Att förstå 3.9.2: Rita ut former

Alla komponentklasser har metoden paintComponent(), som kommer att anropas automatiskt av Java när det är dags att rita upp en specifik grafisk komponenten på skärmen. Er uppgift blir nu att skriva en sådan metod, med kod som ritar upp alla former som finns i komponentens formlista.

För att rita i komponenten använder man Graphics-objektet som man får som parameter. Namnet Graphics är egentligen lite missvisande. Egentligen kunde detta ha hetat Painter, eftersom det är den som har metoder för att rita ut pixlar, linjer och så vidare på skärmen, t.ex. drawLine().

Ett sätt att lösa detta är att paintComponent själv vet hur varje typ av form ska ritas ut. Detta har stora nackdelar i och med att informationen om formerna blir centraliserad: DiagramComponent måste ha detaljerad kunskap om hur alla former ska ritas ut. Vad var det då för poäng med att kunna hantera dem på ett generellt sätt, som "någon sorts Shape"?

Istället ska vi låta varje form själv veta hur den ritas ut. DiagramComponent:s uppgift blir då helt enkelt att veta hur man ritar ut många former på skärmen. I vårt enkla exempel kan detta bestå av att rita upp dem i godtycklig ordning, men i ett mer avancerat program skulle DiagramComponent också t.ex. behöva rita ut dem i rätt ordning ("bakifrån och fram").

Det första steget i paintComponent () blir därför att iterera över de former som finns i listan och för var och en av dem anropa draw(), så formen själv kan rita ut sig.

Att göra 3.9.2: Rita ut former

  1. Just nu har varje Shape-klass en draw()-metod, men den var ju bara en platshållare som just nu skriver ut lite text. Den metoden måste göras om för att rita med hjälp av ett Graphics-objekt. Det betyder i sin tur att metoden måste skrivas om för att ett Graphics-objekt som parameter.

    Även här kan IDEA hjälpa till. Ställ markören i ordet draw och tryck Ctrl-F6: Refactor | Change Signature. Tryck det gröna plusset och fyll in type Graphics, name g. Det talar om att metoden ska få en ny parameter, både i Shape och i alla tre konkreta implementationer. Klicka sedan i "use any var", vilket gör att IDEA också lägger till en parameter till anropen till draw() om det finns en Graphics-variabel tillgänglig där anropet sker. Tryck sedan Refactor.

    Denna refactoring kan även användas för att byta ordning på parametrar i metoddeklaration och alla metodanrop.

  2. Nu undrar vi om det här införde några problem. Tryck Ctrl-F9 för att kompilera. Jodå: TestShapes fungerar inte längre, eftersom den anropar draw() utan parametrar. Vi kommer snart att testa detta i vår nya grafiska klass istället, så vi kommenterar bort anropet och kompilerar om igen.

  3. Ändra implementationerna av draw() i alla tre konkreta formklasser. Kommentera bort utskrifterna och använd istället följande kodrader som ledning till hur man kan rita ut olika typer av former eller text:

        g.setColor(color);
        g.drawOval(x, y, width, height); // calc. from radius!
        g.drawRect(x, y, width, height);
        g.setFont(new Font("serif", Font.PLAIN, size));
        g.drawString(text, x, y);
  4. Nu behöver vi till slut ett fönster som kan visa upp diagramkomponenten. Vi diskuterar hur detta fungerar på GUI-föreläsningarna. För tillfället nöjer vi oss med att basera koden på denna exempelkod för klassen DiagramViewer. Den slumpar fram ett antal former som visas på skärmen. Fungerar den som den är, eller behöver den justeras?

    package shapes;
    
    import javax.swing.*;
    import java.awt.*;
    import java.util.List;
    import java.util.Random;
    
    
    public class DiagramViewer
    {
        private final static List<Color> COLORS =
    	    List.of(Color.BLACK, Color.RED, Color.GREEN, Color.BLUE,
    		    Color.CYAN, Color.YELLOW, Color.MAGENTA);
    
        // Set a fixed seed 0 so you always get the same
        // shapes (for debugging)
        private final static Random rnd = new Random(0);
    
        private static Color getRandomColor() {
    	return COLORS.get(rnd.nextInt(COLORS.size()));
        }
    
        private static Circle getRandomCircle() {
    	return new Circle(rnd.nextInt(400), rnd.nextInt(400),
    			  rnd.nextInt(200), getRandomColor());
        }
    
        private static Rectangle getRandomRectangle() {
    	return new Rectangle(rnd.nextInt(400), rnd.nextInt(400),
    			     rnd.nextInt(200), rnd.nextInt(200),
    			     getRandomColor());
        }
    
        private static Text getRandomText() {
    	return new Text(rnd.nextInt(400), rnd.nextInt(400),
    			"Hello");
        }
    
        public static void main(String[] args) {
    
    	DiagramComponent comp = new DiagramComponent();
    
    	final Random rnd = new Random(0);
    
    	for (int i = 0; i < 10; i++) {
    	    switch (rnd.nextInt(3)) {
    		case 0:
    		    comp.addShape(getRandomCircle());
    		    break;
    		case 1:
    		    comp.addShape(getRandomRectangle());
    		    break;
    		case 2:
    		    comp.addShape(getRandomCircle());
    		    break;
    	    }
    	}
    
    	JFrame frame = new JFrame("Mitt fönster");
    	frame.setLayout(new BorderLayout());
    	frame.add(comp, BorderLayout.CENTER);
    	frame.setSize(800, 600);
    	frame.setVisible(true);
        }
    }
    

    Gör klart klassen, starta, och se ditt nya slumpmässiga konstverk!

Tetris 3.10: En grafisk spelplan

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

Om du inte är van vid grafikprogrammering med Graphics2D i Java kanske du vill vänta till du har varit på grafikföreläsningen. Se även vanliga lösningar.

Att göra 3.10.1: Grafik del 1 – komponenten

  1. Vi kommer att förändra TetrisViewer så pass mycket att det kan vara av intresse att spara undan den. En version borde så klart vara incheckad i versionshanteringen, men just i det här fallet kan vi också spara den nuvarande versionen som en kopia. Högerklicka på TetrisViewer och välj Refactor -> Copy och döp den till TetrisViewer_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å kan ta 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!

Att förstå 3.10.2: Grafik del 2 – utritningen

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

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

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

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

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

Exempel: paintComponent()

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

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

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

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

Att göra 3.10.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 TetrisViewer har en pekare till). I en kommande uppgift ska vi använda ett mer principiellt sätt att få TetrisComponent att uppdatera sig vid ändringar.

Att förstå: Magiska konstanter

Här är det läge att påpeka att du ska undvika magiska konstanter, i betydelsen "konstanter som används i koden utan någon förklaring av var de kommer ifrån". Om t.ex. en kvadrat ritas ut i en konstant storlek av 30x30 pixel är det bra att lägga in namngivna konstanter som man kan använda varje gång man refererar till bredden eller höjden. Då blir det mycket lättare att läsa uttrycken och att förstå hur koden fungerar. Till exempel kan man lägga in:

  • Fältet private final static int SQUARE_SIZE = 30; i klassen, om detta används i flera metoder.

  • "Variabeln" final int squareSize = 30; i en enskild metod, om värdet bara används där.

I båda fallen ska man använda final för att markera att detta är tänkt att vara en konstant. I det första fallet använder vi också static för att vi inte behöver en kopia av konstanten för varje TetrisComponent-objekt som skapas.

Namngivna konstanter är viktigt för läsbar kod, vilket i sin tur är viktigt för betyget på projektet!

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_SIZE - MARGIN)" än "if (x > 27)", även för den som vet att SQUARE_WIDTH är 30 och MARGIN är 3.

Överkurs

Ännu snyggare än att lägga in en namngiven konstant kan det bli om man (alltså TetrisComponent) dynamiskt tar reda på hur stor man är vid uppritningen (i paintComponent), och anpassar storleken på kvadraterna till detta. Då kan allt följa med när man ändrar storlek på fönstret under körning. Tips: this.getSize(), där this är din TetrisComponent.

Tetris 3.11: Observer-mönster / lyssnare

Att förstå 3.11.1: Lyssnare och notifiering

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

Ett annat sätt är att använda designmönstret Observer. I korthet innebär detta att de objekt som kan vilja veta när något händer ses som observatörer. De objekt där något kan hända håller reda på vilka observatörer som har registrerat sitt intresse, och informerar dem (via ett metodanrop) när något faktiskt händer.

Vi har faktiskt redan sett denna sorts "callback", till exempel i Javas GUI-bibliotek. Alla ActionListeners är en sorts observatörer som är intresserade av att veta när någon t.ex. trycker på en knapp. Metoder som addActionListener() lägger till en lyssnare som är intresserad av en specifik typ av handling. Klasser som JButton känner inte till era specifika lyssnare i förväg, men de har ett sätt för lyssnarna att registrera sitt intresse. Om man vill kan man registrera ett godtyckligt antal lyssnare på samma knapp.

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

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

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

Överkurs

Det går också bra att låta boardChanged ta parametrar som talar om vad som har ändrats, och/eller att skapa flera olika metoder i BoardListener som anropas vid olika händelser: Ett block har flyttats, ett block har "landat", spelet är slut, ...

Resultat

Slutresultatet av denna ändring ska bli att allt på skärmen ser ut precis som tidigare – men att koden har en bättre struktur. Den är mer modulär i och med att vi har minskat den starka kopplingen mellan datalagring och utritning på skärmen. Detta är en mycket viktig ändring.

Tetris 3.12: Fallande block

Att förstå 3.12.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.

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

Tetris 3.13: Tangentbordsstyrning

Att förstå 3.13.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 mellan förflyttningarna nedåt. Eftersom key bindings hanteras asynkront, "i bakgrunden", ska detta fungera automatiskt.

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

Överkurs

Den som är bekant med trådad programmering undrar kanske om det nu kan hända att Board anropas från flera trådar samtidigt, dels via tangentbordsbindningen och dels via timerhandlingen. Nej, faktiskt inte. Tangentbordsbindningar behandlas i Swings händelsehanteringstråd, så det är den tråden som flyttar block i sidled. Den timer vi valde att använda tidigare är javax.swing.Timer, och den anropar också sin handling (som i sin tur anropar Board.tick()) från Swings händelsehanteringstråd. Därför bör vi inte få några trådningsproblem här.

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

Tetris 3.14: Kollisionshantering och "game over"

Att förstå 3.14.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 3.14.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 ä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-2019-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.

Tetris 3.15: Rotation av tetrisblock

Att förstå 3.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 3.15.1: Rotation

  1. Lägg 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 för handledarens skull.

Ramtjocklek

Vi kan nu se varför ramen i en tidigare uppgift behövde vara två block stor. Om ett I-block placeras intill ramen och sedan roteras kan det leda till att den nya positionen hamnar två steg utanför spelplanen.

Tetris 3.16: Borttagning av rader

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

För dig som vill ha betyg 4-5 på kursen

Om du vill ha betyg 4 eller 5 på kursen behöver du visa upp både bredare och djupare kunskaper inom Java och objektorienterad programmering.

Detta kan man till stor del visa upp i projektet, oavsett vilket projekt man väljer. Men det finns också vissa specifika kunskaper som alla behöver demonstrera för högre betyg, men som inte nödvändigtvis har en naturlig plats i alla projekttyper – särskilt när man får välja helt fritt. Det kan hända att man inte har något större behov av t.ex. felhantering, filhantering och ett par väl valda designmönster.

För att detta inte ska leda till problem har vi istället infört ett antal uppgifter i Tetris som är specifika för kursbetyg 4 respektive 5. De uppgifterna är tillrättalagda så att man kan demonstrera kunskaperna utan att behöva "tvinga" in dem i ett projekt där de egentligen inte passar.

Dessa uppgifter är fortfarande enskilda och ska utföras på egen hand!

Dessa uppgifter räknas till projektet i den meningen att betyget enbart ges på just projektet (och registreras som del av projektet i WebReg). De redovisas dock tillsammans med Tetris.

För dessa högre betyg kan man ibland behöva leta mer information på egen hand!

[Betyg 4, efter GUI] Tetris 3.17: Mer GUI: Menyer!

Det är dags att vidareutveckla resten av det grafiska gränssnittet för spelet en liten aning.

Att göra 3.17.1: Menyer

  1. Lägg till menyer i spelet enligt vanliga lösningar. Se minst till att det finns en meny med valet "Avsluta". Lägg till fler val efter behov och önskemål.

  2. Se till att "Avsluta" gör att en dialogruta visas med hjälp av JOptionPane, där man får bekräfta att man vill sluta. Se vanliga lösningar. Vid bekräftelse ska spelet avslutas via System.exit(0).

[Betyg 4, efter GUI] Tetris 3.18: Resurshantering

Korrekt hantering av resurser (bilder och andra datafiler som följer med i ett program) är viktigt för att även andra än författaren ska kunna köra ett program. Det är till exempel inte ovanligt att projekt i denna kurs innehåller hårdkodade sökvägar till bilder som ligger på författarens hårddisk, så att assistenter inte kan testa den inlämnade koden. Därför gör vi nu ett snabbt test av användning av Javas inbyggda stöd för resurshantering. Detta låter programmet hitta sina egna filer, utan att veta var det är installerat eller om det till och med ligger inuti en JAR-fil.

Att göra 3.18: Resurshantering

  1. Skapa mappen pics på samma nivå som src och libs i din projektkatalog.

  2. Högerklicka pics i IDEA och välj Mark Directory as | Resources Root. Detta gör att filerna i pics inte bara kommer att finnas i din källkatalog, utan även kommer att kopieras över till det kompilerade resultatet.

  3. Lägg in en bildfil i pics, från kursen eller var som helst, och addera den till Git. Ta gärna hänsyn till vad andra kan tycka är trevligt att se...

  4. Skapa en ny GUI-klass som kan visa en "startbild" för ditt Tetris-program. Klassen ska läsa in startbilden med hjälp av ImageIcon och ClassLoader.getSystemResource() så som diskuteras under andra delen av GUI-föreläsningen. Klassen ska sedan visa bilden på något sätt, lämpligen i ett par sekunder, och sedan stängas. Att det blir snyggt är inte viktigt, eftersom vi fokuserar på just resurshanteringen.

    Om du har lagt bilden foo.png direkt i pics ska du ange namnet /foo.png. Det är bara om du har lagt den i en underkatalog till resurskatalogen pics som du ska ange en katalog som del av resursnamnet.

  5. Använd den nya GUI-klassen i ditt Tetris-spel så att bilden visas en kort tid innan spelet startar.

[Betyg 4, efter GUI] Tetris 3.19: Poänghantering

Att förstå 3.19.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.

Att göra 3.19.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 poängen uppdateras enligt ovan.

  3. Se till att nuvarande poäng hela tiden visas (grafiskt) någonstans i spelet.

Att förstå 3.19.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 spelomgång kan se en highscorelista.

Hur ska rätt del av koden komma åt listan, om vi gång på gång skapar ett nytt Board och detta leder till en ny spelomgång? För att implementera det på rätt sätt får vi tänka på vilka egenskaper vi vill ha, eller i alla fall vilka vi vill förbereda för.

  • Vi vill använda oss av bra objektorientering. En highscorelista ska därmed vara ett objekt, en instans av en klass, och informationen ska lagras i objektets vanliga, icke-statiska fält.

  • Vi vill förbereda för att man vill kunna spela många parallella spel (kanske över nätet), och att man i flera av dessa samtidiga spelomgångar vill kunna använda sig av samma gemensamma highscorelista.

  • Samtidigt kanske vi i framtiden vill separera det hela så att vi har olika highscorelistor i olika grupper eller spelligor.

Då blir nog det bästa att vi har en HighscoreList-klass med den funktionaliteten vi behöver, att vi centralt (i uppstarten) ser till att bara skapa ett enda objekt av den klassen, och att vi på sedvanligt sätt skickar vidare detta unika objekt till alla som behöver tillgång till det.


Det finns flera alternativ som vi av olika anledningar förkastar:

  • Om man inte ville ha stöd för att ha flera olika highscorelistor kunde man rent tekniskt ha lagrat all information om highscores i statiska variabler i någon klass. "Det finns ju bara en lista!"

    Men att lagra sådan information i klassen istället för i enskilda objekt följer inte objektorienteringens principer utan blir snarare procedurell programmering. Det följer inte heller allmänna programmeringsprinciper om att undvika globala variabler och globala tillstånd.

  • Med hjälp av ett designmönster som heter Singleton kunde man ha lagrat informationen i ett objekt, men sluppit att skicka vidare objektet till alla som behövde det. Singleton gör det möjligt för vem som helst att hitta det globala, unika HigscoreList-objektet.

    Detta bryter fortfarande om principerna om att undvika globala variabler och tillstånd. Det leder också till svårigheter att följa hur informationen egentligen flödar genom ett program, eftersom globalt tillgängliga objekt "kortsluter" informationsflödet, vilket i sin tur kan göra det svårare att skapa en bra uppdelning av programmet i tydliga ansvarsområden.

    Singleton kallas därför ibland ett antimönster som man inte bör följa. Det främsta undantaget är när man verkligen inte kan veta vem som kan behöva få tillgång till ett visst unikt objekt och skicka med det dit. Att veta detta är inget problem i Tetris, och inte heller i 99% av alla kursprojekt.

Att göra 3.19.2: Highscorelista

  1. Vi ska inte lagra highscorelistan i en fil förrän i nästa uppgift. För att highscorelistan ändå ska fungera och vara meningsfull 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 lagrar antal poäng plus namn på den som fick poängen.

  3. Skapa en HighscoreList-klass. Den ska innehålla funktionalitet för att lagra highscores, lägga till highscores samt få fram samtliga highscores som finns i listan.

  4. När man kör igång spelet ska det skapa en enda HighscoreList en gång för alla. Detta bör inte ske i Board, utan den kan t.ex. vara den som skapar Board som också skapar listan. Annars skulle ju highscores "nollställas" varje gång man skapar ett nytt Board som har en egen ny HighscoreList!

  5. Så snart en spelomgång avslutas ska programmet fråga användaren efter ett namn. 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.

  6. 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 t.ex. visas med drawString(), som en sträng i en textkomponent, eller som en sträng i en dialogruta. Vi fokuserar inte på hur snygg visningen är.

Att förstå 3.19.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. Typvariabeln T får alltså värdet Highscore denna gång:

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());

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

[Betyg 4, efter GUI/exceptions] Tetris 3.20: Spara på fil

OBS: I slutet av detta steg använder vi oss till viss del av exceptionhantering, som kommer i en föreläsning efter GUI. Det går ändå bra att börja på en gång.

Nu ska vi lagra highscores i en fil, och även läsa tillbaka dem – men först måste vi diskutera vilket format man kan använda för att lagra den informationen.

Att förstå 3.20.1: Format för datalagring

Att läsa och spara information på fil kan vara viktigt i många program. I denna uppgift kommer vi att testa en enkel variant av detta där vi lagrar highscorelistan i en fil.

Varje highscore har i sig ett ganska enkelt format: Ett namn (sträng) och ett poängvärde (heltal). För sådana objekt kan det vara frestande att själv hitta på ett enkelt format, t.ex. en textfil där varje rad har poäng mellanslag namn som fyller resten av raden:

      10000 MittNamn
      4000 Jag är bäst
      3000 Hej på dej 

Men det händer ofta att man kommer på mer att lägga till senare, och då kan det bli krångligt att hålla reda på olika versioner av filformat. Borde vi ha lagt namnet inom citattecken? Om vi har mer info än bara highscores, hur separerar vi listan från resten av informationen? Och så vidare. Det kan vara bättre att direkt gå till en mer strukturerad informationshantering där man använder sig av ett färdigt markup-format.

Det finns många sådana och just nu kommer vi att prova JSON, JavaScript Object Notation. Trots namnet är JavaScript egentligen inte direkt relaterat till Java, men JSON används numera inom många språk. Här syns en typisk JSON-fil:

{
    "firstName": "John",
    "lastName": "Smith",
    "age": 25,
    "address": {
        "streetAddress": "21 2nd Street",
        "city": "New York",
        "state": "NY",
        "postalCode": 10021
    },
    "phoneNumbers": [
        {
            "type": "home",
            "number": "212 555-1234"
        },
        {
            "type": "fax",
            "number": "646 555-4567" 
        }
    ] 
}

Värden inom {} är mappningar, precis som Pythons dict, och värden inom [] är listor, precis som Pythons listor. Vi kommer alltså att kunna skapa en highscorelista som ser ut ungefär så här:

[
  {
    "points": 10000,
    "name": "Mittnamn"
  },
  {
    "points": 4000,
    "name": "Jag är bäst"
  }
  {
    "points": 3000,
    "name": "Hej på dej"
  }
]

Konverteringen från HighscoreList-objekt till JSON-format kan så klart göras genom att "manuellt" skriva ut strängar på det här formatet, men i denna uppgift ska vi istället ta hjälp av ett klassbibliotek för JSON. Det bibliotek vi rekommenderar heter Gson och är skrivet av Google. Det har vissa begränsningar när det gäller mer komplexa datastrukturer, men fungerar utmärkt för bland annat:

  • Primitiva datatyper och strängar

  • Enkla datastrukturklasser som Highscore, med ett antal fält som är primitiva datatyper eller strängar

  • Arrayer och arraylistor av specifika konkreta typer (till exempel ArrayList<HighScore>), med mera

Då kan vi "konvertera" enkla eller sammansatta objekt till JSON på följande sätt:

Gson gson = new Gson();
String listAsJson = gson.toJson(myHighscoreList);

Vill man ha ett snyggare JSON-format som vi människor lättare kan läsa, med radbrytningar och indentering, gör man så här:

Gson gson = new GsonBuilder().setPrettyPrinting().create();
String listAsJson = gson.toJson(myHighscoreList);

Sedan kan vi t.ex. skriva listAsJson till en fil.

För att sedan läsa in dessa objekt igen behöver "dataklasserna" som används ha en konstruktor utan argument. Du kommer alltså att behöva se till att konstruktorerna HighScore() och HighscoreList() existerar. Då kan Gson vid inläsningen börja med att skapa t.ex. ett "tomt" HighScore-objekt och sedan fylla i information för dess fält.

Anta nu att vi på något sätt har fått tag på strängen listAsJson som innehåller en JSON-representation enligt ovan, t.ex. genom att läsa in den från en fil. Då kan vi få fram motsvarande Javaobjekt på följande sätt:

Gson gson = new Gson();
HighscoreList list = gson.fromJson(listAsJson, HighscoreList.class)

Den sista raden kan se lite underlig ut. Varför anger man HighscoreList.class?

Jo, när Gson skapar sin textrepresentation av ett objekt tar den inte med någon information om vilken klass objektet hade. I exemplet på JSON-fil ovan stod det t.ex. aldrig att objektet var av typen HighscoreList; det syntes bara att det var "någon sorts lista". Genom att vi anger HighscoreList.class vet Gson vilken typ av data den ska förvänta sig som startpunkt. Sedan analyserar den själv klassen HighscoreList för att se vilka fält den har och vilka typer de har, och så vidare.

Överkurs
Hur kan den analysera det? Java stödjer så kallad reflection, som låter ett program inspektera sin egen struktur -- vilka klasser som finns, vilka metoder och fält de har, och så vidare.

Att göra 3.20.1: Spara och läsa in highscores

Första steget blir att skriva highscores till fil efter ett avslutat spel.

  1. Spara Gson-biblioteket någonstans i ditt projekt. Du kan t.ex. skapa en ny libs-katalog bredvid din src-katalog och spara biblioteket där.

    Addera filen till Git så det checkas in tillsammans med övriga filer.

    Högerklicka den i IDEA, välj "Add project library" och tryck OK:

  2. Testa att använda Gson genom att skriva ut (på skärmen) en sträng för en highscorelista efter varje avslutat spel. Fungerar det? Ser det ut som du förväntade dig? Varför / varför inte?

  3. Skriv en metod, kanske i HighscoreList, som sparar nuvarande highscorelista på fil i Gson-format. Spara t.ex. i nuvarande katalogen, eller i hemkatalogen: System.getProperty("user.home").

    Se till att denna metod anropas varje gång highscorelistan ändras. Om du t.ex. gör detta i metoden som lägger till nya highscores (som du kanske har kallat addScore) måste du tänka på att detta ändrar metodens kontrakt: Den kommer nu att betyda att man ska lägga till poäng i minnet och att de ska sparas på fil.

    Filhantering finns inte med på föreläsningarna, men i kursböcker (t.ex. Java Tutorial) och i gamla föreläsningsbilder. Att på egen hand kunna utforska de enklaste grunderna inom detta område är en del av det högre kravet för betyg 4.

    Tips: Använd PrintWriter för att skriva text till en fil, med korrekt konvertering av teckenkodning. Använd try-with-resources för att garantera att denna fil stängs om något går fel (en exception kastas).

    Hur ska man hantera de exceptions som kan uppstå? Just nu är det OK att göra det på godtyckligt sätt, och till och med att ignorera dem, så länge som programmet går att kompilera och fungerar i de fall man inte får exceptions. Felhanteringen ska vi snart titta närmare på.

  4. Testa. Spela ett spel, skriv in en highscore, och titta manuellt på filen så att den faktiskt sparas och ser ut som den ska.

  5. Se till att highscores kan läsas in igen vid uppstart av programmet. Detta kan göras genom att läsa in en sträng som man konverterar enligt exemplet ovan. Det kan också göras genom att ge fromJson() en godtycklig Reader, t.ex. en FileReader, som den själv kan läsa in strängen från (det behöver inte vara en JsonReader).

    Glöm inte att det ska fungera även om highscorelistan inte finns när du startar programmet första gången! Här kan man t.ex. prova att öppna filen, och om FileNotFoundException kastas vet man att filen inte finns och att man alltså behöver skapa en ny lista istället.

    (Det går inte att lägga inläsningskoden i konstruktorn för HighscoreList -- inläsningen skapar själv en ny HighscoreList, så det skulle leda till en oändlig loop.)

  6. Testa. Radera den gamla highscorefilen och testa att programmet fungerar även då. Spela ett spel så att highscorelistan sparas, stäng av, starta upp programmet igen, och testa att gamla highscores läses in och finns kvar.

Överkurs
I uppgiften ovan bestämde vi inte exakt hur man skulle se till att highscores sparas varje gång listan ändras. Ett snyggt sätt att lösa det på kunde vara att införa en HighscoreListener med en metod som anropas varje gång listan ändras, och implementera denna i en klass som får ansvaret för att spara på disk. Detta är dock överkurs med tanke på att vi just nu fokuserar på själva filhanteringen.
Överkurs

Varför använde vi inte Javas inbyggda serialization?

En anledning är att den är ganska "ömtålig": När man ändrar på klasser gäller det att man redan från början har gjort helt rätt för att man fortfarande ska kunna läsa in objekt skrivna med den gamla versionen av en klass.

En annan är att vi gärna vill att människor (eller program skrivna i andra språk) ska kunna läsa filerna, och Javas serialisering använder sig av ett specifikt och relativt komplext binärformat.

[Betyg 4, efter exceptions] Tetris 3.21: Felhantering

I förra uppgiften arbetade vi med läsning och skrivning av filer, ett av de områden där det ofta uppstår fel som signaleras som exceptions. Nu ska vi titta vidare på hur man ska hantera dessa typer av fel.

Att förstå 3.21: Felhantering

De undantag (exceptions) som kan uppstå i Tetris har just nu att göra med filhantering. Om det då till exempel blir problem när man vill spara sina highscores i en fil är det kanske inte så mycket som programmet kan göra åt problemet. Men man måste ändå meddela användaren om det, så användaren inte tror att allt fungerade! Det ska vi göra nu.

Var ska vi då lägga koden som meddelar användaren? En tanke kunde vara att lägga den direkt där man försökte spara filen... men vi har också diskuterat under föreläsningen om felhantering att den kod där felen uppstår inte nödvändigtvis är den som är ansvarig för användargränssnittet. Koden som sparar highscores i en fil borde gå att använda även om användaren spelar Tetris över nätverket och inte finns vid samma dator, och i det fallet vill man ju absolut inte skicka upp en dialogruta på Tetrisservern.

Funktioner på lägre nivå ska inte själva meddela användaren när något gick fel: Då går det inte att återanvända funktionerna i andra situationer, när man vill hantera felen på annat sätt.

Alltså ska spara-funktionen skicka vidare felsignalen (undantaget) uppåt. Den ska inte stoppa förrän:

  • Felsignalen når en del av koden som faktiskt tillhör användargränssnittet. I vårt fall är användargränssnittet ett GUI, och når undantaget en del av GUI-koden kan det vara rimligt att fånga undantaget där och visa ett felmeddelande i en dialogruta.

  • Felsignalen når "toppen" av koden, utan att komma ända till användargränssnittet. Till exempel kanske anropet går i följande steg:

    	  Timer anropar timerhandling, en Runnable
    	  Timerhandling anropar spelbrädets tick()
    	  Spelbrädets tick() upptäcker Game Over,
    	      anropar highscorelistans addScore()
    	  I addScore() anropas saveToDisk()
    	  I saveToDisk() kastas ett undantag
    	

    Inget av dessa steg är strikt sett en del av användargränssnittet -- men ju högre upp man skickar felet innan man tar hand om det, desto större delen av koden är fri från antaganden om vem som ska meddelas om ett fel (en användare som sitter vid datorn).

Att göra 3.21: Felhantering

  1. Se till att de undantag som kan uppstå när man sparar highscores i en fil, eller läser tillbaka dem, inte fångas upp i samma metod. Skicka istället vidare undantagen till anroparen.

    Detta borde göra att programmet inte längre går att kompilera, eftersom anroparen inte tar hand om undantagen. Kontrollera om den ska göra det eller om den också borde skicka vidare undantagen. Fortsätt till du hittar en lämplig plats att fånga undantagen, och se till att de faktiskt fångas där.

  2. När undantagen fångas behöver du hantera dem på något sätt. I detta fall är det rimligast att informera användaren om felet

    Hur ska man informera? Om detta vore ett kommandoradsprogram kunde man informera via en vanlig utskrift, men nu är det ju ett GUI-program och där tittar användaren kanske inte ens i ett terminalfönster. Alltså ska man använda sig av en dialogruta!

    Dialogrutan ska visa ett lämpligt felmeddelande och fråga om man ska försöka igen.

  3. Se till att faktiskt försöka igen, om användaren ber om det (om man t.ex. har ordnat mer utrymme för filen, fixat felaktiga skrivrättigheter i katalogen, eller liknande). Det ska gå att försöka igen godtyckligt antal gånger.

  4. Testa att koden fortfarande fungerar när inga fel uppstår.

  5. Testa att provocera fram fel! Du kan till exempel ändra åtkomsträttigheterna så att katalogen där highscores ska sparas inte blir skrivbar (chmod 000 katalogen). Se till att felhanteringskoden faktiskt fungerar!

För dig som vill ha betyg 5 på kursen

Dessa uppgifter är fortfarande enskilda och ska utföras på egen hand!

[Betyg 5, efter GUI] Tetris 3.22: Paus i spelet

Att göra 3.22: Paus i spelet

Se till att det går att göra en paus i spelet, och att fortsätta efter pausen. Det kan till exempel finnas en grafisk knapp att trycka på, eller en tangent man kan trycka.

Avgör själv hur detta ska lösas: Genom att manipulera timern eller genom att styra vad som händer när timern "tickar". Se till att lösningen blir välstrukturerad, så att inte fel delar av programmet behöver känna till varandra.

[Betyg 5, efter GUI] Tetris 3.23: Gradvis uppsnabbning

Att göra 3.23: Gradvis uppsnabbning

Se till att spelet långsamt går snabbare och snabbare. Tick-takten kan t.ex. öka något för varje minut som går, eventuellt med en gräns där den inte ökar mer. Testa att ökningen känns lagom.

[Betyg 5, efter exceptions] Tetris 3.24: Säker skrivning

Att förstå 3.24: Säker skrivning

När man skriver en highscorelista till en fil kan det hända att något går fel. Till exempel kan lagringsutrymmet vara fullt (kanske på grund av begränsad quota), eller så kan programmet krascha mitt i skrivningen. Om man då sparar filer genom att helt enkelt skriva över de gamla, förlorar man inte bara den nya informationen – man kan dessutom ha förstört den gamla filen utan att kunna få tillbaka den. Det är tråkigt om detta händer med highscores.

En enkel lösning på detta kan vara följande procedur:

  • Skriv det som ska sparas till en temporärfil med ett annat namn
  • Om detta lyckades: Ta bort den gamla filen. Döp sedan om temporärfilen till det önskade namnet. I och med att man lyckades spara hela temporärfilen är det betydligt mer osannolikt att något ska gå fel just när man döper om den.
  • Om det inte lyckades: Signalera fel.

Här kan man så klart också tänka sig mer avancerade metoder för att återhämta sig om man lyckades spara till temporärfilen men något faktiskt gick fel i de senare stegen, men det tittar vi inte på i den här uppgiften.

Att göra 3.24: Säker skrivning

  1. Se till att highscorelistor sparas på säkert sätt, enligt ovan.

  2. Testa att detta verkligen fungerar, t.ex. genom att ändra filrättigheterna för katalogen där filerna sparas så att du (och därmed programmet) inte får skapa nya filer.

Under utveckling
Denna uppgift är under utveckling. Här kommer vi att testa säker skrivning till fil: Om vi bara skriver rakt över den gamla highscorefilen, kan vi ju tappa bort data om vi skulle misslyckas mitt i skrivningen.

[Betyg 5] Tetris 3.25: Powerups med State-mönstret

Att förstå: 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(); eller board.setPowerupState(new FasterState());

    Fördelen är inte bara att koden delas upp i moduler, utan att man kan anropa en metod som setPowerupState med powerups som inte ens fanns när Board skrevs!

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 man inte behöver göra detta, eller koden verkar bli alltför komplicerad av att använda detta mönster, är det antagligen bättre att låta bli!

Att förstå 3.25.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 hanteringen av fall och kollisioner. 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 fallhanterare, och behöver flytta ut den nuvarande koden för kollisionshantering till en egen klass som implementerar detta gränssnitt.

Att göra 3.25.1: Flytta kod för kollisionshantering

  1. Skapa ett gränssnitt som heter FallHandler. 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 signatur för 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. Fallhanteraren måste ju veta vilket spelbräde den ska leta efter kollisioner i!

  4. Skapa en klass som heter DefaultFallHandler som implementerar FallHandler. 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 fall- och kollisionshanterare, inte ett Board. Därför behöver koden skrivas om en del så att den nya hasCollision() får information från 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 DefaultFallHandler 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 FallHandler. Sätt detta fält till ett objekt av typ DefaultFallHandler. Se till att Board anropar hasCollision() i detta objekt, 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.

3.25.2: En första powerup

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

Din första powerup ska låta en Poly falla rakt ner, genom existerande kvadrater, ända till den når botten. Där kan den "täppa till" hål som har uppstått nära botten.

  1. Skapa klassen Fallthrough som implementerar FallHandler.

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

  3. Modifiera koden så att:

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

    • Om den fallande brickan överlappar någon annan kvadrat, ignoreras detta.

    När brickan har nått botten ska dess kvadrater ersätta det som fanns på den tidigare positionen. Detta är ungefär vad som har hänt tidigare när en bricka faller. Skillnaden är att det som ersätts inte bara är EMPTY utan även kan vara andra 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 fallhanteraren till en new Fallthrough(). När nästa Poly har fallit ner ska Board sätta tillbaka fallhanteraren till en new DefaultFallHandler().

    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 FallHandler, så att varje FallHandler (inklusive default) kan ge en egen beskrivning av sig själv.

  5. Testa!

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

3.25.3: En andra powerup

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

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. Vi vill att en Poly ska kunna trycka ner kvadrater när den flyttas nedåt, men inte när den flyttas i sidled.

    Just nu vet inte kollisionshanteraren hur falling har flyttats, bara vilken position falling har just nu. Som förberedelse behöver detta åtgärdas. Till exempel kan man ändra hasCollision() så att den får blockets gamla position som parameter. Här kan man med fördel använda refactoring i IDEA.

  2. Skapa en ny sorts fallhanterare: Heavy. Se till att den "trycker ner" kvadrater enligt följande:

    • Om den fallande brickan inte har fallit rakt nedåt, används de vanliga kollisionsreglerna. Se till att den koden inte behöver upprepas utan att alla som vill ha "vanlig kollisionshantering" kan anropa samma kod!

    • Annars har vi alltså ett fall rakt nedåt jämfört med förra positionen. Om den fallande brickan då överlappar OUTSIDE, detekteras en kollision.

    • Om den fallande brickan överlappar någon annan sorts kvadrat, vill vi se om detta kan "fixas" genom att samtliga överlappande kvadrater "trycks ner".

      Man behöver alltså först testa om det för samtliga överlappande kvadrater finns tomma hål längre ner i samma kolumn.

      Om detta är falskt, detekterar vi en kollision så att den fallande brickan fastnar (översta bilden nedan).

      Om det istället är sant, ska samtliga överlappande gamla kvadrater tryckas ner ett steg (mittersta bilden nedan) och ingen kollision rapporteras. Detta kan i sin tur trycka ner andra kvadrater ett steg, men bara fram till nästa hål i denna kolumn (mittersta bilden visar hur två kvadrater trycks ner ett steg på samma gång).

      Sedan går spelet automatiskt vidare och försöker flytta brickan ytterligare ett steg nedåt, vilket kan trycka ner kvadrater ytterligare steg om det finns flera hål (tredje bilden nedan).

      OBS: Tänk på vad vi har sagt om vad som behöver göras i vilken ordning. Man behöver t.ex. verkligen vara säker på att samtliga överlappande kvadrater kan tryckas ner innan man börjar med att flytta vissa kvadrater. Annars kan man trycka ner en del kvadrater och sedan upptäcka att en annan del av den fallande brickan faktiskt har stöd.

  3. Denna hanterare kommer också att behöva kollapsa rader, om de kvadrater som "lossnar" ramlar ner och fyller rader. I exemplet nedan bildar de gröna brickorna en sådan rad.

    Detta kan göras genom att man inför en särskild metod i Board för att kollapsa en given rad, och anropar detta från fallhanteraren.

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

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

Avslutning

När du är klar med alla uppgifter du tänker genomföra demonstrerar du hela slutresultatet för din handledare. Sedan lämnas hela koden för labbserien in genom följande procedur.

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 så att vi snabbt kan öppna det och navigera genom koden. Har du inte använt IDEA: Skapa ett projekt enligt tidigare instruktioner.

  3. Se till att du inte har missat någon av varningarna från IDEAs kodinspektioner!

    Dessa visas normalt upp direkt i kodeditorn i IDEA, men det kan hända att man missar något i alla de filer man arbetar med. Gör därför så här för att se till att du inte har missat något.

    1. Välj Analyze | Inspect Code i menyn.
    2. Välj inspektionsprofil "TDDD78-2019-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. Det är också ett sätt att minimera risken för kompletteringar.

    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, sök efter varningen i de specifika tipsen eller 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!". I projektdelen, efter labbarna, måste sådana kommenteras på plats i koden med en god motivering till varför varningen var "ogiltig" i detta fall. Vissa varningar kanske du vill kommentera redan nu.

  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 IDEAs projektfiler (utom .IWS) 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. Lämna in via vårt centrala gitlab-repo! Annars vet handledaren inte att du är klar. En issue ska skapas innan deadline.

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


Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2019-03-12