2.2. Mäng 2: 2D platformer

Selles peatükis lood 2D platvormeri malli, mida saad edasi arendada vastavalt oma soovile. Põhiline uus oskus siit peatükist on Tilemapi ja tileseti kasutamine. Karakterite animatsioonide tegemise õpetused tulevad järgmises osas.

Valmiva mängu eelvaade

game 2 preview.mov

Super Mariost inspireeritud spraitidega 2D mängu mall. 

Uued vajaminevad assetid, funktsioonid jms

Tileset

Tileset on fikseeritud ruudustikus olevad spraidid, mida saab eraldada Unity Sprite Editori abil ning millest saab Unity Tilemap abil tasemeid disainida. Tegin lihtsa, tugevalt Super Mariost inspireeritud tileseti, millel iga üksuse (1 ruudu jm., pool lihasööjataime) suurus on 16x16.  Tegelased tulevad...

The Tilemap component is a system which stores and handles Tile Assets for creating 2D levels. 

Ruudukesed ehk tilesid, tuleb Unity Sprite Editori abil tilesetist lõikuda.

Soovitatav vabavara Unity Asset Store'ist

Sunny Land on kõige populaarsem tasuta 2D vabara Unity Asset Store'is.

Pixel Adventure 1 on tasuta vabavara package, mis pakub suurt hulka plaate maastiku ehitamiseks (tiles), objekte (takistusi,  õhukesi platvorme jms), karaktereid ja nende animatsioone.

Juhend

1.1 Esimesed seadistused

Unity Editoris mine Edit --> Project Settings --> Quality --> Rendering ning sea Anti Aliasing = disabled. Vastasel juhul võivad tekkida läbipaistvad jooned.

Loo kapsel, millest saab mängija (hierarhia --> parem klikk --> Create --> 2D Object --> Capsule) ning kast, millest saab esimene platvorm (hierarhia --> parem klikk --> Create --> 2D Object --> Square). Skaleeri kasti x-suunas nii, et see meenutaks platvormi. Lisa mõlemale objektile inspektormenüüs Collider2D ( kapslile Capsule Collider 2D ja platvormikastile Box Collider 2D). Lisa kapslile Rigidbody2D. Tekitada uus tag nimega "Ground", mis panna peale platvormile. 

3. Loo mängijale liikumise skript

Loo mängijale uus skript (näiteks nimega PlayerController).

Skriptis olgu "avalikud" ujukomaarvust (public float) väärtused kiirus ja hüppekiirus:

public float speed;

public float jumpSpeed;

Deklareeri veel ujukomaarv-tüüpi "privaatsed" muutujad inputX (horisontaalne sisend, liikumine vasakule ja paremale), inputY (liikumine redelil) ja boolean-tüüpi muutuja onGround (ütleb kas tegelane on maapinna peal), mille võib kohe defineerida ebatõesena (bool onGround = false;):

float InputX, InputY;

bool onGround;

Deklareerida ka Rigidbody2D komponent (Rigidbody2D rb;), mida Start funktsioonis defineerida:

3.1 Horisontaalse liikumise programmeerimine

Update() funktsiooni nurgelistesse sulgudesse panna ujukomaarv inputX väärtuseks klaviatuuri "horisontaaltelje" (vaikimisi klahvid "A" ja "D", vasak ja parem nool) sisend:

inputX = Input.GetAxis("Horizontal");

Analoogselt teha seda ka inputY-iga (tuleb kasutusele hiljem):

inputY = Input.GetAxis("Vertical");

Input.GetAxis on funktsioon, mis annab klaviatuurisisenditelje väärtuse ajaliselt interpoleerituna see tähendab, et kui kasutaja vajutab näiteks klahvi "D", väljastab see funktsioon Input Manageris defineeritud ajalises vahemikus ujukomaarvu väärtused 0f...1f näiteks 0.5 sekundi jooksul ning kui kasutaja vajutab klahvi "A" siis see funktsioon teeb sama, aga vastasmärgiliselt. 

Input.GetAxisRaw on alternatiiv, mis ajaliselt ei interpoleeri, seega kohe kui kasutaja vajutab klahvi, on selle funktsiooni väljund 1 või -1.

Need mõlemad funktsioonid väljastavad 0-i kui mõlemat telje klahvi hoitakse korraga peal.

Edit --> Project Settings --> Input alt saab leida Input Manageri, kus saab muuta ja seadistada klaviatuuri sisendi telgesid, seal on vaikimisi olemas teljed "Horizontal" ja "Vertical".

