Labb 2: Objektorientering i Java / Tetris
Introduktion
I denna labb ser vi bland annat hur man skapar och använder objekt och klasser.
Vi kommer att hjälpa till med flera handgrepp i programmeringen, men det är som tidigare viktigt att ni inte genomför dem "mekaniskt" utan reflekterar över varför ni gör som ni gör.
Bra att tänka på
-
Arbeta på egen tid! Man ska behöva betydligt mer tid än det som schemalagts. Hela kursen är ju på 6 hp = 160 timmar.
-
Använd gärna referenskortet för IDEA när du programmerar. Många funktioner kan vara mycket användbara här och i det stora projektet.
-
Läs genom varje deluppgift (t.ex. hela uppgift 2.1) innan du utför den.
-
En viss mängd dokumentation av era lösningar krävs. Kommentera!
Att labba med Tetris
Utöver att titta på enskilda delar av Java och OO vill vi också ge dig möjlighet att skriva ett lite större fullständigt program. Det program vi har valt är ett enklare Tetris-spel. En fördel med Tetris som spel är att det inte är så krävande i fråga om t.ex. animering, kollisionsdetektering och andra knepigheter, så att vi kan fokusera mer på programmering och hur man kan tänka för att skriva ett objektorienterat program.
Hur programmerar man då ett lite större sammanhängande projekt såsom ett Tetris-spel? Ett tips är att bryta ner det i steg eller milstolpar som kan implementeras i tur och ordning – helst på ett sätt som gör att man kan testa varje steg för sig och känna att man faktiskt har åstadkommit något. Instruktionerna för Tetris är uppbyggda på detta sätt, speciellt efter de första inledande stegen.
Ämnen som kommer att ingå i Tetris-delen av labbserien är bland annat följande. Som du ser motiveras alla ämnen både av behov i spelet och av det man behöver lära sig i kursen!
Begrepp i objektorientering, och hur man modellerar enskilda objekt:
Spelbräde – enklare objektorienterad modellering
"Brickor" med form och färg – mer objektorienterad modellering
"Visare" för spelbräde – uppdelning av ansvarsområden
Modellera sammanhang:
Hur vet "visaren" när något har ändrat sig? – hur man kan tänka på dataflöde i ett program
Visualisering och GUI:
Enkelt, med text – att börja enkelt för att kunna testa koden tidigt
Grafiskt – "rita" block att börja enkel GUI-programmering
Menyer, tangentbordshantering, ... – begrepp i händelsehantering (events) med mera
Spelmekanik:
"Driva" spelet med en timer – en introduktion till tidsstyrd programmering
Hantera spelets regler – en hel del "ren programmering"
Highscorelista – mer OO-modellering
Rotation av brickor, ... – implementera algoritmer i Java
Powerups – hur programmerar man modulärt?
För att leda dig åt rätt håll när du omsätter teori till praktik är de första delarna i Tetris i "tutorial"-form, där vi ganska detaljerat diskuterar hur man kan tänka för att överföra idéerna till kod. Mot slutet av labbserien får du gradvis mer och mer frihet – och färre detaljerade ledtrådar.
Uppgift: Prova Tetris
Läs gärna på lite om Tetris. Det kan hjälpa dig i resten av labbserien.
-
Om du inte har spelat Tetris tidigare: Testa någon gratisvariant, t.ex.
M-x tetris RET
i vissa varianter av Emacs. Uppgifterna kommer att förutsätta att du vet hur Tetris fungerar.
Efter föreläsning 2, typer / allmän OO
Tetris 2.1: Börja programmera Tetris
Syfte: Börja så smått med spelplanen!
Om vi ska implementera Tetris behöver vi bland annat ett sätt att representera spelets nuvarande tillstånd, t.ex. spelplanen. Vi börjar med:
Att diskutera hur spelplanen ser ut och hur delar av den kan modelleras.
Att skapa en datatyp för "kvadrater" (detta kommer att förklaras snart).
Vi vill speciellt fokusera på hur man tänker när man modellerar. Därför går vi genom steg för steg hur vi har tänkt för att komma fram till de föreslagna datatyperna.
Uppgift: Skapa projekt och paket
Vi kommer att fortsätta i samma Git-arkiv och samma IDEA-projekt som du använder för alla labbar. Vi vill däremot separera Tetris från övriga klasser genom att lägga dem i olika (under)paket.
-
Skapa ett paket för Tetris, med lämpligt namn -- förslagsvis
se.liu.dinadress.tetris
, som läggs "bredvid"lab1
(högerklicka mappen motsvarande ditt liuid i IDEA, och välj New / Package).Paketnamnet måste innehålla "tetris" någonstans! Namnet kommer att användas av mjukvara som automatiskt separerar Tetris-kod från annan kod vid inlämning.
Bakgrund: Tetris, polyomino, tetromino
Tetris har en spelplan där tetrisblock steg för steg "ramlar ner" uppifrån. Varje block består av exakt fyra sammanhängande kvadrater i en viss färg och ett visst mönster – själva namnet Tetris kommer faktiskt från tetra (4).
Totalt 7 olika blockmönster är möjliga, bortsett från möjligheten att rotera dem. Nedan syns en illustration
från Wikipedia. Standardnamnen på dessa block är I
, J
, L
,
O
, S
, T
och Z
.

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

Om vi då generaliserar från domino ("di-omino", di=2) får vi namnet polyomino (poly = flera), med specialfallen monomino (1), domino (2), tromino (3), tetromino (4), pentomino (5), hexonimo (6), och så vidare.
Varje tetrisblock är alltså en tetromino.
Modellering av kvadrater
Nu ska vi börja tänka genom alternativ för modellering. Läs genom hela avsnittet innan du börjar genomföra detta. Vi påminner om att vi vill diskutera hur vi tänker, inte bara slutresultatet!
Vi vill på något sätt representera den nuvarande spelplanen, det nuvarande läget i spelet. När man har spelat ett tag kan det till exempel se ut så här:

