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.
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.
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:
- Data, vi måste veta hur banan ser ut
- Grafik till våra "tiles"
- Storleken för en enskild "tile" men även skärmens storlek
- Positionen, vart tittar vi? Vi behöver en position som anger centrum för uppritningen.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework;
namespace EnkelTilemotor
{
class TileEngine : GameComponent
{
public int TileWidth { get; set; }
public int TileHeight { get; set; }
public int[,] Data { get; set; }
public Texture2D TileMap { get; set; }
public Vector2 CameraPosition { get; set; }
private int viewportWidth, viewportHeight;
public override void Initialize()
{
viewportWidth = Game.GraphicsDevice.Viewport.Width;
viewportHeight = Game.GraphicsDevice.Viewport.Height;
base.Initialize();
}
public TileEngine(Game game) : base(game)
{
game.Components.Add(this);
CameraPosition = Vector2.Zero;
}
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
{
}
}
}
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:
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.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Media;
namespace EnkelTilemotor
{
public class Game1 : Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;
TileEngine tileEngine;
public Game1()
{
tileEngine = new TileEngine(this);
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
}
protected override void Initialize()
{
tileEngine.TileHeight = 32;
tileEngine.TileWidth = 32;
tileEngine.Data = new int[,]
{{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0},
{0,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,0},
{0,1,0,0,1,0,0,0,0,0,1,0,1,1,1,0,0,0,1,0,0,1,0,0,1,1,1,0},
{0,1,0,0,1,1,1,1,1,0,1,0,1,1,1,0,0,0,1,0,0,1,0,0,1,1,1,0},
{0,1,0,0,1,0,0,0,0,0,1,0,1,1,1,0,0,1,1,0,0,1,0,0,1,1,1,0},
{0,1,0,1,1,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,1,0,0,0,1,1,0},
{0,1,0,1,1,1,1,1,0,0,0,0,0,1,0,1,1,1,1,1,1,1,0,1,0,1,1,0},
{0,1,0,0,1,0,0,1,1,1,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,1,0,0},
{0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,1,1,1,1,0,1,0,0},
{0,1,1,1,0,1,1,1,1,1,1,0,0,1,0,1,1,1,1,1,1,0,0,1,0,1,1,0},
{0,1,1,1,0,1,0,0,0,0,1,0,0,1,0,1,0,1,1,1,1,0,0,1,0,0,1,0},
{0,1,1,1,0,1,0,1,1,1,1,0,0,1,0,1,0,0,0,0,0,0,1,1,1,0,1,0},
{0,1,1,1,0,1,0,1,0,0,0,0,0,1,0,1,0,1,1,1,0,0,0,1,0,0,1,0},
{0,1,1,1,0,1,0,1,1,1,1,1,0,1,0,1,0,1,0,1,0,0,0,1,0,0,1,0},
{0,1,1,1,0,1,0,0,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,0,1,0},
{0,1,0,0,0,1,0,1,1,1,1,1,0,1,0,1,0,1,0,1,0,1,0,0,0,0,1,0},
{0,1,0,0,0,1,0,1,0,0,0,0,0,1,0,1,0,1,0,1,1,1,1,1,0,0,1,0},
{0,1,0,0,0,1,0,1,1,1,1,1,1,1,0,0,0,1,0,0,1,1,0,1,0,1,1,0},
{0,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,0,0,1,1,0,1,1,1,0,0},
{0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}};
base.Initialize();
}
protected override void LoadContent()
{
spriteBatch = new SpriteBatch(GraphicsDevice);
tileEngine.TileMap = Content.Load<Texture2D>("tilemap");
}
protected override void UnloadContent()
{
}
protected override void Update(GameTime gameTime)
{
// Allows the game to exit
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
this.Exit();
KeyboardState state = Keyboard.GetState();
if (state.IsKeyDown(Keys.Down))
tileEngine.CameraPosition += new Vector2(0, 2.0f);
if (state.IsKeyDown(Keys.Up))
tileEngine.CameraPosition += new Vector2(0, -2.0f);
if (state.IsKeyDown(Keys.Left))
tileEngine.CameraPosition += new Vector2(-2.0f, 0);
if (state.IsKeyDown(Keys.Right))
tileEngine.CameraPosition += new Vector2(2.0f, 0);
base.Update(gameTime);
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
spriteBatch.Begin();
tileEngine.Draw(gameTime, spriteBatch);
spriteBatch.End();
base.Draw(gameTime);
}
}
}
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.
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
{
if (Data == null || TileMap == null)
return;
int screenCenterX = viewportWidth / 2;
int screenCenterY = viewportHeight / 2;
int startX = (int)((CameraPosition.X - screenCenterX) / TileWidth);
int startY = (int)((CameraPosition.Y - screenCenterY) / TileHeight);
int endX = (int)(startX + viewportWidth / TileWidth) + 1;
int endY = (int)(startY + viewportHeight / TileHeight) + 1;
if (startX < 0)
startX = 0;
if (startY < 0)
startY = 0;
Vector2 position = Vector2.Zero;
int tilesPerLine = TileMap.Width / TileWidth;
for (int y = startY; y < Data.GetLength(0) && y <= endY; y++)
{
for (int x = startX; x < Data.GetLength(1) && x <= endX; x++)
{
position.X = (x * TileWidth - CameraPosition.X + screenCenterX);
position.Y = (y * TileHeight - CameraPosition.Y + screenCenterY);
int index = Data[y, x];
Rectangle tileGfx = new Rectangle((index % tilesPerLine) * TileWidth,
(index / tilesPerLine) * TileHeight, TileWidth, TileHeight);
spriteBatch.Draw(TileMap,
position, tileGfx, Color.White, 0f, Vector2.Zero, 1.0f, SpriteEffects.None, 0f);
}
}
}
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 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.
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.
public float MaxZoom { get; set; }
public float MinZoom { get; set; }
private float zoom;
public float Zoom
{
get { return zoom; }
set
{
zoom = value;
if (zoom > MaxZoom)
zoom = MaxZoom;
if (zoom < MinZoom)
zoom = 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.
public override void Initialize()
{
viewportWidth = Game.GraphicsDevice.Viewport.Width;
viewportHeight = Game.GraphicsDevice.Viewport.Height;
MaxZoom = 4.0f;
MinZoom = 0.5f;
Zoom = 1.0f;
base.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.
if (state.IsKeyDown(Keys.PageDown))
tileEngine.Zoom += 0.05f;
if (state.IsKeyDown(Keys.PageUp))
tileEngine.Zoom -= 0.05f;
Då var det dags för en uppdaterad variant av Draw som beräknar zoom.
public void Draw(GameTime gameTime, SpriteBatch spriteBatch)
{
if (Data == null || TileMap == null)
return;
int screenCenterX = viewportWidth / 2;
int screenCenterY = viewportHeight / 2;
float zoomTileWidth = (TileWidth * Zoom);
float zoomTileHeight = (TileHeight * Zoom);
Vector2 zoomCameraPosition = CameraPosition * Zoom;
int startX = (int)((zoomCameraPosition.X - screenCenterX) / zoomTileWidth);
int startY = (int)((zoomCameraPosition.Y - screenCenterY) / zoomTileHeight);
int endX = (int)(startX + viewportWidth / zoomTileWidth) + 1;
int endY = (int)(startY + viewportHeight / zoomTileHeight) + 1;
if (startX < 0)
startX = 0;
if (startY < 0)
startY = 0;
Vector2 position = Vector2.Zero;
int tilesPerLine = TileMap.Width / TileWidth;
for (int y = startY; y < Data.GetLength(0) && y <= endY; y++)
{
for (int x = startX; x < Data.GetLength(1) && x <= endX; x++)
{
position.X = (x * zoomTileWidth - zoomCameraPosition.X + screenCenterX);
position.Y = (y * zoomTileHeight - zoomCameraPosition.Y + screenCenterY);
int index = Data[y, x];
Rectangle tileGfx = new Rectangle((index % tilesPerLine) * TileWidth,
(index / tilesPerLine) * TileHeight, TileWidth, TileHeight);
spriteBatch.Draw(TileMap,
position, tileGfx, Color.White, 0f, Vector2.Zero, zoom, SpriteEffects.None, 0f);
}
}
}
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.
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
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
//spriteBatch.Begin();
spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
SamplerState.PointClamp, DepthStencilState.Default, RasterizerState.CullNone);
tileEngine.Draw(gameTime, spriteBatch);
spriteBatch.End();
base.Draw(gameTime);
}
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:
"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.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework;
namespace EnkelTilemotor
{
class TextureTool
{
public static Texture2D[] Split(Texture2D original, int partWidth,
int partHeight, out int xCount, out int yCount)
{
yCount = original.Height / partHeight;
xCount = original.Width / partWidth;
Texture2D[] r = new Texture2D[xCount * yCount];
int dataPerPart = partWidth * partHeight;
Color[] originalData = new Color[original.Width * original.Height];
original.GetData<Color>(originalData);
int index = 0;
for (int y = 0; y < yCount * partHeight; y += partHeight)
for (int x = 0; x < xCount * partWidth; x += partWidth)
{
Texture2D part = new Texture2D(original.GraphicsDevice, partWidth, partHeight);
Color[] partData = new Color[dataPerPart];
for (int py = 0; py < partHeight; py++)
for (int px = 0; px < partWidth; px++)
{
int partIndex = px + py * partWidth;
if (y + py >= original.Height || x + px >= original.Width)
partData[partIndex] = Color.Transparent;
else
partData[partIndex] = originalData[(x + px) + (y + py) * original.Width];
}
part.SetData<Color>(partData);
r[index++] = part;
}
return r;
}
}
}
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
private Texture2D tileMap;
public Texture2D TileMap
{
get { return tileMap; }
set
{
tileMap = value;
int x, y;
tiles = TextureTool.Split(tileMap, TileWidth, TileHeight, out x, out y);
}
}
protected Texture2D[] tiles;
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å:
position.X = (x * zoomTileWidth - zoomCameraPosition.X + screenCenterX);
position.Y = (y * zoomTileHeight - zoomCameraPosition.Y + screenCenterY);
spriteBatch.Draw(tiles[Data[y, x]],
position, null, Color.White, 0f, Vector2.Zero, zoom, SpriteEffects.None, 0f);
Resultatet blir:
Slutligen finns den korrekta versionen att ladda hem