Bookmark and Share

Svårighetsgrad
Svårighetsgrad
Betyg (6 röster)
BetygBetygBetygBetygBetyg
 

En enkel "tilemotor"

Inledning

En "tilemotor" (tile engine) passar utmärkt till 2D-spel som använder banor. Tekniken bakom banorna i spel som t.ex. Zelda och Super Mario bygger på "tiles" (plattor eller rutor). Banorna byggs upp av grafikbitar, ofta kvadratiska, som upprepas.

bild

Bilden ovan är ett exempel på hur "tiles" kan användas för att bygga upp en bana. Bitarna 1 och 2 är identiska, likaså 3 och 4 samt 5-7. Tillsammans kan ett fåtal "tiles" bygga upp en enorm bana. Det betyder att den totala mängd grafik som används blir liten vilket också ger en historisk förklaringen då äldre spelmaskiner hade mycket begränsade minnesresurser.

Vi skall i denna artikel bygga en "tilemotor" som ritar upp en bana med hjälp av tiles. Motorn skall kunna scrolla steglöst över banan i alla riktningar samt zoom'a in och ut. Exempel kan ses i video.

Filmklippen som presenteras här kräver en nyare version av Adobe Flash Player. Du behöver även ha javaScript aktiverat i din webbläsare. Behöver du Flash Player så kan du ladda hem den här: Adobe Flash player.

TileEngine.cs

Vi börjar med att skapa en ny klass TileEngine i en separat fil (TileEngine.cs) i projektet. Det är några saker vi behöver för att kunna rita ut en bana:

Vi väljer att göra vår TileEngine av en GameComponent främst av en anledning; Vi vet inte när eller om skärmupplösningen kommer att ändras under spelets gång. Det smidigaste är då att ärva från GameComponent vilket gör att vi kan registrera oss som en del av spelets komponenter (rad 30). Varje gång grafikkortet konfigureras om så anropas Initialize() automatiskt. Registreringen sköter vi i konstruktorn (rad 28) som kräver att vi skickar med det spel som vi vill använda vår "tilemotor" i.

Storleken på skärmen sparar vi internt i klassen i variablarna viewportWidth och viewportHeight i metoden Initialize(). Detta garanterar att vi har rätt information även om skärmupplösningen ändras under tiden spelet är igång.

Övrig information blir egenskaper (properties) i klassen. Brickornas storlek lagras i TileWidth och TileHeight. Datan sätts via en tvådimensionell array av heltal, Data. Positionen på kartan döper vi till CameraPosition som är av typen Vector2. Grafiken sätter vi genom TileMap.

Deklarerar vi egenskaper med endast tomma set; och get; (exempel rad 12) så behövs inga privata variabler, egenskapen finns i klassen ändå. Detta är ett effektivt sätt att skriva egenskaper.

Vi lade även till en tom Draw-metod i klassen som vi senare skall gå igenom. I nulägen så är det alltså bara ett skal.

Använda motorn

Vi tar ett kort avbrott från motorn och tittar i stället på vad vi behöver göra i vårt spel.

Vi behöver tile grafik. För enkelhetens skull använder vi endast två rutor. En bild med en massa tiles brukar kallas för tile map eller tile sheet på engelska. Detta får bli vår tile map:

bild

Bilden finns i kommande projekt som går att ladda ned. Storleken vi valt i detta exempel är 32 x 32 pixlar. Försök gärna välja en storlek som är en jämn i basen 2, t.ex. 8, 16, 32, 64, 128 etc. Det är absolut inget krav men det kan öka prestandan.

Vi deklarerar en tileEngine av typen TileEngine i Game1.cs (rad 18). På rad 22 skapar vi ett objekt av typen TileEngine och skickar även med spelet (this) för att registreringen ska gå bra. Vi sätter lite egenskaper i metoden Initialize(), bl.a. storleken på brickorna (32 x 32) samt datan som skall användas som bana. Siffran 0 kommer att representera väggar och siffran 1 golv. Siffran i data är alltså ett index som bestämmer den tile som kommer att användas vid uppritning.

Grafiken laddar vi i LoadContent och sätter samtidigt egenskapen TileMap i tileEngine.

I Update har vi lagt in lite logik som styr kameran. Genom att kolla vilka knappar som är nedtryckta så flyttar vi kameran upp, ned, vänster och höger.

