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.
-
Tidigare klasser låg i paketet
se.liu.ida.dinadress.tddd78.lab2
. Högerklicka på paketettddd78
, välj New | Package och skapalab3
. Detta ska hamna på samma nivå somlab1
ochlab2
i klassträdet. -
Skapa klassen
Circle
i paketetlab3
. -
Lägg till de publika (
public
!)int
-fältenx
,y
ochradius
. -
Lägg till ett publikt fält
color
av typenjava.awt.Color
.Abstract Window Toolkit (AWT) är Javas ursprungliga grafiksystem. Anledningen till att vi skriver hela
java.awt.Color
är att det finns mångaColor
-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 ijava.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
). -
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.
-
Skapa klassen
TestCircle
och enmain()
-metod i den. -
I
main()
ska vi skapa en lista avCircle
. Skrivnew ArrayList<Circle>()
, låt IDEA importeraArrayList
, 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 iif
-satsen med variabeln. -
Skapa ett par cirklar och lägg dem i listan (med listmetoden
add()
). -
Använd live-template iter (iterate collection) för att skapa en
for
-loop som går igenom varjeCircle
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. -
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
-
Hoppa från
TestCircle
tillCircle
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 iCircle
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
). -
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
-
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
-
Skapa gränssnittet
Shape
i paketetlab3
. 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. -
Nu ska vi se till att
Circle
implementerarShape
. Påclass
-raden lägger vi därför tillimplements 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
-
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" ({...}
). -
Vi ser att
draw()
blir gul. Det finns nämligen klasser som säger sig implementeraShape
men som saknar denna metod! Motsvarande varning kan ses när man går tillCircle
: 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örShape
. Tryck Alt+Insert och välj Implement Methods och sedandraw()
. 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.) -
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+InserttoString()
) 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
-
Alternativ 1 av 2: Lägg manuellt till metoddeklarationer för
getX()
,getY()
ochgetColor()
iShape
. Se till att de har samma returvärden och parametrar som iCircle
, men saknar metodkropp. Vi kan ju inte implementera en fullständiggetX()
här uppe iShape
, eftersom gränssnittet själv inte har någotx
att returnera! -
Alternativ 2: Gå till
Circle
. Använd Refactor | Pull Members Up. Se till attShape
är vald i den översta väljaren. MarkeragetX()
,getY()
ochgetColor()
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 tillShape
, om man till exempel ansåg att implementationen skulle vara samma för alla underklasser tillShape
. Men nu ärShape
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 iShape
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 iCircle
.
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.
-
Gå till
TestCircle
. Klona klassen med hjälp av F5. Kalla den nya klassenTestShapes
. På det sättet får ni med strukturen i det gamla testprogrammet. -
I
TestShapes.main()
ska vi inte skapa en lista avCircle
, utan en lista avShape
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.
-
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()
. -
Kör
TestShapes
och verifiera att du får ut rätt utskrifter. Även det gamla testprogrammetTestCircle
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
.
-
Skapa klassen
Rectangle
med de privata fältenx
,y
,width
,height
ochcolor
. Typerna vet ni sedan tidigare.Ge klassen en lämplig konstruktor.
Skapa en
toString()
-metod. -
Implementera gränssnittet
Shape
och de metoder som krävs i detta gränssnitt. -
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
.
-
Skapa klassen
Text
med de privata fältenx
,y
,size
(storleken i punkter),color
ochtext
(enString
) samt en lämplig konstruktor ochtoString()
-metod. -
Implementera gränssnittet
Shape
och de metoder som ingår i gränssnittet. -
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.
Att göra 3.5.1: AbstractShape
-
Skapa en ny "tom" klass
public abstract class AbstractShape
. Se till att den implementerarShape
– alla subklasser tillAbstractShape
ska ju varaShape
s. -
Byt ut
implements Shape
motextends AbstractShape
i klassernaCircle
,Rectangle
ochText
. Detta innebär att klasserna blirAbstractShape
, men de kommer fortfarande också att varaShape
s eftersom detta gränssnitt implementeras avAbstractShape
. -
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 tillAbstractShape
, inteShape
. Markera fältenx,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
ellergetRadius()
, eftersom bara cirklar har radier. Inte hellerdraw()
ellertoString()
: 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.
-
De valda fälten blir
protected
iAbstractShape
istället förprivate
, så att subklasser fortfarande kan komma åt dem. -
IDEA skapar också automatiskt en konstruktor för
AbstractShape
. -
Eftersom fälten nu är flyttade är det upp till
AbstractShape
att hantera initialisering av dem. Vissa tilldelningar i konstruktorn förCircle
ersätts därför med anropetsuper(x,y,color)
, som vidarebefordrar parametrarna frånCircle
upp tillAbstractShape
:s konstruktor. (Ett anrop tillsuper()
ligger alltid först i en konstruktor.)
-
-
Tyvärr vet inte IDEA att den ska göra motsvarande ändringar i de andra klasserna.
Navigera till
Rectangle
. Ta bort fältenx/y/color
, ta bort motsvarande getters, och ta bort initialiseringen ur konstruktorn.Lägg sedan till
super()
-anrop motsvarande det iCircle
-konstruktorn. Ta bort de tre getter-implementationer som nu blev onödiga eftersom samma kod redan ärvs ner frånAbstractShape
.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ånAbstractShape
I
OtherShape
kan vi välja att implementera helaShape
själva, kanske för att klassen redan ärver frånOtherBase
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 kö 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
-
Skapa klassen
Queue
. -
Skapa ett privat fält
List<Person> elements
och initialisera detta till en nyArrayList<Person>
. Eftersom varjeQueue
har sina egna element får detta fält inte vara statiskt. -
Det finns metoder i
ArrayList
som vi vill göra tillgängliga för användaren avQueue
.size()
är en sådan metod. Eftersomelements
håller koll på antalet element den innehåller, skriver vi helt enkelt bara ensize()
-metod iQueue
som returnerar resultatet avelements.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 metodernaisEmpty()
,clear()
ochcontains()
på precis samma sätt, genom att ha en metod som direkt anropar och returnerar svaret från motsvarande metod ielements
.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. -
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()
ochelements.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 specifiktQueue
-objekt), inte i kö-klassen i sin helhet. Metoderna du skriver här ska alltså inte vara statiska. -
Nu är
Queue
färdig och det är dags att skapaStack
. 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 namnetStack
så skapas en ny klass som ser precis ut somQueue
. Byt namn påenqueue/dequeue
tillpush/pop
och ändra koden i dem så attStack
beter sig som en stack. -
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 enwhile
-loop som itererar så länge stacken inte är tom.Gör även samma sak med en kö.
-
När vi nu tar en sista titt på vad vi har gjort upptäcker vi ett par saker:
Stack
ochQueue
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.
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.
hashCode()
i Java motsvaras av att
implementera __hash__()
i Python.
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 hink78
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 miljarderint
.
Att göra 3.7.1: equals()
och hashCode()
-
Skapa
equals()
-metoder i AbstractShape. Använd IDEA som hjälp via Code | Generate | equals() and hashCode(): Alt+Insert. Vi är egentligen bara intresserade avequals()
, 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 ihashCode()
.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 varanull
. Detta är för att inte generera onödiganull
-jämförelser som kan ta någon extra nanosekund. Om du är osäker så ange att fälten kan varanull
(detta kan vara något mindre effektivt men ger alltid rätt resultat). -
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.
java.util.Timer
! Det kan se ut att fungera en
del av tiden, men händelser kommer att inträffa i fel tråd vilket kan leda
till mystiska fel som enbart uppstår ibland.
Här ser vi en anledning till att man lägger klasser i olika paket. Om du i
senare steg inte får rätt Timer
, Action
eller
ActionEvent
så se till att följande imports finns under package
i början på filen:
import javax.swing.*;
import java.awt.event.ActionEvent;
I den här uppgiften ska vi bara göra en enkel utforskning av
Timer
. Senare, när vi har gått vidare till en
fullständig grafisk komponent, kommer vi att titta närmare på
lämplig modellering av spelet som helhet och var spelloopen
egentligen ska placeras.
Det vi behöver veta kan vi då lära oss av följande exempel och beskrivningen under.
final Action doOneStep = new AbstractAction() {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Timer clockTimer = new Timer(500, doOneStep);
clockTimer.setCoalesce(true);
clockTimer.start();
...
clockTimer.stop();
En Timer
ser alltså till att Swings egna
händelsehanteringstråd anropar en viss handling ("action") med
jämna mellanrum. I detta fall är det 500 millisekunder mellan
anropsstarterna, och det som anropas är den handling som vi
kallade doOneStep
. Eller rättare
sagt, Timer
vet att den får någon
sorts Action
som parameter, och att
alla Action
-objekt har
metoden actionPerformed
som den kan anropa.
Genom att anropa
setCoalesce(true)
ser vi till att anropen inte "köas
upp" om ett visst anrop till doOneStep()
skulle ta för
lång tid. Vi kan starta timern när spelet börjar och stoppa den när
spelet är över.
I övrigt kan även clockTimer.setLogTimers() vara intressant för felsökning. Se även Javadoc-dokumentationen som vi länkar till ovan!
Notera att anonyma klasser, som vi använde ovan, bara bör
användas för små klasser. Om implementationen
av actionPerformed()
blir lång kan man vilja ge
klassen ett namn istället:
class StepMaker extends AbstractAction {
public void actionPerformed(ActionEvent e) {
// Gå ett steg i spelet!
}
};
final Action doOneStep = new StepMaker();
Att göra 3.8.1: Timers
-
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 utBoard
-objektet utan ändra i det!) på motsvarande sätt som du gjort tidigare, och visa resultatet i textarean som du skapade i en tidigare uppgift. Handlingen behöver alltså på något sätt få tillgång till både spelplan och textarea. Slutresultatet ska helt enkelt bli att du ser en enkel "animering" på skärmen medan programmet körs. Du behöver inte kunna stoppa timern. -
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
-
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! } }
-
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
-
Just nu har varje
Shape
-klass endraw()
-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 ettGraphics
-objekt. Det betyder i sin tur att metoden måste skrivas om för att få ettGraphics
-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 typeGraphics
, nameg
. 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 tilldraw()
om det finns enGraphics
-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.
-
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 anropardraw()
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. -
Ä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);
-
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
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.Skapa en
TetrisComponent
-klass som är subklass tillJComponent
.Låt
TetrisComponent
ha en pekare till detBoard
som den visar. Den behöver alltså ett fält som pekar på ettBoard
, och den behöver en konstruktor som tar ettBoard
som parameter.Implementera metoden
getPreferredSize()
så att den returnerar den storlek du helst vill ha för den grafiska visaren. Enheten är pixlar. Om du vill anpassa komponentstorleken till skärmstorleken har vi gått genom på föreläsningarna hur man får fram skärmupplösningen. Tänk bara på attTetrisComponent
-komponenten inte kan vara riktigt så stor, eftersom menyer, knappar, ramar och annat också kan ta plats.
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 enSquareType
som nyckel och enColor
som värde. I Java finns dock ingen specialsyntax för mappningar, utan det är en klass som alla andra, och objekten manipuleras med metoder somput()
ochget()
.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 ipaintComponent()
.
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
Implementera
paintComponent()
enligt beskrivningen ovan.Ändra i det tidigare GUIt så att
TetrisComponent
nu används istället förJTextArea
plusBoardToTextConverter
.Ändra i testklassen, eller skriv en ny testklass, så att den
Timer
som introducerades tidigare ber dinTetrisComponent
att rita om sig istället för att manipulera den textarea vi brukade använda. Slutmålet är att brädet ska slumpas om med regelbundna mellanrum och att varje ny konfiguration ska visas på skärmen iTetrisComponent
-komponenten som finns i enJFrame
. För tillfället kan du be dinTetrisComponent
att rita om sig indirekt genom att timerhandlingen direkt anroparrepaint()
iTetrisComponent
-objektet (som dinTetrisViewer
har en pekare till). I en kommande uppgift ska vi använda ett mer principiellt sätt att fåTetrisComponent
att uppdatera sig vid ändringar.
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
.
Ä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 ActionListener
s är en sorts
observatörer som är intresserade av att veta när någon t.ex. trycker på en
knapp. Metoder som addActionListener()
lägger till en
lyssnare som är intresserad av en specifik typ av handling. Klasser
som JButton
känner inte till era specifika lyssnare i förväg,
men de har ett sätt för lyssnarna att registrera sitt intresse. Om man
vill kan man registrera ett godtyckligt antal lyssnare på samma knapp.
I Tetris innebär detta att vi ser spelplanen som något "observerbart".
Varje gång planen förändras på något sätt (t.ex. genom att ett block
faller ner ett steg) kan den själv informera en eller flera "observatörer"
som kan implementera ett passande lyssnargränssnitt. Varje observatör kan
då själv registrera sig som intresserad av vad som händer med ett visst
Board
.
Fördelen med detta tankesätt är bland annat att timerhandlingen kan
fokusera helt på att driva spelet framåt ett steg. När spelet drivs framåt
ett steg är det sedan spelplanen som talar om för alla intresserade att
något har hänt. En av dessa intresserade råkar
vara TetrisComponent
, som tidigare har registrerat sitt
intresse.
Att göra 3.11.1: Lyssnare och notifiering
Skapa ett interface
BoardListener
med en metodpublic void boardChanged();
Lägg till i klassen
Board
ett privat fält som innehåller en lista av BoardListeners. Lägg också till en metodpublic void addBoardListener(BoardListener bl)
som adderar den givna lyssnaren till listan. I detta spel kommer vi inte att behöva något sätt att ta bort lyssnare, även om man så klart kan lägga till även en sådan metod för att vara mer fullständig.Skapa i
Board
en privat metodprivate void notifyListeners()
som loopar över alla element i lyssnarlistan och anropar derasboardChanged()
-metoder.Se till att alla publika metoder i
Board
som ändrar på spelplanen anroparnotifyListeners()
på slutet. Detta inkluderar metoder som t.ex. ändrar koordinater på det block som håller på att ramla ner. Du måste också se till att göra på samma sätt i nya metoder du lägger till senare.Ändra
TetrisComponent
så att den implementerar gränssnittetBoardListener
. Implementera metodenboardChanged()
så att den anroparrepaint()
.Se till att timerhandlingen som driver animeringen framåt inte längre anropar
repaint()
själv, som i tidigare lösning. Ändra istället på era testklasser så attTetrisComponent
-objektet du skapar adderas som en lyssnare på spelplanen.
Det går också bra att låta boardChanged
ta parametrar som
talar om vad som har ändrats, och/eller att skapa flera olika metoder i
BoardListener som anropas vid olika händelser: Ett block har flyttats, ett
block har "landat", spelet är slut, ...
Resultat
Slutresultatet av denna ändring ska bli att allt på skärmen ser ut precis som tidigare – men att koden har en bättre struktur. Den är mer modulär i och med att vi har minskat den starka kopplingen mellan datalagring och utritning på skärmen. Detta är en mycket viktig ändring.
Tetris 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
Ändra spelet så att det utgår från en tom spelplan istället för en framslumpad spelplan.
-
Implementera
Board.tick()
, som behöver titta om spelplanen just nu innehåller ett block (Poly
) som håller på att ramla ner. I så fall flyttar den det blocket ett steg nedåt. Annars slumpar den fram en blocktyp (T
,L
, ...) och placerar ett nytt block av den typen överst på spelplanen, centrerat. (Mer spelmekanik tillkommer senare.) 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
Som diskuterats ovan vill vi att
Board
ska innehålla "logiken" för alla de "drag" man kan göra. Implementera alltså metoder iBoard
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 intresseradeBoardListener
s om att något har ändrats. Däribland finns den grafiska komponenten som därmed ritas om varje gång ett block har flyttats i sidled.-
Använd key bindings (se ovan) för att anropa metoderna för sidledsförflyttning när man trycker pil vänster respektive pil höger. Ni kan även anropa dem från andra tangenter om ni vill, men pilarna måste fungera.
-
Testa. Prova även att skicka blocket utanför spelplanens kanter. Om detta leder till en krasch är det än så länge helt OK.
Den som är bekant med trådad programmering undrar kanske om det nu
kan hända att Board
anropas från flera trådar
samtidigt, dels via tangentbordsbindningen och dels via
timerhandlingen. Nej, faktiskt inte. Tangentbordsbindningar
behandlas i Swings händelsehanteringstråd, så det är den tråden
som flyttar block i sidled. Den timer vi valde att använda
tidigare är javax.swing.Timer
, och den anropar också
sin handling (som i sin tur anropar Board.tick()
)
från Swings händelsehanteringstråd. Därför bör vi inte få några
trådningsproblem här.
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
-
Lägg till
OUTSIDE
som ett nytt enum-värde iSquareType
. -
Se till att konstruktorn lägger till en ram runt spelplanen enligt bilden ovan. Det ska vara två "OUTSIDE" på varje sida, eftersom detta underlättar när vi senare implementerar rotation av block. Arrayens storlek ska alltså vara 4 större än den
width
ochheight
som anges. Tänk på att samma gamla värde ska fortfarande lagras iwidth
ochheight
, eftersom dessa fält representerar spelplanens "egentliga" storlek. -
Eftersom andra klasser inte ska vara medvetna om ramen måste gettern för
squares
ändras så att den indexerar "förbi" ramen. När någon ber om blocket på logisk position (0,0) ska gettern alltså returnera blocket på position (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örOUTSIDE
för att undvika varningar. -
Skriv en metod
hasCollision()
som returnerartrue
om det fallande blockets nuvarande position resulterar i att en icke-tom ruta i blocket överlappar en icke-tom ruta på spelplanen. BaraSquareType.EMPTY
räknas som tomt. Vi kan nu använda
hasCollision()
för att förhindra kollision i sidled. Metoden som förflyttar blocket när spelaren trycker pil vänster eller höger måste testa om detta resulterar i en kollision. I så fall måste metoden flytta tillbaka blocket eftersom förflyttningen var omöjlig. Implementera detta.Lägg till en kontroll i
tick
så att om blocket efter förflyttning nedåt kolliderar skall det flyttas tillbaka upp och stoppas in i brädet. Fältetfalling
skall i detta läge sättas tillnull
.Vi kan nu implementera "game over" genom att direkt göra en kollisionskontroll då ett nytt block slumpats fram. Om det nya blocket omedelbart kolliderade med något, var det omöjligt att lägga till ett nytt block på spelplanen. Implementera detta test samt en flagga som håller koll på om det är "game over" och om så är fallet ser till att
tick
inte gör något.
Resultat
Resultatet i denna uppgift är ett spelbart Tetris som dock saknar rotation av blocket vilket blir nästa steg.
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 Poly
n. Därefter kopierar den SquareType
s
till den nya Poly
n
enligt ovanstående mönster.
Metoden roterar enbart åt höger. Tänk på att two wrongs don't make a right – but three lefts do, och tvärtom. Med andra ord, man kan rotera åt vänster genom att rotera åt höger tre gånger. Alternativt kan man så klart skapa en motsvarande rotateLeft() som roterar åt vänster i ett enda steg.
public Poly rotateRight() {
Poly newPoly = new Poly(new SquareType[size][size]);
for (int r = 0; r < size; r++) {
for (int c = 0; c < size; c++){
newPoly.squares[c][size-1-r] = this.squares[r][c];
}
}
return newPoly;
}
Notera: Om vi valt att representera tetrominos med precis så många rutor som behövs för
att en Poly
ska få plats, kunde vi fått oväntade resultat. Nedan skulle
t.ex. "balken" inte rotera runt sin mittpunkt utan runt sin övre
vänstra kvadrat:
Därför valde vi istället en kvadratisk array av storlek 4x4, som i rotationsstandarden som presenterades tidigare, roterar allt runt den arrayens centrumpunkt:
Att göra 3.15.1: Rotation
-
Lägg till en
rotate()
-metod iBoard
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. -
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
-
En av grundtankarna bakom Tetris är att en rad kvadrater "försvinner" om den blir helt full. Implementera detta, så att du kanske kan spela lite längre innan spelet tar slut!
När din nya
Board
-metod tar bort alla fulla rader och flyttar ner övriga rader: Kom ihåg att inte flytta medOUTSIDE
-värdena!Blir det svårt att fylla en rad? Testa att tillfälligt tvinga spelet att bara skapa kvadrater ("O"), så kan du testa borttagningen enklare. Då testar du även att spelet kan ta bort två rader samtidigt.
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.
[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
-
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.
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 viaSystem.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
Skapa mappen pics på samma nivå som src och libs i din projektkatalog.
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.
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...
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
ochClassLoader.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.
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.
Bestäm en lämplig plats där nuvarande poäng kan lagras.
Se till att poängen uppdateras enligt ovan.
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
Singletonkunde 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, unikaHigscoreList
-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
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 dessdispose()
-metod). Se till att detta fungerar.Skapa en
Highscore
-klass som lagrar antal poäng plus namn på den som fick poängen.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.-
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 iBoard
, utan den kan t.ex. vara den som skaparBoard
som också skapar listan. Annars skulle ju highscores "nollställas" varje gång man skapar ett nyttBoard
som har en egen nyHighscoreList
! 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.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
Skapa en jämförarklass, en
ScoreComparator
, enligt ovan.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).
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.
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.
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:
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?
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å.
Testa. Spela ett spel, skriv in en highscore, och titta manuellt på filen så att den faktiskt sparas och ser ut som den ska.
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 godtyckligReader
, t.ex. enFileReader
, som den själv kan läsa in strängen från (det behöver inte vara enJsonReader
).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 nyHighscoreList
, så det skulle leda till en oändlig loop.)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.
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.
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
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.
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.
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.
Testa att koden fortfarande fungerar när inga fel uppstår.
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
Se till att highscorelistor sparas på säkert sätt, enligt ovan.
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.
[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();
ellerboard.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ärBoard
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
-
Skapa ett gränssnitt som heter
FallHandler
. Detta ska ta över en del av funktionaliteten som just nu ligger iBoard
och bör därför ligga nära den klassen. -
Lägg till en signatur för en
hasCollision()
-metod motsvarande den du nu har iBoard
(men utan själva implementationen, eftersom detta är ett gränssnitt). -
Låt din nya
hasCollision()
-metod få en ny parameter, av typBoard
. Fallhanteraren måste ju veta vilket spelbräde den ska leta efter kollisioner i! -
Skapa en klass som heter
DefaultFallHandler
som implementerar FallHandler. Flytta implementationen avhasCollision()
frånBoard
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
, menthis
är ju numera en fall- och kollisionshanterare, inte ettBoard
. Därför behöver koden skrivas om en del så att den nyahasCollision()
får information från detBoard
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 somDefaultFallHandler
ska ha tillgång till den representationen. Då får man istället skriva om den nyahasCollision()
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åsomgetWidth()
,getHeight()
ochgetSquareAt()
! -
Ge
Board
ett privat fält av typFallHandler
. Sätt detta fält till ett objekt av typDefaultFallHandler
. Se till att Board anroparhasCollision()
i detta objekt, istället för att försöka anropahasCollision()
i "sig själv". -
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.
Skapa klassen
Fallthrough
som implementerarFallHandler
.Kopiera
hasCollision()
-koden frånDefaultFallHandler
.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.
-
Hitta på ett sätt att trigga denna Powerup. Till exempel kan man få en
Fallthrough
var tiondePoly
, 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 ennew Fallthrough()
. När nästaPoly
har fallit ner skaBoard
sätta tillbaka fallhanteraren till ennew 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()
iFallHandler
, så att varjeFallHandler
(inklusive default) kan ge en egen beskrivning av sig själv. -
Testa!
-
Fick
DefaultFallHandler
ochFallthrough
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.
-
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 positionfalling
har just nu. Som förberedelse behöver detta åtgärdas. Till exempel kan man ändrahasCollision()
så att den får blockets gamla position som parameter. Här kan man med fördel använda refactoring i IDEA. -
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.
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.-
Se till att
Heavy
kan triggas på något sätt. -
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
-
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).
-
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.
-
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.
- Välj Analyze | Inspect Code i
menyn.
-
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.
-
Ä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.
-
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.
-
Skapa en tagg (etikett) i Gitlab. Detta visar oss vilken
version av koden som lämnades in vid inlämningsdatumet.
Logga in på gitlab.
Gå till ditt projekt och välj "tags".
Välj "New tag".
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.
Låt "Create from" vara kvar på "master".
Meddelanden och "release notes" behövs inte.
"Create Tag"!
-
Lämna in via vårt centrala
gitlab-repo! Annars vet handledaren inte att du är klar. En
issue ska skapas innan deadline.
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
-
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).
-
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.
-
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.
- Välj Analyze | Inspect Code i menyn.
-
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.
-
Ä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.
-
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.
-
Skapa en tagg (etikett) i Gitlab. Detta visar oss vilken version av koden som lämnades in vid inlämningsdatumet.
Logga in på gitlab.
Gå till ditt projekt och välj "tags".
Välj "New tag".
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.
Låt "Create from" vara kvar på "master".
Meddelanden och "release notes" behövs inte.
"Create Tag"!
-
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