Tile Editor – Del 1

Inledning

Du har säkert spelat Zelda, Super Mario eller något annat äldre plattformsspel. Tekniken bakom banorna i dessa spel 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.

I denna artikelserie skall vi bygga ett windowsprogram för att skapa tile-baserade banor. Dessa banor kommer vi sedan att kunna använda i framtida spelprojekt samtidigt som det blir en lektion i hur ett lite mer avancerat windowprogram kan byggas. Vi kommer att använda en del objektorienterade tekniker för att bygga editorn utan att behöva skriva flera tusen rader kod.

Artikelns mål

Målet med del 1 blir att skapa ett program med all nödvändig GUI för att kunna jobba med flera banor samtidigt. Områden vi kommer in på är följande:

  • MDI formulärer
  • Egna dialogrutor
  • En egen kontroller som bygger på befintliga .NET kontroller
  • Nyttan av interface

I klassdiagrammet nedan kan du se en övergripande design på hur programmet kommer att se ut efter del 1.

bild

Ett nytt projekt

Börja som vanligt med att starta Visual Studio och skapa ett nytt "Windows Forms Application" projekt. Vi döper projektet till LevelEditor. När projektet är skapat skall vi direkt ändra detta till ett MDI ("multiple document interface") program.

Vad är ett MDI program? Enkelt skulle man kunna säga att det är ett program där alla alla öppnade filer/projekt är underfönster till programmet, t.ex. Photoshop, mIRC, m.fl. Det skall tilläggas att MDI inte är så populärt som det en gång har varit. Motsatsen kallas SDI ("single document interface") och används många program däribland Visual Studio. En blandning av MDI och SDI blir TDI ("tabbed document interface") med exempel som IE8, Firefox, m.fl.

Som parentes kan tilläggas att Microsofts Office programvaror började med ett SDI interface, byttes till MDI som nu åter igen bytts tillbaka till SDI.

Högerklicka på ditt projekt och välj "Add" -> "New Item" i solution explorern. Bläddra fram till "MDI Parent Form". Vi väljer namnet LevelEditor.cs och trycker OK.

bild

Nu skall vi se till att programmet startar med att visa LevelEditor formuläret istället för Form1. Detta gör vi enkelt genom att öppna filen Program.cs och gör en liten ändring.

Program.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;

namespace LevelEditor
{
    static class Program
    {
        /// 
        /// The main entry point for the application.
        /// 
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new LevelEditor());
        }
    }
}

Vi har ändrat rad 18 så att ett objekt av typen LevelEditor skapas och körs som vår applikation.

Nu kan vi köra programmet och passa på att undersöka vad som ingår i ett "MDI Parent Form".

bild

Vi får en hel del prylar som spar oss tid med ett "MDI Parent Form", bl. a. en MenuStrip, en ToolStrip och en StatusStrip som redan är utplacerade. Inte nog med det vi får även en del kod i LevelEditor.cs som hanterar fönsterplacering.

Nu börjar vårt arbete

Ett interface

Ett interface (på svenska gränssnitt) kan ses som en innehållsdeklaration. Ett interface innehåller egentligen inte en enda rad körbar kod. Ett interface beskriver vilka metoder och properties (egenskaper) som ingår i ett interface. Detta interface kan sedan klasser underkasta sig på liknande sätt som ett arv. Det fina med interface är att en klass kan ärva från många olika interface utöver det arv vi kan ha från en annan klass. Kom ihåg att en klass kan endast ärva från en annan klass.

Det inteface vi skall definiera skall innehålla information om en karta. Vi vill veta hur många rutor bred och hög vår karta är samt hur bred och hög varje "tile" är.

Skapa ett interface genom att högerklicka på projektet och välj "Add" -> "New item...". Välj Interface i listan och döp filen till IMapSettings. I:et framför namnet indikerar interface och är en mycket vanlig regel för namngivning i C#.

Vi vill bara deklarera properties i vårt interface, resultatet blir:

IMapSettings.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace LevelEditor
{
    public interface IMapSettings
    {
        int MapWidth
        {
            get;
            set;
        }

        int MapHeight
        {
            get;
            set;
        }

        int TileWidth
        {
            get;
            set;
        }

        int TileHeight
        {
            get;
            set;
        }
    }
}

En dialogruta

Nu ska vi använda oss av Form1 för att skapa en dialogruta som hjälper oss att ställa in inställningar för en karta. Dessa inställningar är de som vi deklarerade i vårt interface dvs. kartans storlek samt rutornas storlek i pixlar.

Vi börjar med att döpa om Form1.cs till LevelSettings.cs. Högerklicka på filen och välj "Rename". Svara sedan ja på frågan om du vill uppdatera alla refenser.