I Draw ser vi till att spriteBatch är redo och skickar både gameTime och spriteBatch vidare till TileEngine och låter klassen själv sköta sin uppritning.

Nu återstår bara att gå igenom uppritningen innan vi kan provköra något.

Uppritning utan zoom

Det är dags att komplettera metoden Draw i TileEngine.

Först gör vi en enkel koll så att vi verkligen kan rita. För det krävs att vi gett tilemotorn grafik och någon data.

Nästa steg är att beräkna centrum på skärmen (screenCenterX, screenCenterY). Vi räknar allt i pixlar. Kamerapositionen skall vara centrum på skärmen. Låt oss säga att kamerapositionen är (0,0) dvs. längst upp i vänstra hörnet av banan och att skärmens upplösning är 640 x 480 pixlar. Vilken tile skall vi börja rita?

Vi beräknar först startX, startY som motsvarar de index i Data som skall börja ritas ut. startX blir alltså (0 - 320) / 32 = -10. Alltså 10 rutor utanför banan! Det är OK eftersom banans första tile skall ritas ut mitt på skärmen när kamerapositionen är (0,0).

Nästa steg är att beräkna endX, endY som motsvarar de index i Data där uppritningen slutar. Vi vill absolut inte rita tiles i onödan. Är de utanför skärmen så behövs de inte ritas ut. Hur många tiles får plats på skärmen då? Jo viewportWidth / TileWidth i bredden, i vårt exempel 640 / 32 = 20. Nu lägger vi till en extra ruta p.g.a. att 20 rutor inte alltid räcker till. Det är bara när uppritningen precis börjar jämnt på en ruta som 20 rutor räcker. Vi behöver en extra ruta när vi börjar panorera över kartan. Alltså blir endX = (-10 + 640 / 32) + 1 = 11.

Det som sker näst är en 2-dimensionell loop som loop'ar över varje tile som skall ritas. Först i y-led och innerst i x-led. Vi har även lagt till en spärr så att varken startX eller startY kan vara under 0. Det är ingen mening med att loop'a index som ändå är utanför kartan och som inte resulterar i någon uppritning.

Vi loop'ar från startX till endX i x-led och motsvarande i y-led. Samtidigt har vi lagt till ett villkor i loop'en som bygger på Data.GetLength(dimension), detta för att förhindra att vi försöker rita "utanför" banan, något som annars skulle ge IndexOutOfRangeException. Vi skulle kunna undersöka endX, endY på samma sätt som vi gjorde med startX, startY innan vi går in i loop'en. Det är en smaksak.

Vi beräknar tilesPerLine utifrån grafikens bredd och storleken på varje tile. Denna variabel används senare i beräkningen på vilken grafik från TileMap som skall användas. Liknande beräkning beskrivs i artikeln om animationer, läs gärna mer om den där.

Inne i loop'en börjar vi att beräkna positionen på den tile vi precis skall rita ut. Positionen (x, y) gånger rutans storlek ger pixelpositionen men vi måste även kompensera för skärmens storlek och kamerans position. Med samma siffror som i föregående exempel kan vi testa (startX, startY) = (0,0). Detta get position.X = (0 x 32 - 0 + 320) = 320 vilket blir mitt på skärmen. endX = -10 (som inte ritas ut) hade gett position 0 i x-led. endX = 11 ger position 672, en tile utanför skärmen.

Nästa steg beräknar en rektangel som beskriver vilken grafik vi skall använda från TileMap för det aktuella index vi befinner oss vid uppritningen. Denna teknik beskrivs mer i artikeln om animationer.

Till sist så ritar vi ut med hjälp av spriteBatch. Det är nu dags att testa motorn, den är inte klar än men det mesta är gjort nu.

bild

Uppritning med zoom

Att kunna zoom'a in och ut i banan är inte bara snyggt utan kan även ge en extra dimension i spelet. Säg att vi styr en figur. Om vi rör oss sakta så zoom'ar vi in men om vi rör oss snabbt så zoom'ar vi ut lite grann. Eller kanske man kan tänka sig att man zoom'ar in när man tar skada i spelet. I vilket fall så börjar vi med att lägga in egenskapen Zoom i TileEngine. Samtidigt passar vi på att föra in en Max- och MinZoom.

