EotB – Grafikmotorn

Det är dags att bygga ihop en grafikmotor som kan rendera all trevlig grafik som vi lyckats plocka fram ur Eye of the Beholder. Detta är redan den 6:e delen i en serie som handlar lite om retro gaming och hur spel konstruerades förr.

MonoEye

Det kommer nog inte som en överraskning men vi kommer att använda MonoGame. Vi skapar först ett nytt projekt som vi döpte till ”MonoEye” av typen ”MonoGame Windows Projekt”. Tanken med detta projekt är att det ska bli Open Source och landa på GitHub när vi kommit lite längre fram. Tills vidare kommer källkoden att kunna laddas ned som innan i artiklarna.

Vi står inför följande uppgifter:

  • Lägga till alla klasser från ”PakExtract”.
  • Rendera grafiken till MonoGame Texture2D.
  • Lägga till lite testdata för banan.
  • Rendera väggarna.

Hjälpklasser

Vi kommer att använda ett tvådimensionellt fält för att hålla koll på väggarna i banan. Eftersom vi kommer att behöva indexera med heltal och kunna beräkna saker som ”två steg fram och ett åt vänster”, oavsett riktning på kartan, så har vi nytta av en klass Vector2Int som fungerar ungefär som Vector2 men som då endast använder heltal.

Vector2Int.cs
namespace MonoEye
{
    public struct Vector2Int
    {
        public readonly int X;
        public readonly int Y;

        public Vector2Int(int x, int y)
        {
            X = x;
            Y = y;
        }

        #region Equals

        public override bool Equals(object obj)
        {
            if (obj is Vector2Int) return Equals((Vector2Int)obj);
            else return false;
        }

        public bool Equals(Vector2Int other)
        {
            return X == other.X && Y == other.Y;
        }

        #endregion

        #region Operators

        public static bool operator ==(Vector2Int value1, Vector2Int value2)
        {
            return value1.X == value2.X && value1.Y == value2.Y;
        }

        public static Vector2Int operator +(Vector2Int value1, Vector2Int value2)
        {
            return new Vector2Int(value1.X + value2.X, value1.Y + value2.Y);
        }

        public static Vector2Int operator -(Vector2Int value1, Vector2Int value2)
        {
            return new Vector2Int(value1.X - value2.X, value1.Y - value2.Y);
        }

        public static Vector2Int operator *(Vector2Int value1, int value2)
        {
            return new Vector2Int(value1.X * value2, value1.Y * value2);
        }

        public static Vector2Int operator *(int value1, Vector2Int value2)
        {
            return new Vector2Int(value1 * value2.X, value1 * value2.Y);
        }

        public static bool operator !=(Vector2Int value1, Vector2Int value2)
        {
            if (value1.X == value2.X) return value1.Y != value2.Y;
            return true;
        }

        #endregion

        #region Overrides

        public override int GetHashCode()
        {
            return X.GetHashCode() + Y.GetHashCode();
        }

        public override string ToString()
        {
            return $"{{X:{X} Y:{Y}}}";
        }

        #endregion

        #region Methods

        public Vector2Int Rotate90DegreesRight()
        {
            return new Vector2Int(-Y, X);
        }

        public Vector2Int Rotate90DegreesLeft()
        {
            return new Vector2Int(Y, -X);
        }

        public bool IsIndexable(int mapWidth, int mapHeight)
        {
            return X >= 0 && Y >= 0 && X < mapWidth && Y < mapHeight;
        }

        #endregion
    }
}

Vi överlagrar en del operatorer. På så vis kan vi räkna och t.ex. lägga ihop en position med en riktning. Rent tekniskt är klassen ingen klass utan en struct. Man pratar om värdetyp istället för referenstyp, vi diskuterar ämnet lite i artikeln OOP – Polymorfism.

Med metoderna Rotate90DegreesRight och Rotate90DegreesLeft har vi enkel rotation på riktningen. Vi kommer att använda klassen för att beskriva positioner i banan samt riktningar.

En annan finess med klassen är att den är ”immutable”. Kort beskrivet betyder det att du inte kan ändra värden/innehåll på en Vector2Int utan att samtidigt skapa en ny Vector2Int. Studera koden ovan och se om du kan se vad vi menar.

En annan liten hjälpmetod som vi kommer ha nytta av är Index. Denna skapar vi som en ”extension method” för alla variabler av typen int[,]. Denna metod kommer att snabbt ge oss värdet i ett 2D fält bara genom att skicka en en Vector2Int.

Extensions.cs
namespace MonoEye
{
    public static class Extensions
    {
        public static int Index(this int[,] array, Vector2Int index)
        {
            return array[index.Y, index.X];
        }
    }
}

Mer data