Det vi gör nu är att placera ut en del färdiga komponenter och ställer in egenskaper i design-läget. Bilden nedan visar resultatet och sedan följer en lista på vilka komponenter som använts samt dess ändrade egenskaper.

bild

LevelSettings (form)

  • FormBorderSyle = FixedDialog
  • MaximizeBox = False
  • MinimizeBox = False
  • ShowIcon = False
  • Size = 206;230
  • StartPosition = CenterParent
  • Text = "Inställningar för bana"

NumericUpDown

I formuläret används 4 stycken NumericUpDown. Dessa är namngivna som numericTileWidth, numericTileHeight, numericMapWidth, numericMapHeight. Minimum är satt till 1, Maximum 1024 och Value till antingen 32 eller 20.

Label

Det finns 4 stycken Label utplacerade. Ändra endast Text-attributet och placering så att texten beskriver inmatningen.

Button

De två knappar som är utplacerade är döpta till buttonOK och buttonCancel.

Dubbelklicka på knapparna för att automatiskt skapa "Click"-events. Filen LevelSettings.cs borde nu se ut som:

LevelSettings.cs - version 1

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace LevelEditor
{
    public partial class LevelSettings : Form
    {
        public LevelSettings()
        {
            InitializeComponent();
        }

        private void buttonOK_Click(object sender, EventArgs e)
        {

        }

        private void buttonCancel_Click(object sender, EventArgs e)
        {

        }
    }
}

Nu är det dags att implementera vårt interface. Vi anger att klassen LevelSettings skall ärva från IMapSettings genom att lägga till detta i klassdeklarationen (se bilden nedan). Högerklickar vi sedan direkt på texten IMapSettings så har Visual Studio inbyggt stöd för att hjälpa oss att implementera vårt interface.

bild

Detta spar oss mycket tid och filen ser nu ut som:

LevelSettings.cs - version 2

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace LevelEditor
{
    public partial class LevelSettings : Form, IMapSettings
    {
        public LevelSettings()
        {
            InitializeComponent();
        }

        private void buttonOK_Click(object sender, EventArgs e)
        {

        }

        private void buttonCancel_Click(object sender, EventArgs e)
        {

        }

        #region IMapSettings Members

        public int MapWidth
        {
            get
            {
                throw new NotImplementedException();
            }
            set
            {
                throw new NotImplementedException();
            }
        }

        public int MapHeight
        {
            get
            {
                throw new NotImplementedException();
            }
            set
            {
                throw new NotImplementedException();
            }
        }

        public int TileWidth
        {
            get
            {
                throw new NotImplementedException();
            }
            set
            {
                throw new NotImplementedException();
            }
        }

        public int TileHeight
        {
            get
            {
                throw new NotImplementedException();
            }
            set
            {
                throw new NotImplementedException();
            }
        }

        #endregion
    }
}

Kvar återstår nu bara att returnera korrekta värden från våra properties. De värden som är intressanta är de som finns i våra NumericUpDown. Vi modifierar filen och passar på att frisera bort en del rader för den färdiga versionen:

LevelSettings.cs

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace LevelEditor
{
    public partial class LevelSettings : Form, IMapSettings
    {
        public LevelSettings()
        {
            InitializeComponent();
        }

        public int MapWidth
        {
            get { return (int)numericMapWidth.Value; }
            set { numericMapWidth.Value = value; }
        }

        public int MapHeight
        {
            get { return (int)numericMapHeight.Value; }
            set { numericMapHeight.Value = value; }
        }

        public int TileWidth
        {
            get { return (int)numericTileWidth.Value; }
            set { numericTileWidth.Value = value; }
        }

        public int TileHeight
        {
            get { return (int)numericTileHeight.Value; }
            set { numericTileHeight.Value = value; }
        }

        private void buttonOK_Click(object sender, EventArgs e)
        {
            DialogResult = DialogResult.OK;
        }

        private void buttonCancel_Click(object sender, EventArgs e)
        {
            DialogResult = DialogResult.Cancel;
        }
    }
}

Eftersom en NumericUpDown använder Decimal som Value så behöver vi typomvandla till int när vi returnerar värdet. Vi har också satt olika DialogResult beroende på vilken knapp vi trycker på. Detta behövs för att kunna använda LevelSettings som en dialogruta i vårt program, något vi strax skall göra.

En Form för Map

Vi behöver ett fönster för vår karta. Vi skapar ett nytt "Window Form" som vi döper till Map.cs. Kartan behöver också inställningar för bredd, höjd, etc. Tur att vi har ett interface för detta! Vi implementerar IMapSettingsMap. Vi gör det så pass enkelt vi kan genom att ha tomma get; och set; i klassen. Detta är ett enkelt sätt att slippa deklarera privata variabler till alla properties. Filen Map.cs ser nu ut som:

Map.cs - version 1

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace LevelEditor
{
    public partial class Map : Form, IMapSettings
    {
        Bitmap mapImage;

        public Map()
        {
            InitializeComponent();
        }

        #region IMapSettings Members

        public int MapWidth
        {
            get;
            set;
        }

        public int MapHeight
        {
            get;
            set;
        }

        public int TileWidth
        {
            get;
            set;
        }

        public int TileHeight
        {
            get;
            set;
        }

        #endregion
    }
}

Egenskapen AutoScroll sätter vi till True för formuläret Map. Detta gör så att vi får scrollistor i formuläret när innehållet är större än fönstret.

Vi kommer att återkomma till Map.cs senare men först skall vi skapa en komponent som Map kommer att använda sig av.

En egen kontroll

Vi skall nu bygga en egen kontroll utifrån en befintlig komponent. Den komponent vi skall skapa är den grafiska delen i programmet som skall visa själva kartan och reagera på input genom att grafiskt uppdateras. Den kontroll vi kommer att bygga på är PictureBox.

Vi skapar en ny klass som vi döper till MapPictureBox.cs. Vi börjar med koden och tar diskussionen efter.

MapPictureBox.cs

using System;
using System.ComponentModel;
using System.Data;
using System.Windows.Forms;
using System.Drawing;
using LevelEditor;

namespace LevelEditorControls
{
    class MapPictureBox : PictureBox
    {
        int mouseX, mouseY;
        bool drawTile;

        public Map MapParent
        {
            get;
            set;
        }

        public Point TilePosition
        {
            get
            {
                return new Point(mouseX / MapParent.TileWidth, mouseY / MapParent.TileHeight);
            }
        }

        public Point TilePixelPosition
        {
            get
            {
                int drawX = mouseX - (mouseX % MapParent.TileWidth);
                int drawY = mouseY - (mouseY % MapParent.TileHeight);
                return new Point(drawX, drawY);
            }
        }

        public MapPictureBox()
        {
        }

        protected override void OnPaint(PaintEventArgs pe)
        {
            base.OnPaint(pe);

            if (drawTile && MapParent != null)
            {
                pe.Graphics.FillRectangle(Brushes.Red, 
					new Rectangle(TilePixelPosition.X, TilePixelPosition.Y, 
								  MapParent.TileWidth, MapParent.TileHeight));
            }
        }

        protected override void OnMouseMove(MouseEventArgs e)
        {
            mouseX = e.X;
            mouseY = e.Y;
            base.OnMouseMove(e);
            this.Invalidate();
        }

        protected override void OnMouseEnter(EventArgs e)
        {
            base.OnMouseEnter(e);
            drawTile = true;
            this.Invalidate();
        }

        protected override void OnMouseLeave(EventArgs e)
        {
            base.OnMouseLeave(e);
            drawTile = false;
            this.Invalidate();
        }
    }
}

Notera att vi valt ett nytt namespace namn. Detta är för att designern i Visual Studio agerar lite konstigt när en ett formulär och en komponent ligger i samma namnrymd. För att undvika detta har vi valt namnrymden "LevelEditorControls" vilket också medför att vi får inkludera namnrymden "LevelEditor" med ett using direktiv.

På rad 14 deklarerar vi propertyn MapParent av typen Map. Tanken är att en Map kommer att ha en MapPictureBox samtidigt vill vi i MapPictureBox komma åt inställningar från kartan. Via MapParent kommer vi att nå ägaren och inställningarna (IMapSettings).

Vi vill få en grafisk effekt av att se en "tile" när muspekaren förs över komponenten. Den "tile" vi vill se är den som vi skall sätta ut på kartan. I denna första del har vi inte stöd för att välja "tile" utan använder endast en röd fyrkant för att symbolisera en "tile". Vi behöver veta om muspekaren befinner sig ovanför komponenten och vilken position den då har. Till detta har vi variablerna mouseX, mouseY och drawTile.

För att kontrollera status på muspekaren bygger vi om metoderna OnMouseMove, OnMouseEnter och OnMouseLeave. Dessa metoder finns redan definierade i basklassen PictureBox vilket gör att vi kan bygga om dem genom att använda override. Vi använder fortfarande basklassens implementation via base anropet men passar på att lägga till de ändringar vi behöver. Invalidate signalerar till programmet att komponenten behöver ritas om. Omritningen sker sedan i OnPaint.

Kvar återstår nu metoden OnPaint som vi modifierar. Här lägger vi till villkoret för att ruta en röd "tile". Muspekaren skall vara ovanför komponenten samt att vi inte glömt sätta en MapParent. Vi beräknar sedan positionen samt storleken för vår "tile" och ritar ut en röd rektangel.

Propertyn TilePixelPosition och TilePosition räknar ut punkter baserat på muspekaren. Dessa är gjorda som properties eftersom Map kommer att vara intresserad av informationen.