I egenskapen Zoom lägger vi in logik så att vi inte kan zoom'a mer än max och inte mindre än min. Vi lägger nu även in lite syandardvärden på egenskaperna i Initialize.

Innan vi skriver om vår Draw-metod så lägger vi in lite logik i Game1.cs så att vi kan styra vår zoom. Jag har valt knapparna "Page Up" och "Page Down". Ändringarna skall in i metoden Update under övrig hantering av knappar.

Då var det dags för en uppdaterad variant av Draw som beräknar zoom.

Det är egentligen ett fåtal ändringar bara som gjorts. Vi skapar två nya variabler zoomTileWidth, zoomTileHeight som är vi multiplicerar med en zoom-faktor. Dessa använder vi framöver i alla beräningar istället för TileWidth, TileHeight som användes tidigare. På så sätt ritar vi aldrig ut några tiles i onödan.

Vi inför även zoomCameraPosition för att beräkna kamerans position korrekt. zoomCameraPosition används i alla beräningar där tidigare CameraPosition användes. Tidigare använde vi pixel-värden som koordinater i vår "värld". Nu beräknar vi om alla pixel-värden med en zoom-faktor. Skärmens bredd och höjd påverkas inte av vår zoom.

Till sist lägger vi till parametern Zoom i anropet Draw till vår spriteBatch (parameter nummer 7). Det är nu dags att provköra med zoom!

Problem i uppritningen

Som du nu kanske upptäckt så ser det inte så snyggt ut när vi zoom'ar in/ut. Det uppstår "sprickor" mellan våra tiles. Detta är ett vanligt problem som många har svårt att förstå, därför ägnar vi ett helt avsnitt åt att förklara detta fenomen. Nedan visar en bild av problemet.

bild

Det finns många diskussioner och bloggar på nätet som försöker lösa detta problem varav många angriper problemet på fel sätt. Problemet är inte att vi använder float i uppritningen. Problemet ligger i att texturerna (grafiken) filtreras när den ritas ut. Störningarna i kanterna kommer från intilliggande grafik i TileMap. Det kan man testa genom att rita lite i grafiken.

Det som är problemet är den linjära filtrering som används vi upp- och nedskalning av grafiken. Det enklaste sättet att bota problemet är att stänga av filtreringen vid uppritning. Inställningen kan göras per SpriteBatch så vi ändrar i Game1.cs i metoden Draw

Lösning 1

Den 3:dje parametern, SamplerState, anger vilken typ av filtrering som skall användas. PointClamp använder "nearest point" vid skalning vilket är synonymt med ingen filtrering alls. Resultatet blir nu:

bild

"Störningarna" är nu borta men skalningen blir väldigt "pixlig". Detta kan passa vissa sorters spel och till och med ge en härlig "retro"-känsla men denna lösning passar inte alla.

Lösning 2

Återställ ändringar du gjort i "lösning 1". Denna gång vill vi ha filtrering.

Nästa lösning tillåter oss att använda vilken filtrering vi vill. Problemet med filtreringen är att Direct X (som XNA använder) inte respekterar den rektangel av grafik som vi vill ha. Dvs. när vi har en tilemap så "läcker" grafik från intilliggande tiles i grafiken. Även en tile på kanten "läcker" runt till andra sidan på bilden. Utan att gå in på djupet i filter och drivrutiner så presenterar vi nästa lösning:

Dela upp din tilemap i flera texturer (Texture2D), en för varje tile. Det låter kanske obekvämt? Det är det! Därför skriver vi en hjälpklass som automatiskt delar upp en större tile map till mindre delar.

Vi kommer inte närmare att gå in på koden annat än att säga att den delar upp en textur till flera mindre texturer beroende på vilken storlek man vill ha.

Vi tittar istället på hur vi använder TextureTool. Först ändrar vi i TileEngine.cs och egenskapen TileMap

Vi inför en array av Texture2D som vi kallar tiles. När vi uppdaterar TileMap så anropar vi TextureTool Split för att dela upp alla tiles och få dem i en array istället.

Sedan ändrar vi uppritningen en sista gång. Vi behöver inte längre beräkna en rektangel, vi kan nu använda tiles direkt. Den inre loop'en i TileEngine Draw blir alltså:

Resultatet blir:

bild

Slutligen finns den korrekta versionen att ladda hem

Kommentarer

8 inlägg