Tidigare har vi i klassen WallRenderData lagt in en massa data som hjälper oss att rendera väggarna. Denna data kompletterar vi med vilken position på kartan som hänger ihop med vilken väggtyp. Positionerna som vi tidigare benämnt A-Q kommer nu att få informationen StepsForward och StepsLeft för att enkelt kunna avgöra, i förhållande till positionen på banan, vilket index som gäller för väggdata.

T.ex. ”N-south” ligger ett steg fram. Positionen ”G-south” ligger 3 steg fram och -3 steg till vänster (alltså 3 till höger), o.s.v. Ladda ned källkoden och kolla, vi listar inte denna fil en gång till just nu.

Till vår tänkta banan så definierar vi olika väggtyper.

WallType.cs
namespace MonoEye
{
    public enum WallType
    {
        Nothing = 0,
        Wall1 = 1,
        Wall2 = 2,
        Door = 3
    }
}

Dessa typer stämmer väl in med de väggtyper vi redan identifierat i BRICK.VMP. Alltså två olika ”vanliga” väggtyper och dörrar som nummer 3.

Vår tanke är sedan att generera en Texture2D för varje väggtyp som hör till varje position. Alltså vi har alla positioner A-Q, framifrån och sidledes,  totalt 25 st, alla definierade i WallRenderData. Vi skapar en ny klass som representerar en position i ”fönstret till världen”. Vi behöver då veta position i förhållande till spelarens position på banan, position för uppritning i fönstret samt alla varianter av grafik beroende på vilken väggtyp som ska visas.

ViewPositions.cs
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System.Collections.Generic;

namespace MonoEye
{
    class ViewPortPositions
    {
        public int StepsForward { get; set; }
        public int StepsLeft { get; set; }
        public List<Texture2D> Texture { get; set; } = new List<Texture2D>();
        public Vector2 DrawPosition { get; set; }
    }
}

Anpassningar MonoGame

Istället för att rendera grafik till Bitmap så måste vi rendera till Texture2D. Dessa förändringar är inte så tekniskt intressanta så vi nöjer oss med att konstatera att dessa behöver göras.

Grafikmotorn

När vi nu har alla klasser, data och hjälpklasser på plats så blir grafikmotorn rätt kompakt och lättläslig.

MonoEyeGame.cs
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System.Collections.Generic;

namespace MonoEye
{
    public class MonoEyeGame : Game
    {
        GraphicsDeviceManager graphics;
        SpriteBatch _spriteBatch;

        private const int MapWidth = 10;
        private const int MapHeight = 10;
        private Vector2Int _position = new Vector2Int(1, 1);
        private Vector2Int _direction = new Vector2Int(0, -1);
        readonly List<ViewPortPositions> _viewPortPositions = new List<ViewPortPositions>();
        Texture2D _backDropTexture;
        Texture2D _playFieldTexture;

        private readonly int[,] _map =
        {
            {2,2,1,2,1,2,1,2,1,2},
            {1,0,0,0,0,0,0,0,0,1},
            {2,0,0,2,1,3,1,2,1,2},
            {1,0,1,2,1,0,2,0,0,1},
            {2,0,2,0,2,0,1,0,0,2},
            {1,0,1,0,1,0,3,0,0,1},
            {2,0,2,0,2,0,1,0,0,2},
            {1,0,1,0,1,0,2,1,2,1},
            {2,0,2,0,0,0,1,1,1,2},
            {1,2,1,2,1,2,2,1,2,1}
        };

        public MonoEyeGame()
        {
            graphics = new GraphicsDeviceManager(this);
            graphics.PreferredBackBufferWidth = 320;
            graphics.PreferredBackBufferHeight = 200;
            Content.RootDirectory = "Content";

            Components.Add(new KeyboardComponent(this));
        }

        protected override void Initialize()
        {
            var pakFile = PakFile.FromFile("Paks\\EOBDATA3.PAK");
            var palette = PaletteFile.FromData(pakFile["BRICK.PAL"].RawData);
            var decodedBrick = CpsFile.FromData(pakFile["BRICK.VCN"].RawData);
            var vcnFile = VcnFile.FromData(decodedBrick.RawData);
            var vmpFile = VmpFile.FromData(pakFile["BRICK.VMP"].RawData);

            var renderer = new BlockRenderer(vcnFile, vmpFile, palette);
            _backDropTexture = new Texture2D(GraphicsDevice, 176, 120);
            renderer.DrawBackdrop(_backDropTexture, 0, 0);

            foreach(var data in WallRenderData.Data)
            {
                
                int xpos = (data.OffsetInViewPort % 22) * 8;
                int ypos = (data.OffsetInViewPort / 22) * 8;
                var vpp = new ViewPortPositions() {
                    DrawPosition = new Vector2(xpos, ypos),
                    StepsForward = data.StepsForward,
                    StepsLeft = data.StepsLeft
                };

                for(int i=0; i<vmpFile.NrOfWallTypes; i++)
                {
                    var texture = new Texture2D(GraphicsDevice, data.VisibleWidthInBlocks * 8, data.VisibleHeightInBlocks * 8);
                    renderer.DrawWall(texture, i, data, -xpos, -ypos);
                    vpp.Texture.Add(texture);
                }
                _viewPortPositions.Add(vpp);
            }

            pakFile = PakFile.FromFile("Paks\\EOBDATA5.PAK");
            var playField = CpsFile.FromData(pakFile["PLAYFLD.CPS"].RawData);
            pakFile = PakFile.FromFile("Paks\\EOBDATA2.PAK");
            palette = PaletteFile.FromData(pakFile["EOBPAL.COL"].RawData);
            _playFieldTexture = new Texture2D(GraphicsDevice, 320, 200);
            playField.RenderTexture(_playFieldTexture, palette);

            base.Initialize();
        }