Skripti lisada juurde lisaks Update funktsioonile void FixedUpdate meetod ja selle nurgeliste sulgude sisse objekti rigidbody x-suunalist kiirust modifitseeriv rida:

rb.velocity = new Vector2(inputX * speed, rb.velocity.y);

Nüüd peaks liikumine platvormil vasakule ja paremale töötama. Ära unusta inspektoris skripti aknas seada väärtused muutujatele nimega speed ja hiljem jumpSpeed. Kui mängija kapsel kukub külili, tuleb mängija RigidBody2D komponendis inspektormenüüs Constraints --> Rotation --> Z seadma vääraks. Kui kaamera vaateväljast liigub kiirest eemale, võib esialgu hierarhias aheldada kaamera mängija külge. 

3.2 Hüppamise programmeerimine

Kuna see hüppamiseks vajalik y-sihilise kiiruse muutus saab toimuda ühe kaadri vältel, võib hüppamist programmeerida Update funktsioonis.  Hüppamine olgu, nagu tavaliselt, klahviga "Space", millele saab argumendis viidata KeyCode.Space-iga või InputManageris defineerida string "Jump" ning seada selle klahviks "Space". Input.GetKeyDown väljastab tõese boolean väärtuse selle kaadri jooksul, mil seda klahvi vajutatakse. Selle alternatiiv Input.GetKey väljastab kõikide kaadrite jooksul, mil seda nuppu on vajutatud. Hüppamise jaoks on vaja esimest ning on vaja, et onGround ("onMaaPeal" muutuja oleks tõene):

Selleks, et kontrollida mängija kokkupuudet maapinnaga on kaks moodust: 1) kasutada OnCollisionEnter2D (OnCollisionEnter2D is called when this collider2D/rigidbody2D has begun touching another rigidbody2D/collider2D.) funktsiooni 2) kasutada FixedUpdate funktsioonis Physics2D.CircleCasti või Physics2D.RayCasti funktsiooni, mis iga kaadri kestel tekitab kujuteldava kiire või rõnga, mis kontrollib, kas see kiir või rõngas lõikub mõne collideriga

1) OnCollisionEnter2D on kõige lihtsam üles seada, kuid on ebatäpne. Näiteks kui "Ground" tagiga objekt on mängija vastas (külje pealt kui mängija on vastu seina), on onGround muutuja tõene ning saab hüpata, kuigi see ei pruugi olla arendaja kavatsus. Kuna OnCollisionEnter töötab ainult siis, kui mingi colliderite-vaheline kokkupõrge on toimumas, siis if-else lause OnCollisionEnteriga ei töötaks. Tuleb lisaks kasutada OnCollisionExit funktsiooni, mis töötab analoogselt, aga siis, kui koos olevad kokku põrkanud colliderid lahknevad:

2) Kokkupõrke tuvastamisele alternatiivne viis on luua stseenis uus Transform komponentiga objekt (hierarhias parem klikk --> Create --> Empty Game Object) ning panna see hierarhias mängija külge ning stseenis mängija "jalgade" kõrgusele. Sellele tuleb teha viide mängija skriptis: 

Transform groundDetector;

Edaspidiselt tuleb selle koordinaatidelt saata allapoole detektorkiir, kasutades FixedUpdate event functionis funktsiooni Physics2D.Raycast, mis väljastab esialgu boolean väärtuse (kas läheb vastu colliderit või mitte) ning millelt saab küsida collideri nimetust, millega see kiir lõikub. Raycast katkeb esimese collideri ees, millega see kiir pihta saab, seega tuleb Physics2D.Raycast() funktsiooni neljandaks argumendiks, mis on layerMask ehk mängu füüsikakiht, mida see kiir ignoreerib, asetada mängijale. Selleks tuleb stseenis mängija inspektormenüüs seada mängijale uus kiht ehk Layer. Olgu see Layer indeksiga 3 ning nimetusega "Player" ("Mängija"). (Skriptis saab viidata sellele indeksiga 3 või funktsiooniga LayerMask.NameToLayer("Player") kui pole indeks teada, kuid on selle nimetus teada)

Enne Raycasti funktsiooni kirjutamist koodis, tuleb selgeks teha kõik funktsiooni argumendid; Scripting API dokumentatsiooni järgi: 

Raycast(Vector3 origin, Vector3 direction, float maxDistance = Mathf.Infinity, int layerMask = DefaultRaycastLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);

Viimased neli parameetrit on vaikimisi defineeritud, kuid me peame kolmanda ja neljanda ümber defineerima, sest vastavalt vastasel juhul saab hüpata lõpmatuseni ja/või mängija collider katkestab detektorkiire. Olgu skripti alguses defineeritud ujukomaarv, mis väljendub detektorkiire pikkusena:

public float detectorRange;

Kokkuvõtteks tuleb FixedUpdate funktsioonis defineerida uus Raycast komponendi instants ning panna see kohe võrduma järgnevaga:

var hit = Physics2D.Raycast(groundDetector.position, Vector2.down, detectorRange, LayerMask.NameToLayer("Player"));

var võib kirjutada, kui pole kindel mis tüüpi muutujaga tegu on. Antud juhul RaycastHit2D, mis väljendub boolina, kuid on klass, mille üks propertitest on collider, millele pääseb ligi suffixiga .collider. Viimast teades, saab kirjutada järgmise if-lause: if(hit && hit.collider.tag == "Ground"){ ... }. Kokku tuleb teise maapinna tuvastuse alternatiiv FixedUpdate'is järgmine:

4. Redelid

Tee veel üks platvormiga analoogne kast (hierarhias parem klikk Create --> ...), millel oleks Collider2D komponent ja mis oleks skaleeritud suuremaks y-telje sihis. Lisa sellele uus tag  nimetusega "Ladder" ning selle collideri isTrigger sea inspektoris true peale, sest vastasel juhul tekiksid redeliga ainult kokkupõrked. 

4.1 Redelil olemise tuvastus koodis

Mängija liikumisskripti lisa uus (privaatne) boolean väärtus, mis viitaks redelil olemisele onLadder:
bool onLadder = false;

Kuna redeli collideril kokkupõrked ei simuleeru (sest selle Collider.isTrigger = true), siis lisada juurde uued event functionid OnTriggerEnter2D ja OnTriggerExit2D, sest OnCollision... trigger-tüüpi colliderite peal ei tööta (ning vastupidiselt OnTrigger... tavaliste colliderite peal ei tööta). Vastavalt nende event functionite nurgeliste sulgude sisse lisa if-laused, mis oleksid analoogsed üle-eelneva koodilõigu näitega.  Pane tähele, et erinevalt OnCollision...-ist on selle event functioni väljastatav argument on Collider2D mitte Collision2D:

4.2 Redelil liikumise ning redelil olemise piirangute programmeerimine

Redelil peaks olema võimalik liikuda nii x- kui ka y-suunas ning redelil ei tohiks olla võimalik hüpata (kui just mitte redelilt maha hüpata) ega redelil ei peaks simuleerima Rigidbody2D gravitatsioonikiirendus (rb.isDynamic = false).

Selleks tuleb panna Update funktsioonis hüppamine ning FixedUpdate funktsioonis horisontaalne liikumine if-lause loogeliste sulgude sisse ning selle if-lause argumendiks peab olema redelil olemist kontrolliv boolean: 