Men hur ska vi alltså representera detta tillstånd i ett program?
Vi kunde tänka oss att lagra en lista av de hela tetrisblock som har ramlat ner samt x- och
y-positioner för dessa block på planen. Men när ett tetrisblock väl har "fallit på plats" kan ju vissa av
dess kvadrater så småningom försvinna medan andra finns kvar (att fulla rader försvinner är ju själva
poängen med spelet). Ovan ser vi t.ex. ett "helt" grönt L
men även
6 partiella L
där vissa av kvadraterna från det ursprungliga
mönstret är borta. Detta vore det rätt krångligt att representera med en lista över tetrisblock eftersom vi
skulle behöva hålla reda på vilka delar av varje tetrisblock som fortfarande finns kvar.
Istället kan vi modellera på följande sätt: För varje position (x,y) på spelplanen, är den
tom? Och om inte, vilken typ av tetromino tillhör kvadraten på den positionen? Varje gång
ett block faller på plats läggs det då in information om fyra nya kvadrater på lämpliga (x,y)
-koordinater.
Vi behöver inte hålla reda på vilket tetrisblock varje enskild kvadrat kom från. Vi behöver bara något som
indikerar vilken typ av kvadrat vi har på en viss position: Kommer kvadraten ursprungligen från ett
L
, ett J
, ...? Det behöver vi så att vi senare vet vilken färg som ska ritas ut.
Hur representerar vi då en "typ av kvadrat"? Vi vill kunna representera exakt 8 olika värden: "här
finns ingen kvadrat", "här finns en kvadrat från ett I
-block", "här finns en
kvadrat från ett T
-block", och så vidare.
Java stödjer enum-typer, som är gjorda just för situationen när en datatyp har ett fast antal värden. "Enum" kommer från "enumeration", dvs. "uppräkning". Vi vill räkna upp de 8 värden som finns, och sedan ska man inte kunna skapa nya.
Enum-typer är egentligen klasser, och typens värden är objekt, men det behöver vi egentligen inte tänka så mycket på just nu.
Vi ska därför skapa en enum-typ med namn SquareType
.
Uppgift: Kvadrater
Skapa en enum-typ genom att högerklicka på Tetris-paketet i IDEA, välja New | Java Class och sätta "Kind" till Enum. Ge klassen namnet
SquareType
.Lägg in följande inom klamrarna:
EMPTY, I, O, T, S, Z, J, L
Klassen ser alltså ut så här:
public enum SquareType { EMPTY, I, O, T, S, Z, J, L }
Detta betyder helt enkelt att typen
SquareType
har värdenaSquareType.EMPTY
,SquareType.I
,SquareType.O
, och så vidare. Eventuellt klagar IDEA på att namnen är för korta. Detta kan du ignorera.Här finns mer information om enum-typer om du skulle vara nyfiken på mera detaljer.
Tetris 2.2: Skapa objekt med new
+
testning
Syfte: Komma igång med objekt; testa testning!
Nu ska vi komma igång och skapa våra första objekt.
Detta är inte helt sant. Vi har skapat objekt förut, men det har då varit "underförstått" i andra
operationer vi har utfört. De sju konstanterna i SquareType
(L
, I
,
...) blev till exempel till objekt. Men nu ska vi för första gången uttryckligen välja att skapa
nya objekt, med operatorn new
.
För att komma dit så snabbt som möjligt skapar vi objekt av en redan existerande klass från Javas klassbibliotek. Då kan vi även prova på att:
Skapa en
main
-metod som används specifikt för testning av en klass / typ såsomSquareType
.Använda Javas "inbyggda" dokumentation, Javadoc.
Använda typbaserad autokomplettering, som drar nytta av att veta variablernas typer.
För att göra detta behöver vi inte ha lärt oss att skapa egna generella klasser, och det är delvis därför vi börjar med just den här övningen – så att vi kan komma lite längre redan innan den tredje föreläsningen (objektorientering i Java).
Om testning
Ofta är det bra att ha ett sätt att testa en enskild klass (datatyp) för att se att implementationen
fungerar som den ska. Då kan man använda testramverk som TestNG och JUnit, men så långt går vi inte i den här kursen. Istället provar vi på att
skapa en egen main
-metod inuti själva klassen. Då kan man helt enkelt "köra klassen" för att
testa den. Vill man istället starta huvudprogrammet (spela Tetris!) gör man detta med hjälp av en huvudklass
vars main
-metod inte är till för testning utan för programstart.
SquareType
har inte så mycket egen funktionalitet, så vi ska helt enkelt testa att slumpa fram
några värden, skriva ut dem, och verifiera att utskriften ser ut som den borde.
Om slumptal
Vi behöver alltså kunna skapa slumptal med en (pseudo-)slumptalsgenerator.
En sådan initialiseras normalt med ett "frö" och ger sedan en sekvens av slumptal. Samma frö ger alltid samma sekvens av slumptal (som ska vara svår att förutse och därmed efterlikna "äkta slump"), medan olika frön förhoppningsvis ger olika sekvenser.
Slumptalsgeneratorn måste alltså hålla reda på (bland annat) sitt frö – den har ett eget tillstånd (state, intern information att hålla reda på). Den har också ett beteende, en funktionalitet: Den kan lämna ut nästa slumptal. Med både tillstånd och beteende passar det att modellera den som ett objekt.
Så är det också i Java: Slumptalsgeneratorer är objekt av klassen Random
(eller SecureRandom
,
om man vill ha kryptografiskt
säkra pseudoslumptal).
Uppgift: Slumptal
-
Skapa en
main()
-metod iSquareType
. Låt metoden skapa en slumptalsgenerator:Random rnd = new Random();
Som alla klasser i Java ligger
Random
i ett paket. Olika paket kan ha klasser med samma namn. När du skriver inRandom
vill IDEA därför veta vilken Random du menar. Den ger då en popup som föreslår import avjava.util.Random
, den endaRandom
som hittas just nu. Tryck Alt+Enter för att acceptera detta. Detta resulterar i enimport
-sats, som skiljer sig en del frånimport
i Python (mer om detta på en föreläsning).Koden deklarerar att vi vill ha ett objekt av typen
Random
som vi kommer att referera till med namnetrnd
. Vi skickar inte med några initialiseringsparametrar till konstruktorn utan är nöjda med defaultbeteendet hosRandom
.
Javadoc -- hur får man fram dokumentation?
Javadoc är Javas standardiserade dokumentationsformat. Det byggs av speciella kommentarer som skrivs omedelbart före en klass, metod eller fält. Javadoc-verktyget gör om detta till indexerade HTML-filer. Dokumentationen för Javas standardklasser finns så klart färdigindexerad på nätet.
Uppgift: Visa Javadoc i IDEA
Prova att placera markören i t.ex. Random()
och trycka Ctrl-Q. Detta visar javadoc i
en liten popup-ruta så att du snabbt får fram information utan att behöva lämna utvecklingsmiljön och
navigera genom HTML-filerna.
Så småningom ska du skriva Javadoc-kommentarer även för din egen kod. Även där fungerar Ctrl-Q för att snabbt få fram information.
Exempel från fältet "out" i System.out
:

Det går också att få informationen in en popup eller i ett separat fönster som kan flyttas till en annan desktop, precis som för alla Tool Windows i IDEA. See info om hur man arrangerar fönstren och ändrar visningsläge om du är intresserad.
VSCode: Se Stack Overflow (otestat!).
Uppgift: Testa autokomplettering
IDEAs autokomplettering visar vilka valmöjligheter som finns för fortsatt kodskrivning och kan vara mycket användbar för att upptäcka vad man kan göra med ett objekt.
Testa att skriva "rnd.
" på raden under new Random();
. Beroende på dina
inställningar kanske rutan med möjliga fortsättningar kommer automatiskt, eller så får du ta fram den själv
med Ctrl-Space.
Eftersom IDEA vet vilken typ variabeln har vet den exakt vad som kan stå efter "rnd.
", vilket
är ett väldigt bra sätt att lära sig mer om tillgänglig funktionalitet.
Du kan välja ett alternativ i listan eller fortsätta skriva. Om du fortsätter skriva kommer IDEA att
filtrera listan och försöka hitta rätt metod utifrån det som skrivs. Skriver du t.ex. "on" kommer IDEA att
föreslå " nextLong()
".
Man kan navigera i alternativlistan med pil upp/ner. Man kan också trycka Ctrl-Q vilket tar fram javadoc-information för den entitet man just har markerat.
Uppgift: Generera slumptal
Vi ska nu skapa och skriva ut slumptal med en hjälp av Random
.
-
Testa först slumptalen genom att låta
main()
skriva ut 25 slumptal på varsin rad. Använd valfri typ av loop för att iterera 25 gånger. Använd t.ex.nextInt(100)
för att generera ett slumptal mellan 0 och 99. -
Testkör programmet.
-
Nu ska vi även skriva ut slumpmässiga
SquareType
-värden.Med hjälp av klassmetoden (statiska metoden)
SquareType.values()
får vi fram en array som innehåller alla värden i enum-typenSquareType
. Om vi inte har kommit till arrayer än, räcker det att veta att de fungerar ungefär som listor med fixerad längd, och att man indexerar dem som listor i Python:minArray[index]
, därindex
startar på 0. Längden får vi ut medminArray.length
.Utifrån detta kan du fundera på hur man skapar slumptal av rätt storlek. Skriv sedan en loop som 25 gånger skriver ut en slumpmässig
SquareType
(ett slumpmässigt index iSquareType.values()
). -
Testkör programmet.
Efter föreläsning 3, OO i Java
Uppgift 2.3: En egen "fullständig" klass
Syfte: Skapa egna datatyper och objekt!
Vi ska nu gå vidare med skillnaderna mellan objektorienterad och icke objektorienterad programmering. Detta görs genom att vi skapar vår första mycket enkla "fullständiga" klass, som har både internt tillstånd (som lagras i fält), beteende (som anges av metoder), och en egen konstruktor.
Vi tittar även på utskrifter och ser hur vi ger en klass en egen anpassad utskriftsmetod.
Tänk på att du behöver kunna objektorientering från föreläsning 3 innan du börjar!
Om modellering och objekt
Vi tänker oss att vi skall arbeta med olika personer i en applikation. Då är det naturligt att samla information och funktionalitet som rör en person i en klass.
För att kunna konstruera olika objekt av typen Person
behöver vi en konstruktor, en
metod som initialiserar varje nytt objekt. Den heter alltid samma som klassen, har ingen returtyp, och tar
noll eller flera parametrar. Den kan bland annat sätta värden på alla fält i det objekt som den skapar,
t.ex. genom att "spara undan" värden som den har fått som parametrar.
Uppgift 2.3.1: Skapa en klass för personer
-
Skapa klassen
Person
i paketetlab1
. -
Addera ett privat fält med namn
name
av typenString
som ska användas för att lagra personens namn. Eftersom varje person har sitt eget namn (som lagras i personobjektet) ska fältet inte varastatic
. -
Addera ett privat icke-statiskt fält
birthDay
av typenLocalDate
som ska användas för att lagra personens födelsedatum. NärLocalDate
matas in kommer IDEA inte att känna till denna klass, men föreslå att den importeras av personklassen så att den känns igen och kan användas. Skulle man trycka bort möjligheten att låta IDEA importeraLocalDate
kommer IDEA att rödmarkera namnet. Ställer man markören i namnet kommer en röd lampa att tändas där man åter kan välja att IDEA skall importera klassenLocalDate
.Här finns mer information om
LocalDate
för den som är intresserad. -
Lägg till en konstruktor som tar en
String
och enLocalDate
som inparametrar och lagrar dessa värde i objektets "privata" fält. IDEA kan snabbt skapa en konstruktor via Alt+Insert->Constructor.VSCode: Se t.ex. Java on Visual Studio Code June Update.
-
Se till att allt kommer i rätt ordning – fält, sedan konstruktorer, sedan metoder – så som vi har diskuterat på föreläsningen. Lägg helst
main()
sist i klassen. -
Ett
LocalDate
-objekt som representerar ett datum kan t.ex. skapas med hjälp av anropetLocalDate.of(1990, 6, 1)
om man vill skapa ett datum som representerar den 1:a juni 1990. Skapa en main()-metod (snabbast genom att skriva psvm och därefter trycka CTRL+Space eller CTRL-J och välja att ni vill ha en main() metod). I main()-metoden kan du skapa en person som motsvarar dig själv.Kör ditt testprogram och se att det inte kraschar!
Om tidsperioder
Vi skall nu utöka vår personklass med en metod för att beräkna personens ålder. Till vår hjälp har vi redan
sett att LocalDate
kan användas för att representera ett datum. För att få nuvarande tid
använder vi LocalDate.now()
som returnerar ett LocalDate
-objekt som representerar
dagens datum.
Om vi vill räkna ut en persons ålder behöver vi vet hur lång tid det är mellan dess födelse och dagens
datum. Vi kan räkna ut hur lång tidsperiod det är mellan två datum med hjälp av klassen Period
.
Om man anropar Period.between(start,slut)
där start och slut är av typen LocalDate
får man ett Period
-objekt. Man kan ta reda på hur många år perioden består av genom metoden
getYears()
.
Uppgift 2.3.2: Tidsperioder
-
Implementera metoden
getAge()
som returnerar personens ålder. Eftersom varje person har sin egen ålder (som lagras i personobjektet) ska metoden inte varastatic
. -
Testa att metoden fungerar genom att skriva ut åldern på den person med dina data som skapats i
main()
-metoden.
Uppgift 2.3.3: Skapa och skriva ut objekt
Vi vill kunna skriva ut våra personer på ett läsligt format. Först provar vi vad som händer när vi skriver
ut ett Person
-objekt.
-
Se till att
main()
skapar minst ettPerson
-objekt och skriver ut det medSystem.out.println()
. Du skapar objekt med ett anrop till en konstruktor, på formennew Person(...)
. -
Testkör. Får du ett underligt resultat, i stil med
"Person@28cd724"
? Då är allt rätt.
Om utskrifter och metoden toString()
Till skillnad från t.ex. listor finns det inget bra standardiserat sätt att skriva ut enskilda objekt. Att
bara visa värdet på alla fält t.ex. är ofta inte det bästa sättet, även om det hade fungerat för
personklassen som den ser ut just nu. Som standard skriver Java därför ut objekt på formen "klassnamn@objektID
",
där objektID är olika för varje objekt.
För att ändra detta implementerar man metoden public String toString()
i sin klass. Vid ett
anrop såsom System.out.println(mittObjekt)
anropas då (indirekt och automatiskt) mittObjekt.toString()
,
och det är den returnerade strängen som skrivs ut istället för "klassnamn@objektID
". Nu ska vi
implementera en sådan metod.
IDEA kan själv skapa en toString()
via Alt+Insert / toString()
. Detta
kan vara användbart när man snabbt vill generera en toString()
för användning i debuggning
(debuggern visar varje objekts toString()
för att identifiera det). Den skulle dock generera
ett resultat av typen "Person{name='namn', birthDay=1990-06-01}
". I vår applikation är det
viktigt att snabbt kunna se åldern på personer och därför gör vi på ett annat sätt.
VSCode: Generate toString()
toString()
-metod. Implementerar man ingen egen ärvs en ned
från superklassen. Superklassen till Person
är Object, och dess toString()
har
detta standardbeteende. Detta kommer att diskuteras när vi har gått genom arv.
Uppgift 2.3.4: Utskrifter och toString()
-
Tryck Alt+Insert och välj Override. Välj sedan
toString()
.VSCode: Override/implement methods
-
Vi sätter nu ihop en sträng som får representera vår person. Man kan sätta ihop två strängar med hjälp av "
+
"-operatorn. Om man sätter ihop en sträng med något som inte är en sträng kommer Java att konvertera det andra objektet till en sträng. LåttoString()
returnera värdetname + " " + getAge()
. -
Skapa några personer till och skriv ut var och en på sin egen rad.
Sammanfattning
Vi kan nu skapa egna objekt och vet hur man konsturerar en strängrepresentation av dessa. Vi har sett hur man definierar klasser och initialiserar objekt då de skapas. Detta skiljer sig från programmering i icke objektorienterade språk.
Uppgift 2.4: Samverkande klasser – almanackan
Syfte
Nu när vi vet mer om objektorientering i Java (föreläsning 3) ska vi prova på att skapa en större sammanhängande uppsättning klasser för att lösa en specifik uppgift.
Istället för att fortsätta direkt med Tetris kommer vi snabbt att ta ett steg åt sidan och göra en almanacksuppgift som låter oss kontrastera de olika stegen mot den icke objektorienterade och "icke-typade" Python-programmering de flesta av er gjort i en tidigare kurs. Vi kommer även att kunna jämföra hur dataabstraktionen fungerar när vi programmerar objektorienterat. Vi går dock inte lika långt som i Python – bara tillräckligt för att se kontrasterna i modellering.
(Om du inte har gjort motsvarande övning i Python kommer du ändå att kunna lära dig hur man börjar modellera data i Java.)
Om typad OO-programmering
Vi skall nu skapa en klass Month
som har motsvarande funktionalitet som i Python-almanackan
(TDDE24-2020, med NamedTuple
i implementationen).
I Python-almanackan skapade vi många separata funktioner för varje typ, t.ex. för månader.
Funktioner som month_name()
, month_number()
och number_of_days()
blir
nu metoder i Month
. Detta samlar ihop relaterad kod till en sammanhängande enhet.
Den statiska typningen i Java gör också att vi inte måste "manuellt" testa att metoder får parametervärden
av förväntad typ, så som vi gjorde med funktionsanrop som ensure_type(mon, Month)
i TDDE24.
Istället deklarerar vi en parameter av typen Month
och förlitar oss på att kompilatorn
kontrollerar att bara korrekta parametervärden kan skickas in.
Uppgift: Månadsklassen
-
Skapa paketet
se.liu.dinadress.calendar
för almanackan, så den inte blandas ihop med koden för Tetris. Lägg även detta paket "bredvid"lab1
(högerklicka mappen motsvarande ditt liuid i IDEA, och välj New / Package). -
Skapa klassen
Month
i paketetcalendar
, med fälten:- name - månadens namn ("January")
- number - månadens nummer (1)
- days - antal dagar i månaden (31)
Eftersom vi inte vill att någon skall ändra dessa värden utifrån lägger vi attributet "
private
" framför deklarationerna. -
Använd IDEA för att skapa en konstruktor som initialiserar
Month
åt dig. Tryck Alt+Insert och välj Constructor. I detta fall ska alla fält anges som parametrar till konstruktorn, så markera alla fält innan du trycker OK (använd t.ex. Ctrl-A eller Ctrl-klicka raderna).VSCode: Se t.ex. Java on Visual Studio Code June Update.
-
IDEA: Tryck Alt+Insert och välj Getter, välj alla tre fälten och sedan Ok. Nu genererar IDEA funktioner så att andra klasser kan få ut informationen (
getName()
och andra metoder som skapas är publika) men inte ändra den (själva fälten är privata och kan inte ändras utifrån).VSCode: Generate getters and setters.
Vi har nu alla de viktigaste funktionerna som fanns i Python-month.
Om månadsdata
Enligt ovan associeras en månad med ett namn, ett månadsnummer, samt antal dagar. I nuläget kan man skapa en
månad med vilka parametrar som helst. Detta är inte vad vi vill uppnå. Vi vill ha en klart definierad månad
som beter sig så som vi förväntar oss. Därför behövs mer information kring månader. Vi måste kunna testa om
en sträng motsvarar ett månadsnamn samt kunna fråga hur många dagar det finns i en månad med ett visst namn.
Detta är information som är specifik för månader och den bör alltså ligga i klassen Month
.
Vi kommer att bortse från skottår då det inte tillför något av värde ur objektorienteringssynpunkt!
Hur ska man då lagra den här typen av information? Ett sätt är att använda if
-satser eller
switch
-satser:
static int getMonthDays(String name) {
switch (name) {
case "january":
case "march":
case "may":
case "july":
case "august":
case "october":
case "december":
return 31;
case "february":
return 28;
case "april":
case "june":
case "september":
case "november":
return 30;
default:
throw new IllegalArgumentException("Unknown month " + name);
}
}
Detta är inte idealiskt, för det leder oss in på spåret att hårdkoda information. Just antalet dagar i en månad kanske inte ändras så ofta, men det finns väldigt många andra typer av information som man kan vilja ändra utan att kompilera om. Vi vill lagra information i datastrukturer som vi lätt kan manipulera och skicka till de som behöver dem, inte i kod!
Därför kan vi istället använda en Map
, Javas motsvarighet till dict
i Python. Vi behöver
skriva en liten aning mer än i Python, eftersom Java ser dictionaries / maps som vilka datatyper som helst och
därför saknar specialsyntax. Här är en början på en sådan datastruktur:
private static Map<String,Integer> MONTH_NAME_TO_LENGTH = Map.ofEntries(
Map.entry("january", 31),
Map.entry("february", 28)
);
Här ser vi att nyckeln är av typen String
och värdet av typen Integer
, en sorts
objektvariant av den primitiva typen int
– även detta diskuteras mer senare i kursen.
Uppgift: Månadsdata
-
Skapa i klassen
Month
de två fältenMONTH_NAME_TO_LENGTH
ochMONTH_NAME_TO_NUMBER
, som ska göra det möjligt att mappa ett månadsnamn till antalet dagar i månaden respektive månadens nummer från 1 till 12. Detta ska motsvaraMONTH_NAME_TO_NUMBER
ochMONTH_NAME_TO_LENGTH
i Python-almanackan,Fälten ska deklareras
final static
, så att de bara lagras en gång som "globala konstanter", och inte en gång för varjeMonth
-objekt som skapas. Globala konstanter namnges iUPPER_SNAKE_CASE
i Java.I exemplet ovan syns hur man kan initialisera ett fält till en fullständig mappning ("dictionary"), trots att Java inte har någon specialsyntax i stil med
{ key:value, key:value, ... }
som i Python. -
Skapa metoderna
getMonthNumber(String name)
ochgetMonthDays(String name)
. De ska slå upp månadsnamnet iMONTH_NAME_TO_NUMBER
respektiveMONTH_NAME_TO_LENGTH
, returnera korrekt månadsnummer respektive månadslängd om namnet var korrekt, och returnera -1 om namnet inte var korrekt (inte var definierat i mappningen. Då kan en anropare sedan också använda någon av metoderna för att testa om en sträng faktiskt representerar en existerande månad.I just detta fall vill vi använda statiska metoder (deklarerade
static
). Då behöver vi inte skapa ettMonth
-objekt för att kunna ta reda på antal dagar i en månad, utan anropar metoderna direkt i klassen istället. Metoderna anropas därmed somMonth.getMonthNumber(...)
. Detta ska vi bara göra för metoder som verkligen är "globala" och oberoende av information om enskilda månadsobjekt. Mer om detta kommer på senare föreläsningar.Användbara metoder i
Map
:Map.get(key)
– hämta ett värde. Fungerar sommap[key]
i Python, utom att värdetnull
returneras om nyckelnkey
inte var definierad i mappningen.Map.getOrDefault(key, defaultValue)
– hämta ett värde, men returneradefaultValue
om nyckeln inte var definierad.
Uppgift: Klasser för tid och datum
Vi kommer inte att skapa speciella typer för timmar, minuter och dagar. Eftersom vi inte ska gå vidare och ge dessa tidsenheter mer funktionalitet än att just innehålla ett heltal, använder vi i denna labb helt enkelt int för att representera dessa. Däremot behöver vi representera datum som är sammansatta av år, dag och månad. Vi kommer också att behöva representera tidpunkter samt tidsintervall.
I och med att det redan finns en klass som heter Date
i Java döper vi vår nya klass till SimpleDate
,
för att undvika sammanväxlingar. (Java kan hålla ordning på flera klasser med samma namn, så länge
man importerar rätt klass, men i våra diskussioner och beskrivningar kan det vara svårare.)
-
Skapa klassen
SimpleDate
med fältenyear
(int),month
(Month
) ochday
(int). Lägg till en konstruktor som tar in dessa som parametrar. Lägg också till getters för dem. Skriv entoString()
-metod presenterar ett datum i ett format som du tycker är lämpligt. Där kan du bl.a. anropamonth.getName()
ellermonth.getNumber()
för att få reda på information som du behöver. -
Skapa klassen
TimePoint
som innehåller ett klockslag för att representera start/slut på en aktivitet. Klassen skall ha fältenhour
ochminute
.I konstruktorn skall
hour
ochminute
tilldelas värden. Till skillnad från Python-almanackan tar vi in dessa som separata heltalsparametrar till konstruktorn istället för att dela upp en tidssträng i delar.Generera också getters för
hour
ochminute
.De fält och metoder vi skapar nu ska anropas i ett objekt, inte i en klass. De ska alltså inte vara statiska! Vi frågar till exempel ett specifikt
SimpleDate
-objekt, inte datumklassen, vilket år det har. -
Skriv en
toString()
-metod iTimePoint
. Användhour
ochminute
för att skapa en lämplig sträng att returnera. Här kan man t.ex. utnyttja String.format(...) med formatsträngen "%02d:%02d" – vi lämnar medvetet några detaljer till er att utforska!ÖverkursSkapa enint compareTo(TimePoint other)
-metod som returnerar ett negativt tal om den aktuellaTimePoint
kommer föreother
, 0 om de representerar samma tid och ett positivt tal omother
kommer före. -
Skapa till sist klassen
TimeSpan
. Den behöver två fält av typenTimePoint
,start
ochend
. Gör getters och en konstruktor som tar in dessa. Skriv sedan entoString()
-metod som skapar utskrifter av typen "12:15 - 13:15". Detta görs bland annat genom anrop tilltoString()
istart
ochend
. På detta sätt bygger man rekursivt upp en textrepresentation av ett sammansatt objekt.
Om listor i Java
Till skillnad från Python har Java ingen speciell syntax för listor. Istället är de klasser som alla andra,
och det finns flera olika listklasser med olika egenskaper. Den vanligaste och mest använda är ArrayList
.
Listklasserna är generiska typer, något vi ska diskutera senare i kursen. Detta innebär i princip att man kan ange en parameter som talar om vilken typ av element de innehåller, så att kompilatorn kan utföra bättre typkontroller. Om vi vill ha en lista som bara innehåller strängar:
ArrayList<String> myList = new ArrayList<String>();
Som vi nyss har sagt har Java olika listklasser med olika egenskaper. Alla de listklasserna är specialfall
av den mer generella list-typen List
, och det kan hända att IDEA klagar lite på att man är onödigt
specifik i sin deklaration. Även detta ska vi diskutera mer om senare under föreläsningarna. Just nu
kan man helt enkelt skriva om deklarationen på det här sättet:
List<String> myList = new ArrayList<String>();
Vi kan dessutom förenkla detta: Vi behöver inte skriva med elementtypen på högersidan, utan kan
använda <>
istället – en förkortning som betyder "samma typ som på vänstersidan":
List<String> myList = new ArrayList<>();
Vi kan därefter stoppa in och plocka ut element:
myList.add("foo"); // Lägg till sist i listan
String x = myList.get(0); // Hämta första elementet
String y = myList.get(3); // Hämta fjärde elementet
int antal = myList.size(); // Antal element i listan
Uppgift: Möten och kalendern
Allt som återstår nu är att lägga till en klass för möten och att skapa själva kalendern. Vi skapar en
simpel kalender där alla möten lagras i en lista. En stor del av koden i metoden book()
som
används för att lägga möten i kalendern utgörs av parameterkontroll. Att kontrollera användarens
inparametrar är ofta en stor del av den kod som ligger i ett API. Vi kommer att generera ett undantag
(exception) om man försöker skapa ett otillåtet möte.
-
Skapa klassen
Appointment
med tre fält,subject
(String
),date
(typSimpleDate
) ochtimeSpan
(typTimeSpan
). Skapa getters och en konstruktor som tar samtliga som parametrar. Skapa även entoString()
-metod som använder sig avSimpleDate.toString()
ochTimeSpan.toString()
för att formattera en fin utskrift. -
Skapa klassen
Cal
. Den skall ha en privat lista av appointments som realiseras av enArrayList
, vilket är en collection som implementerar list-gränssnittet, i en konstruktor utan inparametrar. Detta sker genom radernaprivate List<Appointment> appointments;
i klassen och
appointments = new ArrayList<>();
i konstruktorn.
Varför inteCalendar
? Jo, det finns redan en sådan klass i Java (fast den representerar en tidpunkt), och även om det skulle fungera att ha en till, kan det vara förvirrande att ha namnkrockar med välkända klasser. -
Implementera metoden
show()
som går igenom listan och skriver ut alla appointments. Detta kan med fördel göras genom att använda Live-Template (Ctrl+J) iter som leder till en så kallad for-each-loop där varje element i t.ex. en lista besöks. Denna loop har formatet "for (element : behållare)
". -
Implementera metoden
public void book(int year, String month, int day, int startHour, int startMinute, int endHour, int endMinute, String subject)
Notera att vi här tar in ett månadsnamn som en sträng. Att skapa ett
SimpleDate
-objekt kräver däremot att vi har ett månadsobjekt. Det är upp tillbook()
att anropa lämpliga statiska metoder iMonth
för att ta reda på bl.a. vilket nummer månaden har och sedan skapa ett lämpligt månadsobjekt.Metoden skall skall kontrollera alla inparametrar. Detta betyder att
- year > 1970
- För start och end är 0 <= hour <= 23 och 0 <= minute <= 59
- month skall vara ett namn som motsvarar en existerande månad
- månaden skall ha tillräckligt många dagar för att day skall vara tillåten
Om någon av parametrarna är fel ska detta signaleras till anroparen. Detta gör man normalt med exceptions, "undantag". Exakt hur detta fungerar kommer vi att gå genom i en senare del av kursen. För tillfället räcker det med att ni vet att ett
IllegalArgumentException
ska kastas, och att detta görs med:throw new IllegalArgumentException("felmeddelande");
När parametrarna är kontrollerade skall ett
SimpleDate
-objekt skapas utifrån datuminformationen och tvåTimePoint
-objekt skapas somstart
/end
. Dessa två används sedan för att skapa ettTimeSpan
och slutligen ettAppointment
som läggs till i listan. -
Kalendern är nu färdig och du skall skriva ett testprogram som skapar en kalender, bokar 5-10 appointments och sedan skriver ut kalendern.
ÖverkursSkriv ut appointments i sorterad ordning.
Sammanfattning
Du har nu byggt ett objektorienterat program med flera klasser. Du är bekväm med Setters/Getters, har testat for-each och listor i Java, inklusive en första titt på generics, samt har kastat Exceptions.
Tetris 2.5: Spelplanen
Syfte: Modellering av spelplan
Nu återvänder vi till Tetris genom att skapa fler datatyper för spelets nuvarande tillstånd –
speciellt en typ för själva spelplanen, som vi kan kalla Board
. Som tidigare vill vi speciellt
fokusera på hur man tänker när man modellerar. Därför diskuterar vi hur vi har tänkt för att komma
fram till de föreslagna datatyperna.
Om spelplaner – vilken information behövs?
När vi tänker på Tetris är det lätt att vi fokuserar på det visuella: Ett spelbräde visas upp på skärmen med ritade rutor i olika färger. Just nu ska vi dock fokusera på den information som behövs för att hålla reda på spelet och spelmekaniken. Till exempel måste man veta:
Hur stor spelplanen är
Vilka rutor som är upptagna, och vad de innehåller
Formen och koordinaterna för den bit som håller på att ramla ner just nu
Den här informationen är nödvändig: Utan den kan man överhuvudtaget inte spela. Koden som ritar upp ett spelbräde är jämförelsevis mindre central – den skulle till exempel inte alls behövas om man vill programmera en AI-baserad spelare (bot), eller om man vill spara informationen om ett spel i en fil så att man kan återuppta den senare.
Om spelplaner – hur representerar vi informationen?
Nu behöver vi alltså en spelplansklass, Board
, som till att börja med innehåller
information om vilken typ av kvadrat som för tillfället finns på varje position på spelplanen. Hur ska vi
lagra detta?
Ett sätt är att ha en lista på kvadrater, där varje kvadrat känner till sin egen position. Då skulle en spelplan t.ex. kunna innehålla 25 olika L-kvadrater, var och en med sina egna koordinater. Det skulle fungera utmärkt när vi ritar ut spelplanen men blir kanske mindre bra när vi vill undersöka om en viss rad är full. I sådana fall är det ju bra om vi snabbt kan hitta alla kvadrater på en viss rad, vilket vi inte får om vi måste leta genom en lista på alla kvadrater.
Ett annat sätt är att lagra ett rutnät som man kan indexera med x- och y-koordinater för att se vad som finns på en viss position. Det kan man göra med en nästlad lista, som i Python. Ett alternativ är att använda en tvådimensionell array. Arrayer är i princip som listor, men har (i Java) en fast längd som inte kan ändras efter att arrayen skapades. Den begränsningen är inget problem för oss eftersom vi inte vill ändra storleken på spelbrädet.
Om arrayer i Java
Arraysyntaxen i Java liknar i vissa delar Pythons listsyntax, men det finns skillnader.
Första raden nedan skapar en array med plats för 10 element. Arrayen innehåller 10 st null
,
vilket på vissa sätt motsvarar Pythons None
: "Det finns inget här". Andra raden stoppar in ett
värde på den fjärde positionen, som har index 3 (index börjar på 0). Tredje raden visar hur man hämtar ut
ett element.
SquareType[] array = new SquareType[10];
array[3] = SquareType.EMPTY;
SquareType pos3 = array[3];
Men nu är det en tvådimensionell array, ett "rutnät", som vi behöver för spelplanen. Första raden nedan skapar en sådan array. Andra raden används för att komma åt en rad i arrayen, tredje raden visar hur man kommer åt en cell, och fjärde raden visar hur man ändrar en cell.
SquareType[][] squares = new SquareType[rows][columns];
SquareType[] rowZero = squares[0];
SquareType row5col9 = squares[5][9];
squares[5][9] = SquareType.EMPTY;
Mer information finns t.ex. i Java Tutorial, en av våra kursböcker.
Om spelplaner lagrade som arrayer
Ett Board
kan alltså innehålla en tvådimensionell array av lämplig storlek, där varje
element är en SquareType
som identifierar typen av kvadrat.
Detta fält bör vara private, eftersom andra klasser inte ska kunna komma åt det. Detta kommer att diskuteras i mer detalj på föreläsningarna, men just nu kan du bara följa instruktionerna.
Ovan hade vi new SquareType[rows][columns]
som exempel. I det fallet indexerar vi alltså med
koordinaterna [y,x]
och inte [x,y]
! Tänk på denna ordning när du använder din array
internt i Board
. Tänk också på om du anser att rad 0 är den översta eller understa raden i spelbrädet!
(Varför gör vi på det sättet? En anledning är att det kan vara bra att
kunna plocka ut en hel rad ur spelbrädet: board[6]
är raden med index 6, inte en
kolumn.)
Board
behöver också ha en konstruktor som tar en bredd och höjd på spelplanen som argument och
skapar en lämplig tvådimensionell array. Detta verkar vara det enda vi behöver just nu, men fler fält och
metoder tillkommer säkert senare.
Uppgift: Skapa klass för spelplan
Skapa klassen
Board
i Tetris-paketet.Ge klassen fältet
private SquareType[][] squares
. Detta deklarerar en tvådimensionell array av SquareType-värden. Lägg även till privata heltalsfältwidth
ochheight
där konstruktorn nedan ska spara undan brädets dimensioner.Ge klassen en konstruktor som tar parametrarna
width
ochheight
. Låt konstruktorn sättasquares
tillnew SquareType[height][width]
. Detta skapar den faktiska arrayen som fältet ska peka på.-
Alla positioner i arrayen är nu
null
, dvs. vi har en array av pekare som inte pekar på några verkliga objekt. Se till att konstruktorn fyller alla positioner i arrayen (alla kolumner i alla rader) med "tomma kvadraten" istället. -
Gör en
main
-metod som helt enkelt skapar ettBoard
med valfri storlek. Testkör detta för att se att programmet inte kraschar, t.ex. genom att man försöker skriva utanför arrayens gränser.
Om att gömma data
Varför skulle den tvådimensionella arrayen vara privat i Board
? För att vi vill
att spelplansklassen ska ha full koll över hur planen manipuleras – andra klasser får anropa metoder i
planen för att be den göra något, men de får inte gå in och ändra direkt i arrayen. Man ska inte heller
skapa en metod som returnerar hela arrayen, eftersom det låter utomstående manipulera den direkt. Detta
kommer som sagt att diskuteras mer under föreläsningarna.
Tetris 2.6: Visualisering av spelplan
Syfte: Visualisera... innan vi lär oss grafiska gränssnitt
Vi vill gärna kunna visa spelplanen för att se om vi har gjort rätt. Istället för att gå direkt på grafiken kommer vi i ett första steg att köra textbaserat. Dels är detta enklare och gör att vi snabbt kan komma vidare, dels kan det vara användbart för debuggning av programmet, och dels är det ett steg i att demonstrera användningen av strängar i Java.
Om modellering: Vems ansvar är visualiseringen?
I all programmering är det viktigt att man delar upp koden i olika moduler med tydliga ansvarsområden. Detta gör koden enklare att läsa, skriva, förstå, och strukturera. Java delar t.ex. upp koden i paket och klasser.
Vems ansvarsområde är det då att visualisera något på skärmen? Det kan man argumentera om. Det finns som vanligt många sätt att dela upp ansvarsområdena, alla med sina fördelar och nackdelar. Vilket som blir bäst beror ofta på situationen.
Tänk dig att vi programmerar ett plattformsspel med många olika sorters objekt på skärmen: Bakgrunder, spelare, fiender, bonusar att plocka upp, med mera. Då kanske vi tycker att varje objekt borde hålla reda på hur det själv ska se ut. Då får vi en klar och tydlig uppdelning:
En klass håller reda på allt om spelaren, inklusive hur den rör sig, hur den ser ut på skärmen, hur många poäng spelaren har, och så vidare
En annan klass håller reda på allt om fiender av typ 1, inklusive hur de ser ut, hur de beter sig, hur starka de är, och så vidare
En tredje klass håller reda på allt om fiender av typ 2, inklusive hur de ser ut, hur de rör sig på skärmen, hur starka de är, och så vidare
...
Om det görs på rätt sätt kan detta bli en bra och modulär uppdelning och ansvarsfördelning. Vill man lägga till en ny typ av fiende lägger man till en ny klass, och behöver inte ändra i de andra.
Men nu programmerar vi en annan typ av spel, där det inte kommer att finnas så många olika sorters objekt på skärmen. Vi har en bakgrund, kvadrater från block som har ramlat ner, och eventuellt en fallande tetromino. Då kan vi göra en annan typ av uppdelning, som också är klar och tydlig:
En klass (
Board
) håller reda på all grundläggande information om spelplanen, inklusive hur stor den är, var kvadraterna finns, och så vidare. Den vet också hur spelmekaniken fungerar (hur saker faller, hur rader försvinner).En annan klass vet hur man ritar upp spelplanen.
Här har vi delat upp funktionaliteten åt ett annat håll, genom att samla hela modellen på en plats och hela visualiseringen, vyn, på en annan plats. Detta är en del av ett designmönster som i sin helhet kallas Model-View-Controller.
Så vilket ska vi välja?
I många projekt tror vi att den första lösningen kommer att fungera bäst, eftersom man annars får en alltför stor centralisering av "hur man ritar" för många olika typer av skärmobjekt. Detta skulle strida mot principer om modularisering.
Men i Tetris har vi inte så många olika typer av skärmobjekt, och vi tjänar antagligen mer på att separera utritning från datamodellen än på att separera utritning av kvadrater från utritning av bakgrunden.
Därför bestämmer vi att vi ska skapa en separat vy-klass som är ansvarig för att rita ut hela spelbrädet.
Om "getters" och "setters"
Vy-klassen kommer att behöva information från Board
för att veta vad den ska rita ut.
Om man tillåter objekt från en klass att direkt använda fälten i objekt som tillhör en annan klass,
låser man fast sig i en viss representation som sedan blir svår att ändra. Därför vill vi till exempel inte
att klassen som visar ett spelbräde på skärmen ska komma åt den tvådimensionella squares
-arrayen
i Board
direkt. Istället ska denna representation vara Board
:s hemlighet.
Men på något sätt måste ju visaren få reda på informationen. Hur gör man då? Jo, vi skapar ett mellanlager av metoder som gör informationen tillgänglig. Som vi ska diskutera på föreläsningarna kan det mellanlagret exponera informationen utan att tala om exakt hur och var den lagras. En av fördelarna är att den interna representationen kan ändras utan att andra klasser behöver modifiera sin kod.
Metoderna som hämtar och ändrar information kan kallas för getters respektive setters. Java har en konvention att dessa metoder kallas för getXYZ() respektive setXYZ(), där XYZ är någon egenskap man är intresserad av.
Om getters och setters i spelbrädet
Vy-klassen kommer bland annat att vara intresserad av spelbrädets bredd och höjd. Dessa lagras just nu i
fälten width
och height
, och vi vill skriva metoderna getWidth()
och
getHeight()
. Här ser vi att metodnamnen direkt motsvarar fältnamnen, och att funktionaliteten
blir att direkt returnera ett värde.
Vi kommer också att vara intresserade av vilken typ av kvadrat (om någon) som finns på en viss position.
Detta kunde man rent tekniskt göra tillgängligt genom att returnera hela squares
-arrayen, men
då exponerar vi lite för mycket av den interna representationen. Det blir bättre att istället skapa en metod
som bara returnerar information om en specifik position. En sådan metod blir lättare att ändra om
man senare vill ändra Board
för att t.ex. lagra spelbrädet i nästlade listor eller någon annan
representation.
Uppgift: Ge vy-klassen tillgång till information
-
Skapa getters för
width
ochheight
iBoard
samt en metod för att ta reda på vilkenSquareType
som är lagrad i en given cell (x,y) isquares
.Skapa inte en metod som returnerar hela
squares
! Vårt spelbräde vill ha mer kontroll över sin matris av kvadrater och vill inte att andra ska kunna peta i matrisen/arrayen direkt.
Att skriva ut ett spelbräde
Vi ska nu skapa en klass vars ansvarsområde är att visa upp ett spelbräde. Men hur ska den nuvarande situationen egentligen visas?
Just nu kommer vi att använda textgrafik, men så länge som vi designar våra (objektorienterade)
gränssnitt på ett bra sätt kommer vi senare att kunna byta ut detta mot "vanlig" grafik. Vi skulle också
kunna "visa" spelplanen som en lista på upptagna
positioner
[A7,A8,B5,...]
– inte så intutivt för oss, men det skulle fungera bra för en
AI-spelare som vill "se" spelplanen. Vi skulle kunna visa det på en 3D-display för blinda (se bilden), och
så vidare.
Om strängmanipulation i Java
Om vi nu ska använda textgrafik är det bra att veta att Javas strängar är oföränderliga: Man kan inte ändra på dem när de väl har skapats. Man kan visserligen göra så här:
String str = "Hello ";
str = str + "World";
Men det gör att man först skapar en sträng, och sedan kopierar den för att skapa en ny sträng. Det är inte så farligt om man gör det någon enstaka gång, men om vi ska göra många tillägg blir det väldigt långsamt.
För att inkrementellt bygga upp en lång sträng använder man istället klassen StringBuilder
och dess append()
-metoder. För att lägga till en radbrytning efter varje rad används
"\n
" (backslash n). När strängen är klar tas den fram som String
-objekt med toString()
.
Här är ett exempel:
// Create new StringBuilder.
StringBuilder builder = new StringBuilder();
// Loop and append values.
for (int i = 0; i < 5; i++) {
builder.append("abc ");
}
// Create a string with the same contents.
String result = builder.toString();
// Print result.
System.out.println(result);
Om SquareType och textsymboler
Givet en SquareType
behöver vi veta vilken symbol den ska illustreras med. Man kunde tänka sig
att detta skulle läggas in direkt i själva SquareType
-objektet men då har vi blandat ihop
modellen (SquareType
) med ett specifikt sätt att visa modellen på (text, grafik, ...). Istället
kan vi låta vyn känna till vilka SquareType
s som finns och ha en hjälpmetod som returnerar
symbolen motsvarande en viss SquareType
.
Uppgift: Skriv ut ett spelbräde
-
Skapa klassen
BoardToTextConverter
, med metodenconvertToText(Board)
som returnerar en strängrepresentation av ett modellobjekt – den skriver alltså inte ut på skärmen själv utan returnerar en sträng som anroparen kan skriva ut. Tomma rutor ska då representeras med t.ex. mellanslag eller bindestreck, och olika typer på kvadrater (SquareType
s) ska representeras som textsymboler, t.ex. "#
", "%
" och "-
".Metoden ska inte vara static. Vi vill ha ett
BoardToTextConverter
-objekt vars metoder t.ex. kan override:as i subklasser (diskuteras senare).Du behöver en nästlad loop som itererar över rader och kolumner i lämplig ordning. Inuti loopen kan man använda en
switch
-sats med ettcase
för varjeSquareType
-värde, något som är möjligt på grund av attSquareType
är en enum. Det vill säga, om elementet på en viss position ärSquareType.EMPTY
lägger du till ett mellanslag, om elementet ärSquareType.I
lägger du till en annan symbol, osv.Glöm inte radbrytningarna
"\n"
mellan raderna. -
Skriv en separat testklass,
BoardTester
, som skapar en tom spelplan, konverterar den till en sträng med hjälp av ettBoardToTextConverter
-objekt, och skriver ut resultatet. När det är klart har du åstadkommit ett testbart ramverk och kan se att du har gjort konkreta framsteg!Gör inte spelbrädet kvadratiskt! Att använda
width == height
kan dölja buggar i koden, eftersom det inte märks om man blandar ihop begreppen. Se till att bredd och höjd är olika, så kan du hitta sådana buggar och minska risken för komplettering.
Tetris 2.7: Slumpning av spelplan
Uppgift: Test
För vår testning vill vi också ha en metod för att slumpa fram en spelplan.
-
Skapa en metod i
Board
som kan användas för att ersätta det nuvarande innehållet isquares
med genererat slumpmässigt innehåll (en slumpmässigSquareType
i varje ruta).Som tidigare använder du ett objekt av klassen
java.util.Random
. Nu ska vi använda samma slumptalsgenerator flera gånger i olika anrop, så lagra Random-objektet i ett statiskt fält iBoard
och ge det ett värde direkt vid fältet:private final static Random RND = new Random();
.Glöm inte
final
, eftersom vi vill ha en konstant och inte behöver byta ut vår slumptalsgenerator. Om vi skapar en ny generator varje gång vi vill ha ett slumptal blir det (a) onödigt långsamt eftersom vi måste skapa många objekt i onödan, och (b) dålig kvalitet på slumptalen eftersom vi inte får en chans att utnyttja serien av slumptal.Anropa sedan generatorn varje gång du behöver ett slumptal!
-
Ändra klassen
BoardTester
så att den 10 gånger slumpar om spelplanen och skriver ut den. Testkör så att du ser att utskriften fungerar.
Tetris 2.8: Tetrisblock / tetrominoes
Att modellera polyominos
Efter spelplanen kommer själva Tetrisblocken. Det finns som sagt totalt 7 varianter i grundspelet, och vi kan tänkas vilja utöka detta på olika sätt i framtiden – genom nya former (utökning till pentominoes med fem kvadrater per block, penta=5) eller andra varianter (block som exploderar). Frågan är då hur man ska implementera block på bästa sätt för att tillåta sådana utökningar.
-
En klass per blocktyp (L, T, ...)? Det är så klart en möjlighet, men en grundregel är att vi bara ska ha olika klasser om objekten faktiskt beter sig annorlunda. Här beter de sig blocken egentligen inte olika, utan de har bara olika utseende. Det kan vi enkelt representera som data (information om blockens form) istället för olika klasser.
Vi upprepar regeln: Skapa bara nya klasser om de verkligen har olika beteende som ger större skillnad i koden. Skapa inte nya klasser när det räcker med att några värden skiljer sig. I projektet ger detta komplettering!
-
En enda klass? Det verkar vara ett bättre val. Och om vi sedan vill implementera exploderande block kan vi göra det med en enda ny klass, snarare än sju olika subklasser ("exploderande L", "exploderande O", osv) – eller till och med i den ursprungliga klassen.
Vi skapar därför en enda blockklass, som vi kan kalla Poly
(kort för Polyomino).
Vi behöver ett sätt för varje Poly
-objekt att veta vilken "form" den har. Detta har inte att
göra med visualisering (pixlar på skärmen) utan är en del av den fundamentala modellen för ett block (till
exempel avgör det hur långt ett block kan falla). Därför ska det definitivt finnas med i Poly
-klassen.
Eftersom vi ska placera ut Poly
-objekt på vårt Board
kan vi med fördel
representera denna på samma sätt. Vi väljer därför en tvådimensionell array för vår Poly
.
Tanken är att klassen Poly
ska kunna användas för vilken typ av block som helst, inklusive
t.ex. block med 3 eller 5 kvadrater. Därför vill vi inte direkt i Poly
hårdkoda kunskapen om
exakt vilka blocktyper som finns. I stället låter vi konstruktorn ta in en tvådimensionell array av
kvadrater som parameter. Den exakta storleken på arrayen bestäms då av den som anropar konstruktorn.
Uppgift: Polyomino-klass
-
Implementera klassen
Poly
enligt beskrivningen ovan.Klassens konstruktor ska alltså ta in en array som beskriver konfigurationen hos en godtycklig polyomino. Sådana arrayer kommer sedan att skickas in av
TetrominoMaker
i nästa uppgift – det är där de 7 specifika blocktyperna definieras.
Att skapa olika typer av Polyomino: TetrominoMaker
Någon måste hålla reda på vilka Poly
som ska finnas i spelet, hur de ser ut, och hur de skapas.
Detta är ett tydligt ansvarsområde som man kan skapa en egen klass för – med en fabriksmetod.
Vi skapar alltså klassen TetrominoMaker
med dessa metoder:
public int getNumberOfTypes() { ... }
public Poly getPoly(int n) { ... }
En anropare kan då skapa en TetrominoMaker
och anropa getNumberOfTypes()
för att
fråga om hur många blocktyper som den kan ge oss (just nu 7). Notera att metoderna inte ska vara
static
: Vi vill programmera objektorienterat och t.ex. bevara möjligheten att skapa underklasser
till TetrominoMaker
med egna beteenden för metoderna (mer om det senare).
Anroparen kan sedan slumpa fram ett tal mellan 0 och getNumberOfTypes()-1
och anropa objektets
getPoly()
med detta som argument. Metoden getPoly()
returnerar då ett nytt Poly
-objekt
av den givna typen.
getPoly()
gör detta genom att skapa en 2D-array av lämplig storlek, fylla den med kvadrater
motsvarande det begärda blocket, skapa en ny Poly
för denna array, och returnera det nya
objektet. Om ett ogiltigt nummer skickas in kan ett fel signaleras: throw new
IllegalArgumentException("Invalid index: " + n);
Vilken storlek ska då arrayen ha? För att underlätta för senare rotation av blocken är det en fördel att använda en storlek på 2x2, 3x3 eller 4x4, beroende på vilken tetromino som den innehåller. Arrayerna kan då skapas enligt vänstra kolumnen i följande bild (där de övriga kolumnerna visar hur blocken ser ut när de roteras, vilket vi kommer till senare). Blocket O ("kvadraten") ser ut att ligga i en array med storlek 4x3, men för att förenkla för oss själva kan vi helt enkelt lägga det i storleken 2x2 istället.
Bilden nedan kommer från en beskrivning av det standardiserade rotationssystemet.
Att göra: TetrominoMaker
-
Implementera klassen
TetrominoMaker
enligt beskrivningen ovan.Tänk på att inte skapa för långa metoder – om
getPoly()
blir för lång när du lägger in skapandet av alla blocktyper i den metoden kan du bryta ut delar till sju olika hjälpmetoder istället.
Om fabriker och fabriksmetoder
En TetrominoMaker
är en fabrik (factory): Ett objekt som kan skapa andra objekt och
returnera dem till anroparen. Man kan också bredda detta begrepp och kalla getPoly()
för en
fabriksmetod (factory method).
Att representera Tetrisblock på spelplanen -- olika alternativ
Vår spelplan kan representera de block som redan har fallit ner. Detta representeras som en array av SquareType
s,
vilket ger oss all information vi behöver för att rita upp det som är kvar av de block som fallit ner. Nu
behöver den också kunna representera ett block som är på väg att falla ner, så att all information
om spelets nuvarande tillstånd sparas på ett och samma ställe. Hur åstadkommer vi det? Läs klart innan du
börjar!
Alternativ 1 är att de fyra kvadraterna (SquareType
) från det fallande blocket läggs in
i samma tvådimensionella array som används för att lagra de block som redan har fallit ner. Det har en
fördel: Eftersom allt som ska ritas ut ligger lagrat på samma sätt som förut, behöver vi inte behöver ändra
uppritningsfunktionen (BoardToTextConverter
). Å andra sidan har vi en nackdel: När det fallande
blocket flyttar sig ett steg (eller roteras) måste vi komma ihåg var vi hade lagt in det, ta bort
dess SquareType
s från dessa positioner i arrayen och lägga till dem igen på nya positioner.
Detta kan bli lite omständigt.
Alternativ 2 är att den tvådimensionella arrayen i Board
bara lagrar de block som redan
har fallit färdigt. Det block som fortfarande faller får Board
hålla reda på genom att ha ett
fält falling
som pekar på en fallande Poly
(eller null
om inget block faller just nu),
och fältet fallingPos
som anger dess nuvarande plats. Då blir det lätt att flytta det fallande blocket genom att
helt enkelt ändra dess koordinater, utan att överhuvudtaget peta i Board
:s tvådimensionella
array. Det underlättar också framtida utökningar där ett block inte nödvändigtvis faller och roterar med ett
"hopp" från ett läge till ett annat utan animeras jämnt och mjukt. Å andra sidan behöver vi ändra
uppritningsfunktionen så den tar hänsyn till både de block som fallit färdigt och det fallande blocket.
Fältet fallingPos
är då lämpligen av typ
java.awt.Point
.
Det låter oss hantera en position som en enda meningsfull enhet, istället för att lagra den som två separata fält för
x- och y-koordinater. Se dokumentationen som vi länkar ovan för att hitta lämplig funktionalitet i klassen!
Att representera Tetrisblock -- vilket alternativ väljer vi?
Här ska det andra alternativet användas, eftersom det första ger många studenter problem. Det kräver
nya fält och metoder i Board
samt utökningar i BoardToTextConverter
.
Just nu (utan mjuka animeringar) gäller alltså för varje position (x,y) som ska ritas ut (läggas till i textsträngen):
-
Om positionen täcks av en kvadrat i ett fallande block gäller denna
SquareType
-
Annars gäller den
SquareType
som anges avBoard
.
(Just nu kan det finnas positioner (x,y) där det både finns en ruta från spelplanen och en ruta från den fallande tetrominon. I dessa fall är det alltid rutan från den fallande tetrominon, inte rutan från spelplanen, som ska "ritas ut" (läggas till i strängen). Det vill säga, den fallande tetrominon ska aldrig "döljas bakom" de rutor som redan ligger i spelplanen, utan ska alltid synas. När vi har spelmekaniken på plats kommer den här typen av överlapp inte att kunna ske!)
Att representera Tetrisblock -- hur implementerar vi?
Ett sätt är åstadkomma det vi vill är att BoardToTextConverter
känner till fallande block, och
vet hur de ska ritas ut.
Ett annat är att implementera en ny metod SquareType getVisibleSquareAt(x,y)
i Board
som
vet vilken kvadrat som ska synas på en viss position (enligt ovan) och returnerar detta:
Blocket
falling
har sitt övre vänstra hörn på vissa koordinater, enPoint
vars koordinater vi kan kalla (x1,y1). Utifrån detta ochPoly
:ns storlek kan vi beräkna vilka kvadrater som täcks av dennaPoly
: Från (x1,y1) till (x2,y2).Om (x,y) inte ligger mellan (x1,y1) och (x2,y2) kan
falling
definitivt inte täcka över (x,y). Då är det bara att titta iBoard
-arrayen.Annars täcker
falling
över positionen (x,y) – men den kan göra det med en "genomskinlig" ruta (EMPTY
) på sin interna position (x-x1,y-y1). I så fall ska vi också titta iBoard
-arrayen.Slutligen finns fallet där
falling
täcker över positionen (x,y) och faktiskt har en annanSquareType
änEMPTY
där. Då måste vi returnera rättSquareType
enligtfalling
.
Då kan BoardToTextConverter
anropa getVisibleSquareAt()
utan speciell kunskap om fallande
block. Välj själv!
Uppgift: Tetrisblock på spelplanen
-
Lägg till ett fält
Poly falling
iBoard
(som vanligt privat). Där ska du lagra "den poly som just nu håller på att ramla ner", ellernull
om ingen poly faller just nu. Du behöver också ha koll på polyns nuvarande position (x,y), så lägg till fält för detta. Lägg slutligen till getters så attBoardToTextConverter
kan få ut den fallande polyn och dess koordinater -
Ändra så att även den fallande tetrominon "ritas" ut (i textsträngen). Se "Hur åstadkommer vi det?" ovan.
-
Testa detta genom att ändra
Board
-konstruktorn så att den skapar enPoly
som läggs in ifalling
och sätter en lämplig position för dennaPoly
, t.ex. överst i mitten av spelplanen. LåtBoardTester
skapa en spelplan och skriva ut resultatet. Ser du det "fallande" (men stillastående) blocket på den position där du vill ha det?Senare ska vi se till att slumpa fram ett block (
falling
), att blocket faktiskt faller ner ett steg i taget, och att man kan styra blocket. Just nu testar vi bara att "utskriften" av blocket fungerar när det ligger stilla på en fast position.
Tetris 2.9: Textgrafik i ett fönster
Om textgrafik
Det är nu dags att visa upp textgrafiken i ett fönster istället för i terminalen. Om du arbetar enligt det föreslagna schemat kommer du hit innan vi har diskuterat hur grafiska gränssnitt programmeras i Java. Vår nuvarande version av gränssnittet kommer därför att vara väldigt simpelt och vi kommer att ge extra tips om de viktigaste sakerna man behöver veta.
Du kommer alltså att börja med att skriva ett grafiskt gränssnitt som använder en grafisk
textkomponent, en JTextArea
, för att visa den gamla textrepresentationen av en
"spelplan". Det verkar kanske som ett lite underligt sätt, men det gör dels att du kan börja med GUIt redan
innan du lär dig hur man skapar egna komponenter, dels att du får ett enklare "mellansteg" så att du inte
behöver göra om hela användargränssnittet på en gång. Det gör det också möjligt att göra en väldigt enkel
animering av t.ex. nedfallande block genom att du i JTextArea
byter ut texten i varje
steg, istället för att som med System.out.println()
skriva ut varje ny spelplan under
den förra.
Denna flerstegsimplementation är något vi gör i just Tetris-projektet men är absolut inte något man behöver göra för det senare projektet!
Uppgift: Textgrafik
Skapa den nya klassen
TetrisViewer
.På något sätt ska
TetrisViewer
få tillgång till ettBoard
-objekt. Man kunde tänka sig attTetrisViewer
själv skulle skapa ettBoard
, men det är bättre om man istället skickar in ettBoard
som parameter till konstruktorn. Detta följer principen att varje klass ska ha en väl avdelad uppgift att utföra och ska inte göra något som ligger utanför den uppgiften!TetrisViewer
s uppgift är att visa en spelplan och låta oss interagera med den. Spelstartarens uppgift är (bland annat) att skapa en spelplan och ge den till ett fönster och till alla andra som kan behöva tillgång till den.Vi vill nu att
TetrisViewer
ska kunna öppna ett fönster för att visa upp spelet. Var gör vi det?En möjlighet vore att lägga detta i konstruktorn. Å andra sidan bör en konstruktor initialisera objekt men inte göra särskilt mycket "riktigt arbete". Den som anropar konstruktorn kan vänta sig att snabbt få tillbaka ett nytt objekt och sedan be det objektet att göra något, som att öppna ett fönster eller påbörja något annat arbete.
Därför skapar vi istället en ny metod
show()
iTetrisViewer
. Denna metod ska skapa ett fönster, enJFrame
, som den ska använda för att visa upp spelet – både själva spelplanen och annan information runt omkring, till exempel poängräkning.JFrame
representerar alltså ett GUI-fönster som kan innehålla ett antal olika komponenter såsom knappar, menyer och textrutor. Klassen har en konstruktor som saknar argument, men då får vi ingen fönstertitel. Det är bättre att anropa denJFrame
-konstruktor som tar emot en fönstertitel som parameter.Nedan antar vi att fönsterobjektet lagras i en variabel (eller ett fält) som heter
frame
.I
show()
(eller i metoder som denna anropar) ska du sedan bygga upp ett lämpligt användargränssnitt. Detta sker bland annat genom attshow()
lägger till komponenter i fönstret som den har skapat.Den viktigaste delen just nu är den
JTextArea
som ska användas till att visa själva "spelplanen". Skapa alltså en sådan och ange i konstruktorn tillJTextArea
hur många kolumner och rader som ska visas – detta avgör vilken preferred size textarean ska ha och därmed hur stor den blir på skärmen. Antalet kolumner och rader får du reda på genom att fråga detBoard
som konstruktorn har fått som parameter, inte genom att hårdkoda!Hur hittar man vilka konstruktorer som finns? I IDEA, skriv "
new JTextArea(
" och tryck Ctrl-P. Då får du se alla varianter på parametrar. Välj en av dem och tryck Ctrl-Q så får du dokumentation för parametrarna.Textarean behöver också ges sitt första innehåll. Här förutsätter vi att du har följt instruktionerna och skrivit en
BoardToTextConverter
med en metod som returnerar en sträng, inte en metod som skriver ut en spelplan direkt till t.ex.System.out
. Då kan du använda den metoden för att få en sträng motsvarande ettBoard
, och sedan använda textareans metodsetText()
för att sätta rätt text. Att uppdatera textarean när spelplanen ändras blir en senare uppgift.I övrigt behövs en del kod i
show()
för att definiera fönsterlayouten, se till att fönstret visas, osv.:Layouten för ett fönster hanteras av en layouthanterare som applicerar en layoutalgoritm för att placera ut komponenterna (inte genom att man själv anger koordinater för komponenterna). En lämplig start för just detta enkla fönster kan vara att
TetrisViewer
sätter fönstrets layouthanterare medframe.setLayout(new BorderLayout())
.Vi kan sedan lägga till textarean i fönstret med
frame.add(textarea, BorderLayout.CENTER)
.CENTER
placerar textarean "i mitten" i fönstret (givet att vi har just enBorderLayout
som layouthanterare). Eftersom vi inte har lagt till några andra komponenter i fönstret kommer textarean att ta upp hela utrymmet i fönstret.För att rutor i tetrisbrädet skall visas lika stora oavsett vilka tecken man valt att representera olika
SquareType
s lägger vi tilltextarea.setFont(new Font("Monospaced", Font.PLAIN, 20));
. Det sätter fonten till en monospaced font.För att utföra layouten och göra fönstret synligt:
frame.pack(); frame.setVisible(true);
-
Ändra en existerande testklass, eller skapa en ny, så att den skapar ett
Board
och enTetrisViewer
, och som ber dennaTetrisViewer
att öppna ett fönster. När testet körs ska slutresultatet vara att du ser ett lagom stort fönster med en framslumpad spelplan (inte 10 spelplaner, som tidigare). Just nu finns inga menyer eller knappar för att avsluta, så du får använda "stoppknappen" i utvecklingsmiljön för att stänga av testprogrammet (eller ctrl-c i terminalfönstret).Som tidigare: Gör inte spelbrädet kvadratiskt! Att använda
width == height
kan dölja buggar i koden, eftersom det inte märks om man blandar ihop begreppen. Se till att bredd och höjd är olika, så kan du hitta sådana buggar och minska risken för komplettering.
Avslutning
Här slutar andra laborationen. Det är dags att visa och demonstrera slutresultatet för din handledare – det krävs för att få godkänt!
Du behöver inte skicka in din kod just nu, utan handledaren tittar på det viktigaste vid demonstrationen. Det du har skrivit kommer däremot att följa med i en senare inlämning, och då kan handledaren göra en övergripande genomgång av allt du har gjort.
Passa gärna på att fråga om det är något du undrar över, och be om återkoppling på det du har skrivit!
Fortsätt direkt med nästa labb om handledaren är upptagen.
Labb av Jonas Kvarnström, Mikael Nilsson 2014–2025.
Sidansvarig: Jonas Kvarnström
Senast uppdaterad: 2025-01-19