        protected override void LoadContent()
        {
            _spriteBatch = new SpriteBatch(GraphicsDevice);
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed 
                || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            var nextPosition = _position;

            if (KeyboardComponent.KeyPressed(Keys.Delete) || KeyboardComponent.KeyPressed(Keys.Q))
                nextPosition += _direction.Rotate90DegreesLeft();
            if (KeyboardComponent.KeyPressed(Keys.PageDown) || KeyboardComponent.KeyPressed(Keys.E))
                nextPosition += _direction.Rotate90DegreesRight();
            if (KeyboardComponent.KeyPressed(Keys.Up) || KeyboardComponent.KeyPressed(Keys.W))
                nextPosition += _direction;
            if (KeyboardComponent.KeyPressed(Keys.Down) || KeyboardComponent.KeyPressed(Keys.S))
                nextPosition -= _direction;
            if (KeyboardComponent.KeyPressed(Keys.Left) || KeyboardComponent.KeyPressed(Keys.A))
                _direction = _direction.Rotate90DegreesLeft();
            if (KeyboardComponent.KeyPressed(Keys.Right) || KeyboardComponent.KeyPressed(Keys.D))
                _direction = _direction.Rotate90DegreesRight();

            if (_map.Index(nextPosition) != (int)WallType.Wall1 &&
                _map.Index(nextPosition) != (int)WallType.Wall2)
                _position = nextPosition;

            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.Black);

            _spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend,
                SamplerState.PointClamp, DepthStencilState.Default, RasterizerState.CullNone);

            _spriteBatch.Draw(_playFieldTexture, Vector2.Zero, null, Color.White, 0f, 
                Vector2.Zero, 1.0f, SpriteEffects.None, 0f);
            bool flipBackground = ((_position.X ^ _position.Y ^_direction.X) & 1) > 0;
            _spriteBatch.Draw(_backDropTexture, Vector2.Zero, null, Color.White, 0f, Vector2.Zero, 
                1.0f,  flipBackground ? SpriteEffects.FlipHorizontally : SpriteEffects.None, 0f);

            foreach (var vp in _viewPortPositions)
            {
                var index = _position + vp.StepsForward * _direction + vp.StepsLeft * _direction.Rotate90DegreesLeft();
                if (!index.IsIndexable(MapWidth, MapHeight) || _map[index.Y, index.X] == (int)WallType.Nothing)
                    continue;

                int wallType = _map[index.Y, index.X] - 1;
                _spriteBatch.Draw(vp.Texture[wallType], vp.DrawPosition, null, Color.White, 0f, 
                    Vector2.Zero, 1.0f, SpriteEffects.None, 0f);
            }
            _spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

Definitionen av banan är väldigt simpel just nu (rad #22).

Renderingen till Texture2D sker på rad #57-75. Vi anropar med negativa offset för att börja rita alla texturer på position (0,0).

Vi laddar också grafiken till UI:t för att öka Eye of the Beholder-känslan i demot.

I Update() läser vi bara av tangentbordet och kollar så att vi kan gå till nästa tänkta position. Se användningen av metoden Index på rad #117. Koden blir rätt snygg och lättläslig.

När vi sedan ska rita upp allt så behövs endast en loop som går igenom alla 25 positioner och ritar, vid behov, ut rätt textur! Mycket ren kod. Vi hoppar såklart över uppritning av typ 0 (=ingenting) och index som ligger utanför kartan. Nedan kan ses resultatet.

Avslutning

Tills vidare för ni nöja er med en stillbild. Inom kort kommer vi lägga upp ett litet Youtube-klipp. Ladda annars hem källkoden och tillhörande PAK-filer och testa lite själva!

I nästa artikel kommer vi att ladda den riktiga banan för level 1. Vi kommer också att försöka rendera alla dekorationer så som spakar, avlopp, dörrar, handtag, etc.

Nästa artikel

EotB - Dekaler

Dags att ta itu med uppritningen av dekaler

Scroll to top