if(onLadder == false) { // horisontaalset liikumist või hüppamist teostav kood (NB! mitte sisend) }

Kui tavapärane liikumine on redelil olekust tingitult "lukus", tuleb redelil liikumine eraldi ridadega tekitada. Kõik uued redelil liikumisega seotud read tuleb panna if-lausesesse, mille argumendi tingimuseks on redelil oleku tõesus (onLadder == true, if-lause argumendis lihtsalt: if(onLadder) või if(onLadder){ ... } else { ... }). Kui on soov kasutada mingit muud kiirust redelil liikumiseks, võib järgnevas koodis korrutada tavakiiruse mingi koefitsiendiga (0.5f * speed) või tekitada algusesse uus "avalik" float muutuja (public float onLadderSpeed;). Järgnev kood kasutab esimest viisi. Järgnev koodlilõik on osa FixedUpdate event funktsioonist ning arvestab ka vastase juhuga (!onLadder ehk mitte redelil):

Rigidbody2D.gravityScale, mis eelnevas näites, on üks paljudest viisidest gravitatsiooni skaleerimise jaoks (vaikimisi on selle väärtus 1). Teised viisid on Rigidbody2D.isKinematic ja Rigidbody2D.isDynamic booleanide muutmine.

On hea viis kuidas praeguseni valminud skripti optimeerida: rb.gravityScale saab antud juhul FixedUpdate-is seatud põhimõtteliselt iga kaadri (fikseeritud füüsikakaadri) vältel väärtuse. Samas seda pole vaja iga kaader teha. Piisab rb.gravityScale väärtuse vahetamisest nende kaadrite kestel, mil void OnTrigger... event functionid, mis praeguses skriptis redelil olekut kontrollivad, töötavad.  Järgnev koodilõik on viimast osa kokkuvõttev:

5. Leveli ehitamine: Grid, Tilemap ja Tile Palette

Lisa hierarhiasse komponent nimega Tilemap (parem klikk --> Create --> 2D --> Tileset). Hierarhiasse tekib komponent objekt Grid, mis väljendub stseenivaates ruudustikuna ning, mille alamobjekt on hierarhias Tileset, mida võib ümber nimetada. 

Edasise selguse nimel duplikeeri see Tilemap ning nimeta üks ümber Groundiks ja teine Ladderiks ning anna neile vastava nimetusega tagid. Lisa mõlemale Tilemapile nende inspektoris komponent TilemapCollider2D, Rigidbody2D ja CompositeCollider. komponent ning sea inspektoris Rigidbody2D sektsioonis Body Type Kinematic'ule.  TilemapCollider2D inspektoris lisada linnuke Used By Composite alla.

5.1 Spraitide importimine ja seadistamine

Editori akna alumises osas Project aknas on kaust nimega Assets. Sinna lisada (vedada) spraitide pildifailid (lehe alguses antud tileset ja tegelased) (vt gifi). 

Nende failide peale vajutanud, tee Inspector menüüs järgmised seadistused: Compression = None ja Pixels Per Unit = 16 --> Apply (ehk 16 pikslit vastab ühele "meetrile"; antud spraitide puhul 16 sobiv) (vt järgmist vasemat pilti). Pildifailidele, mis sisaldavad mitu spraiti (tileset, animatsiooni kaadrid), tee järgmine seadistus: Sprite Mode = Multiple --> Apply ning ava Sprite Editor  --> Slice --> By Grid Size --> x = 16 ja y = 16  --> Apply (jällegi, sest antud spritesheet on tehtud 16x16-ühikuliste pikslitega; mõni muu pixel-art teos Asset Store'ist võib olla teise suurusega, nagu 8x8, 32x32 vms) (vt järgmist paremat pilti). 

5.2 Tile Palette ja maailma loomine

Unitys Window --> 2D --> Tile Palette avab ruudupaleti akna, mida vaja Gridis oleva "Tilemapi" värvimisel. Selles aknas vali Create New Tile Palette ning vali sobiv kaust. Soovituslik on Assets kausta teha eraldi kaust, kuhu panna ruudupalett ning sinna hiljem panna sellega seonduvad spraitide duplikaadid. Project aknast Asset kaustast vali lõigutud spritesheeti fail (st eelmisest alapunktikesest sliced) ning vea see Tile Palette aknasse.  Nüüd on olemas Tile Palette, millest saab võtta "ehituskivisid" maailma loomiseks. Varasemalt loodud Tilemap'e, mida joonistada, valida Tile Palette'i aknas-- see tähendab, et eraldi tuleb valida, kas joonistada maad (Tilemap = "Ground") või redelit (Tilemap = "Ladder"). Siis tuleb vastavalt valida ka sobivad ruudud ruudupaletis.

Ruudupaleti kasutamise peamised töövahendid, nagu harilikul digitaalkunstiloomevahendil" on "pintsel" (kolmas sümbol, klahv "B") ning pipett (viies sümbol, klahv "I")

Redel ei pea ilmtingimata olema ainult redeli ruudukestest koosnev. Näiteks praeguses näites on redeli ots peidetud, nagu Super Marios või teistes platvormerites on osad kohad justkui seinad, aga sellegipoolest ligipääsetavad.

Mis edasi?

Nüüd on olemas põhiline 2D platvormeri mall, mida saab oma kujutlusvõimele vastavalt mänguks arendada. Vastavalt sellele, mis valikuid Sa teed võib veel mingeid asju vaja juurde õppida (vt järgmist alapunkti). Lisaks Super Mario taolistele mängudele on 2D platvormereid ka teistsuguseid. Näiteks nagu Celeste, mis on nö skill-based platformer ehk selle tasemed on tehniliselt rasked. Celeste-taolise mängu tegemiseks on vaja teha mängitavale karakterile mugav ja sujuv sisendi-liikumise süsteem (coyote time, muudetud kukkumise kõrguse-aja kõver, libisemine jms). Celeste'st vähem tehnilist pilotaaži nõuavad igasugused story-mängud ja RPG-d, mida saab 2D mallist arendada. 

Kõik need ilmselt vajavad interaktiivseid elemente, mis nõuavad skripte.

Mängu disaini elemendid mida lisada

Järgneb loetelu asjadest, mida 2D-mängus võib kohata. Esialgu lisan ainult märksõnad, kuid ehk hiljem ka õpetused mõnede tegemise jaoks.