Modifiera Map

Vi skall nu placera ut en MapPictureBox i Map. Detta kan vi göra i design-läge om Visual Studio har uppdaterat Toolbox-fönstret. I värsta fall kan du behöva starta om Visual Studio men det skall inte behövas. Komponenten skall listas som bilden nedan visas:

bild

Placera ut en MapPictureBox på position 0;0 samt döp den till mapPictureBox. Testa att köra programmet. Skulle du få fel i InitializeComponent i Map.Designer.cs så beror det på att vi har samma namespace i Map som i MapPictureBox. Visual studio genererar LevelEditor.MapPictureBox där det bara borde står MapPictureBox. Det är lätt ändrat.

Vi lägger till följande i Map.cs:

Map.cs - Tillägg

		Bitmap mapImage;

        public Map(IMapSettings source)
        {
            LevelEditor.CopySettings(source, this);
            InitializeComponent();

            mapImage = new Bitmap(MapWidth * TileWidth, MapHeight * TileHeight);
            Graphics g = Graphics.FromImage(mapImage);
            g.Clear(Color.Black);
            mapPictureBox.MapParent = this;
            mapPictureBox.Image = mapImage;
            mapPictureBox.Width = mapImage.Width;
            mapPictureBox.Height = mapImage.Height;
            mapPictureBox.Click += new EventHandler(pictureBoxMap_Click);
            mapPictureBox.MouseMove += new MouseEventHandler(pictureBoxMap_MouseMove);
        }

        private void DrawTile()
        {
            Graphics g = Graphics.FromImage(mapImage);
            g.FillRectangle(Brushes.Blue, 
				mapPictureBox.TilePixelPosition.X, mapPictureBox.TilePixelPosition.Y,
                TileWidth, TileHeight);
        }

        #region pictureBoxMap events

        void pictureBoxMap_MouseMove(object sender, MouseEventArgs e)
        {
            if (e.Button == MouseButtons.Left)
            {
                DrawTile();
            }
        }

        void pictureBoxMap_Click(object sender, EventArgs e)
        {
            DrawTile();
        }

Vi skapar här en ny konstruktor som tar en IMapSettings objekt som en inparameter. Vi anropar sedan en statisk metod, CopySettins för att kopiera IMapSettings inställningarna från source till vår Map. Denna metod är tyvärr inte skriven ännu utan kommer under nästa rubrik.

Vi skapar en Bitmap, mapImage, som kommer att vara kartan uppritad grafiskt. Denna mapImage ger vi till MapPictureBox via mapPictureBox.Image. Vi ändrar bredden och höjden på mapPictureBox samt binder några events.

pictureBoxMap_MouseMove och pictureBoxMap_Click reagerar på events från mapPictureBox. Vi passar då på att uppdatera mapImage med att rita en symboliskt blå "tile".

Ändringar i LevelEditor

Nu är det äntligen dags att koppla ihop våra modifieringar. Vi skall lägga till metoden CopySettings samt modifiera metoden ShowNewForm.

LevelEditor.cs - Ändringar

		#region Static members

        public static void CopySettings(IMapSettings source, IMapSettings destination)
        {
            destination.MapHeight = source.MapHeight;
            destination.MapWidth = source.MapWidth;
            destination.TileHeight = source.TileHeight;
            destination.TileWidth = source.TileWidth;
        }

        #endregion
		
		private void ShowNewForm(object sender, EventArgs e)
        {
            LevelSettings popup = new LevelSettings();
            switch (popup.ShowDialog(this))
            {
                case DialogResult.OK:
                    Map childForm = new Map(popup);
                    childForm.MdiParent = this;
                    childForm.Cursor = Cursors.Cross;
                    childForm.Text = "Bana " + childFormNumber++;
                    childForm.Show();
                    break;
                case DialogResult.Cancel:
                    break;
            }
        }

Här ser vi nu hur vi kan använda formuläret LevelSettings som en dialog. Genom att titta på resultatet av en ShowDialog kan vi se om dialogen avslutats med "OK" eller "Cancel". Det är samma dialogresultat vi sätter i formulärets knappar. Enkelt och smidigt!

Vid "OK" skapar vi en ny Map och skickar inställningarna från popup vidare till childForm. Vårt interface gör återigen lite nytta. Det går att diskutera meningen med att skicka inställningarna som en parameter till konstruktorn eller redan här anropa CopySettings mellan popup och childForm.

Testa att köra programmet, skapa ett nytt fönster ("New"), samt rita i det.

bild

Avslutning

Grunden är lagd. I nästa avsnitt skall vi bygga vidare för att kunna ladda riktig grafik och sedan rita ut den.

Du kan ladda ned det färdiga projektet nedan om du missade någon detalj i de olika stegen.

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *

Scroll to top