Du kan finne en presentasjonen som hører til workshopen under docs.
Denne workshopen er delt inn i to deler: den første delen gir deg en generell introduksjon til noen viktige konsepter i Kotlin, før vi skal lage et spill i del to! Dersom du har vært borti Kotlin før, må du gjerne hoppe over del en.
Og ikke glem, bruk coachene og kollegaene dine aktivt! Vi er her for å hjelpe 🚀
En data class
er en klasse kun ment til å holde på data.
Når du definerer en dataklasse får du en del funksjonalitet gratis, som f.eks. toString
, equals
, hashCode
og copy
.
Oppgave:
Åpne filen i introduction som heter DataClass.kt. Her ligger det en klasse som heter Konsulent
, og en main funksjon.
- Kjør main funksjonen, og se hva som skjer.
- Gjør
Konsulent
om til endata class
og kjør main funksjonen igjen. Hvilke to forskjellger ser du ser du i konsollutskriften? Hvorfor er det slik?
Løsningsforslag 🤠
I konsollutskriften ser vi to endringer:- Vi får en fin utskrift av objektet vårt, i stedet for at det bare står
Konsulent@<hashkode>
. - Vi får
true
når vi sammenligner to konsulenter med samme navn, i stedet forfalse
.
Endringen i objektutskriften er fordi Konsulent
alle vanlige klasser (class
) har en default implementasjon av toString
som skriver ut klassenavnet og en hashkode.
Dette fører til at utskriften av println(konsulent)
blir noe sånt som Konsulent@6d06d69c
. Instanser av data class
derimot,
har en implementasjon av toString
som skriver ut alle feltene i klassen, slik at utskriften blir noe sånt som Konsulent(name=Gaute)
.
Endringen i sammenligningen er fordi vanlige klasser (class
) har en default implementasjon av ==
(eller equals
som det heter)
som bare sammenligner referansene til objektene, altså om de er det samme objektet i minnet.
data class
derimot, har en implementasjon av equals
som sammenligner innholdet i objektene, altså om de har samme verdi for alle feltene.
Dette fører til at når Konsulent er en data class
så får vi true
dersom vi sammenligner to objekter med samme navn.
I Kotlin er man ofte opptatt av mutability
og immutability
, eller "muterbarhet" og "ikke-muterbarhet".
Disse konseptene referer til hvorvidt dataen i et objekt kan endres etter at det objektet har blitt opprettet.
En stor fordel med å gjøre ting immutable
er at koden kan bli mer lesbar og lettere å debugge. Eksempler på hvordan dette kommer frem er:
- Ett ikke-muterbart objekt du bruker kan du alltids være sikker på at ikke kommer til å endre seg og kan heller ikke bli endret noe annet sted i koden. Dette reduserer utilsiktede utfall da det er vanskeligere å påvirke annen funksjonalitet ved "uhell" dersom den funksjonaliteten også bruker samme data.
- En kan alltids spore verdier tilbake til der de ble opprettet. Dette gjør det lettere å forstå hvor i koden en verdi ble opprettet og hvor den har blitt brukt og hvor den skal brukes videre.
I standardbiblioteket til Kotlin skiller man på datastrukturer som er muterbare og de som ikke er det. F.eks:
det finnes både List<T>
og MutableList<T>
. Begge disse typene er lister, men typen List
er ikke-muterbar og lar deg dermed ikke legge til eller fjerrne
elementer fra lista. Typen MutableList<T>
lar deg bruke funksjonene add
og remove
for å legge til og fjerne elementer fra lista.
Dersom en har et objekt som en ønsker å endre på, så er det vanlig å lage en kopi av objektet og deretter utføre endringene en ønsker å gjøre. F.eks.
val tall = listOf(1,2,3)
val flereTall = tall + 4 // Dette lager en kopi av lista `tall` og legger til 4 i den kopierte lista, uten å endre på `tall`.
println(tall) // -> [1, 2, 3]
println(flereTall) // -> [1, 2, 3, 4]
Egendefinerte dataklasser har også støtte for dette gjennom copy
-funksjonen som genereres automatisk når du definerer en data class
.
Denne gir deg muligheten til å lage en kopi av et objekt med noen av feltene endret, mens de andre feltene forblir uendret.
val gaute = Person(name = "Gaute", age = 26)
val eldreGaute = gaute.copy(age = 27) // Dette lager en kopi av `gaute` som er 27 år gammel, men beholder navnet "Gaute".
Oppgavene løses i fila Mutability.kt
Oppgave 1:
- Kommenter inn linjen med
gaute.name
, og undersøk feilen du får. Hvorfor er ikke dette lov? - Hvordan kan du opprette et nytt person-objekt med samme verdi for
age
men med et annet navn?"
Oppgave 2:
Din oppgave er å få skrevet ut en liste med tallene fra lista viktigeTall
men med tallet 4 lagt på slutten.
Dette skal da gjøres uten å endre på hvordan funksjonen funkSjonalitetSomIkkeLikerTalletFire
fungerer.
- Kommenter inn linja
skrivUtTallListeMedFire(viktigeTall)
og kjør koden. Hvorfor kræsjer koden? - Skriv om koden i funksjonen
skrivUtTallListeMedFire
slik at du ikke endrer verdiene i listaviktigeTall
men fortsatt får skrevet ut lista med tallet 4 på slutten.
Løsningsforslag til oppgave 1 🤠
Linja gaute.name = "Sondre"
er ikke lov fordi name
er definert som en ikke-muterbar verdi med nøkkelordet val
og kan dermed ikke endres etter at objektet er opprettet.
Løsningsforslag:
val gaute = Person("Gaute", 26)
val sondre = gaute.copy(name = "Sondre")
println(sondre) // -> Person(name=Sondre, age=26)
Løsningsforslag til oppgave 2 🤠
Koden kræsjer fordi vi legger til tallet 4 i den muterbare lista viktigeTall
i funksjonen skrivUtTallListeMedFire
.
Dette fører til at koden kræsjer i funksjonen funkSjonalitetSomIkkeLikerTalletFire
, da den funksjonen ikke krever at lista ikke kan ikkeholde tallet 4.
Løsningsforslag:
fun skrivUtTallListeMedFire(viktigeTall: MutableList<Int>) {
val viktigeTallMedFire = viktigeTall + 4 // Lager en kopi av lista uten å endre på den originale lista.
println(viktigeTallMedFire) // -> [1, 2, 3, 4]
}
Funksjoner i kotlin defineres med fun
-nøkkelordet, og kan ha parametere og returverdier.
fun add(a: Int, b: Int): Int {
return a + b
}
Man kan også gi et parameter en defaultverdi ved å skrive = <verdi>
etter typen som dette: a: Int = 0
.
Oppgavene ligger i Funksjoner.kt
- Legg inn et parameter i funksjonen
skrivUtIntroduksjonMedHobby
slik at en kan det printes ut en valgfri hobby. - Endre hobby-parameteret slik at hobbyen 'kode kotlin' blir brukt dersom ingen annen hobby er oppgitt."
Løsningsforslag 🤠
Løsningsforslag til oppgave 1:
fun skrivUtIntroduksjonMedHobby(hobby: String) {
println("Hei! Mitt navn er $name. Jeg er glad i å $hobby")
}
Løsningsforslag til oppgave 2:
fun skrivUtIntroduksjonMedHobby(hobby: String = "kode kotlin") {
println("Hei! Mitt navn er $name. Jeg er glad i å $hobby")
}
Løsningen bruker string templates
for å sette sammen meldingen uten å bruke +
Kotlin har støtte og legger veldig til rette for bruk av funksjoner som kalles Higher Order Functions
.
Det høres kanskje litt stort ut, men en Higher Order Function
er bare en funksjon tar en eller flere funksjoner som argumenter,
eller returnerer en funksjon som resultat. Ett eksempel på en slik funksjon er map
-funksjonen som finnes på lister i Kotlin:
fun doble(tall: Int): Int {
return tall * 2
}
val tallListe = listOf(1, 2, 3)
val dobledeTall = tallListe.map ({tall -> doble(tall) }) // -> [2, 4, 6]
Her ser man hvordan map
-funksjonen tar inn funksjonen doble
, og bruker den til å transformere hvert element i listen.
For lesbarhetens skyld pleier en ofte å kombinere slike higher order functions
med funksjoner uten navn, bedre kjent somanonyme
- funksjoner eller lambda
-funksjoner.
Vi kan f.eks. skrive om koden over til å bruke en lambda
i stedet for å definere en egen funksjon:
val tallListe = listOf(1, 2, 3)
val dobledeTall = tallListe.map ({ tall -> tall * 2 }) // -> [2, 4, 6]
Standardbiblioteket til Kotlin bruker lambda ganske mye, og språket har en del syntaktisk støtte for å gjøre det enklere å bruke.
F.eks. En trenger ikke å navngi argumentene i en lambda og en kan referere til dem direkte ved å bruke it
som et standardnavn istedenfor.
listOf(1, 2, 3).map({ tall -> tall * 2 }) // [2, 4, 6]
listOf(1, 2, 3).map({ it * 2 }) // [2, 4, 6]
Og hvis en lambda er det siste argumentet til en funksjon kan en også droppe bruken av paranteser.
listOf(1, 2, 3).map { it * 2 } // [2, 4, 6]
Store deler av det vi gjør som utviklere er å hente data, manipulere den og deretter bruke den videre i applikasjonene våre. I Kotlin finnes det mange "higher order functions" som gjør dette veldig mye lettere enn i Java. Andre eksempler inkluderer da
filter
, for filtrering:
listOf(1, 2, 3).filter { it != 2 } // [1, 3]
reduce
, for å slå sammen elementer i en liste basert på en funksjon:
listOf(1, 2, 3).reduce { tall1, tall2 -> tall1 + tall2 } // Plusser sammen alle tallene i lista, og returnerer 6 (Ekvivalent til sum)
partition
, for å dele en liste i to basert på en betingelse:
val (tallUnderTo, tallMedToEllerMer) = listOf(1, 2, 3).partition { it < 2 }
println(tallUnderTo) // [1]
println(tallMedToEllerMer) // [2, 3]
any
, for å sjekke om minst ett element i en liste oppfyller en betingelse:
val inneholderTalletTo = listOf(1, 2, 3).any { it == 2 }
println(inneHolderTalletTo) // true
Kraften i bruken av slike higher order functions
og lambda-funksjoner er at de lar deg skrive kode som bryter ned oppgaver i mindre biter og dermed er lettere å lese.
F.eks. vi for å sjekke om en liste inneholder et positivt tall hvor dens kvadrat er større enn 20
listOf(-5,-2, 0, 2, 5)
.filter { it > 0 } // Filtrer ut negative tall -> [2, 5]
.map { it * it } // Kvadrer tallene -> [4, 25]}
.any { it > 20 } // Sjekk om noen av tallene er større enn 20 -> true
Oppgave:
Åpne filen som heter HighOrderFunction.kt:
- Bruk
coacher2023
-listen, og bruk lambdafunksjon(er) for å finne ut hvor mange år alle coachene i 2023 har jobbet i Bekk. - Bruk
coacher2023
-listen, og lag en liste for coachene som er i teknologi-avdelingen. - Bruk
coacher2023
-listen, og skriv kode for å lage en kopi av lista hvor Johan er i BMC-avdelingen og Ragnhild er i Design-avdelingen.
Bruk main-funksjonen til å sjekke at du får riktig resultat.
Løsningsforslag 🤠
val antallAarIBekk = coacher2023.map { it.aarIBekk }.reduce { aarIBekk1, aarIBekk2 -> aarIBekk1 + aarIBekk2 }
val teknologiCoacher = coacher2023.filter { it.avdeling == Avdeling.TEKNOLOGI }
val endredeCoacher = coacher2023.map { coach ->
when (coach.name) {
"Johan" -> coach.copy(avdeling = Avdeling.BMC)
"Ragnhild" -> coach.copy(avdeling = Avdeling.DESIGN)
else -> coach
}
}
Du kan lese mer om high order functions i Kotlin her
Her bruker vi when
-uttrykket, som ikke ble dekket i presentasjonen. Det kan du lese mer om her.
Noen ganger har vi behov for spesialtilpasset funksjonalitet på en klasse som vi ikke har tilgang til å endre, f.eks. de innebygde klassene Int
eller String
.
Da kan du skrive en spesiell type funksjon som heter "Extension Functions".
Funksjonen kan skrives på følgende måte:
fun <Klasse>.<funksjonsnavn>(<argumenter>): <return-type> {
// Gjør noe
}
For å refere til instansen av klassen bruker vi this
. Ett eksempel på en slik funksjon er:
fun List<Int>.doble(): List<Int> = this.map { it * 2 }
listOf(1,2,3).doble() // -> [2, 4, 6]
Dersom en kombinerer extension functions med higher order functions
og lambda-funksjoner kan en få veldig lesbar kode.
Ta f.eks. det tidligere eksempelet med å sjekke om en liste inneholder et positivt tall hvor kvadratet er større enn 20:
listOf(-5,-2, 0, 2, 5)
.filter { it > 0 } // Filtrer ut negative tall -> [2, 5]
.map { it * it } // Kvadrer tallene -> [4, 25]}
.any { it > 20 } // Sjekk om noen av tallene er større enn 20 -> true
Dette kan skrives om til:
val List<Int>.fjernNegativeVerdier(): List<Int> = this.filter { it > 0 }
val List<Int>.kvadrerVerdier(): List<Int> = this.map { it * it }
val List<Int>.sjekkOmListaHarEnVerdiOver(verdi: Int): List<Int> = this.any { it > verdi }
listOf(-5,-2, 0, 2, 5)
.fjernNegativeVerdier()
.kvadrerVerdier()
.sjekkOmListaHarEnVerdiOver(20) // -> true
Oppgave: Oppgavene ligger i fila ExtensionFunctions.kt.
- Lag en extension function for
List<BootcampCoach>
som returnerer bare Coacher fra en avdeling. - Lag en extension function for
List<BootcampCoach>
som skriver ut navn, antall år i Bekk og avdeling for alle bootcampcoachene. - Lag en extension function for
List<BootcampCoach>
som returnerer totalt antall år i Bekk for alle coachene i listen. - Lag en extension function for
List<BootcampCoach>
som øker antall år i Bekk for alle coachene i listen med et gitt antall år.
Løsningsforslag 🤠
// Oppgave 1
fun List<BootcampCoach>.fraAvdeling(avdeling: Avdeling): List<BootcampCoach> {
return this.filter { it.avdeling == avdeling }
}
// Oppgave 2
fun List<BootcampCoach>.skrivUtInfo() {
this.forEach { coach ->
println("${coach.name} er i avdeling ${coach.avdeling} og har jobbet ${coach.aarIBekk} år i Bekk")
}
}
// Oppgave 3
fun List<BootcampCoach>.totaltAntallAarIBekk(): Int {
return this.map { it.aarIBekk }.reduce { total, aar -> total + aar }
}
// Oppgave 4
fun List<BootcampCoach>.leggTilAar(aar: Int): List<BootcampCoach> {
return this.map { coach ->
coach.copy(
aarIBekk = coach.aarIBekk + aar
)
}
}
Du kan lese mer om extension functions i den offisielle Kotlin-dokumentasjonen.
Gratulerer med vel overstått introduksjon til Kotlin! 🎉
Nå skal vi lage et spill! Du skal styre en firkant på skjermen. Firkanten skal unngå andre firkanter som faller ned fra toppen av skjermen. Målet med oppgaven er å gjøre deg litt kjent med et par viktige konsepter som du kan ta med deg inn i de litt mer kreative oppgavene.
Skjelettet av koden er allerede skrevet - og består i hovedsak av tomme metoder som det er opp til deg å implementere i denne delen av workshopen. Vi tar det stegvis, og når alle metodene er implementert ender man opp med et ferdig spill.
Koden vi skal jobbe finner du i filen Main.kt.
Løsningsforslag ligger i fila Solution.kt, men bruk den med omhu!
Vær obs på at den inneholder løsningen på alle oppgavene, så ikke ødelegg moroa for deg selv.
Det første vi skal gjøre er å tegne noe på skjermen, og vi starter med selve spilleren
Start med å implementere metoden drawPlayer
slik at den tegner spillere som en firkant
på skjermen. Du kan fritt velge farge du ønsker å bruke.
Spilleren er definert som et Rectangle
som ligger i variabelen this.player
. Du kan
endre start posisjon og størrelse på spilleren ved å endre verdiene denne variabelen
initialiseres med.
Du kan bruke hjelpemetoden drawRectangle
for å tegne et rektangel på skjermen.
Det neste vi skal gjøre er å sørge for at man kan styre spilleren med tastaturet.
Posisjonen til spilleren er definert som en Vector2
på player
variabelen.
Du kan velge å enten endre manipulere X og Y verdiene på denne direkte eller å bruke
hjelpemetoden Rectangle.move
for å flytte spilleren. Variabelen delta
som man får
inn som argument er tiden siden forrige update, og kan brukes for å sørge for at man
får gjevn bevegelse uavhengig av update-rate. Dette kan man gjøre via å bruke den som
en faktor: val moveDistance = movementSpeed * delta
.
For å sjekke tastatur-input kan man bruke funksjonen Gdx.input.isKeyPressed
. F.eks.
kan man se om man holder nede PIL OPP
med Gdx.input.isKeyPressed(Input.Keys.UP)
.
Når man har fått spilleren til å bevege seg rundt på skjermen kan man legge til at
spilleren ikke skal få lov til å bevege seg utenfor skjermen. Bredde og høyde på skjermen
han man hente fra EngineConfig.VIEWPORT_HEIGHT
og EngineConfig.VIEWPORT_WIDTH
.
Posisjonen til spilleren er posisjonen til nedre venstre hjørne av rektangelet, og det kan være fint å ta høyde for størrelsen på rektangelet når man skal holde spilleren innenfor skjermen.
For at det skal bli et spill må det noe mer gameplay på plass. Så her er tanken at vi skal ha noen bokser på starter på toppen av skjermen og "faller" nedover, og så er målet å unngå å bli truffet av disse. For å få dette på plass må følgende ting implementeres:
shouldSpawnNewBlocks
: Denne metoden skal returnere true om det skal lages flere blokker for spilleren å unngå.spawnNewBlock
Her skal den lage ny blokk(er). De nye blokkene skal ha en posisjon på toppen av skjermen, og et tilfeldig X-koordinat. Den nye blokken skal legges i listenblocksToDodge
.drawAllBlocksToDodge
: Denne skal tegne alle blokkene som ligger ithis.blocksToDodge
. Dette kan gjøres ganske likt som tegning av spilleren. Men bruk gjerne en annen farge.handleMoveBlocks
: Her flytter man blokkene nedover på skjermen. I starten er det greit å bare flytte de med en konstant fart.
Om du starter main-metoden skal man når ting er implementert korrekt se at det faller ned noen blokker fra toppen av skjermen. Men om styrer figuren din inn i en av de vil du se at det ikke skjer noen ting. Det er som og blokkene ikke er der. Så det neste vi må få på plass er en enkel kollisjonstest.
Om man ser ser i update
metoden har den en sjekk på playerIsColliding
. Hvis denne returnerer
true så kaller den onGameLost()
som resetter spillet. Så målet nå er å implementere en sjekk i
playerIsColliding
som sjekker om spilleren har kollidert med en av blokkene. Rectangle.isCollidingWith
kan brukes for å sjekke om 2 rektangler overlapper.
Slik spillet er implementert nå vil det gjevnlig legges til nye blokker i listen blocksToDodge
,
men de fjernes aldri. Over tid vil dette påvirke ytelsen både i form av at den bruker mer og mer minne
men også at den må gjøre operasjoner på fler og fler elementer som ikke lenger er relevante. Vi trenger
derfor en måte å rydde opp.
I update
-metoden så kaller vi removeBlocksOutOfBounds()
. Tanken er at denne skal fjerne alle blokker
som er utenfor skjermen, og derfor ikke lenger er relevante for spillet.
Vi kan nå gå videre med å legge til litt fler kule features i spillet. Her er det bare å bruke kreativiteten. Men under
kommer det noen forslag til ting man kan legge til. Se gjerne på ting i examples
-mappen for inspirasjon til flere ting
man kan gjøre.
Implementere en måte å gi en score til spilleren som vises når de taper. F.eks. kan scored være hvor mange sekunder man klarte seg. F.eks. kan denne vises midt på skjermen etter man tapte og så må man trykke på en knapp for å starte på nytt.
En vanlig ting i slike spill er at vanskelighetsgraden øker over tid. Her er det mange ting man kan vurdere, som f.eks. at man det kommer fler og fler blokker over tid. Eller at de beveger seg raskere. Kanskje de etterhvert også har ulik størrelse og fart?
Firkanter kan være litt kjedelig. Hva om man bytter ut firkantene med noen kule bilder i stedet? Her kan man se på eksempelkoden MovingGraphicModule.kt for hvordan man kan tegne grafikk på skjermen.
Til slutt har vi en litt åpen oppgave, hvor du kan lage ditt helt eget spill. Bygg på det du lærte fra de andre oppgavene og se om du klarer å lage ett lite spill fra scratch. Ta gjerne kontakt med coachene om du sitter fast eller trenger hjelp.
Forslag kan være å se på å lage noe som gamle klassikere som Pong eller Breakout. Eventuelt kanskje man vil forsøke seg på en egen Flappy Bird?
For å komme i gang kan du starte med å lage en fil som inneholder koden under.
fun main() {
Lwjgl3Application(AppRunner { MyGame() }, config)
}
class MyGame: AppModule {
override fun update(delta: Float) {
}
override fun draw(delta: Float) {
}
}