Sonntag, 17. März 2019

Wellenschlagen (MonoGame)



Normalerweise sind 3D Objekte die Importiert wurden oder programmatisch erstellt wurden sehr statisch. Man kann zwar das 3d Objekt per Transformation bewegen, aber wenn man aus dem Objekt einzelnen Polygone bewegen will, dann sieht das ganze schon etwas schwierig aus.
Vor Jahren hatte ich mal ein PDF Dokument erhalten, das die Animationen eines 3D Models beschrieb. Leider bin ich nie dazugekommen, dies auszuprobieren und mit der Zeit ist auch das Dokument auch verloren gegangen.
Die folgende Beschreibung ist sicherlich für Performante Ausführungen nicht geeignet und ist daher nur für weniger Aufwendige Anwendungen gedacht. Wenn man nicht auf alternativen und kompromisse eingeht.

Grundaufbau
Für dieses Beispiel verwende ich einen Auszug von meinem bereits erstellten Beispiel Projekt 'ExampleMoveOnMap3d' und habe dazu einen neuen Branch mit dem Namen Waterwaves erstellt. In diesen Beispiel werden diesmal keine 3d Objekte importiert, sondern hier wird komplett eine Fläche erzeugt, auf die eine Textur zugewiesen wird.

Die neue Klasse AnimatedWaterwaves wird in den Ordner Map eingesetzt.



Parameter Angaben
Die ersten Members legen die Einstellungen der Fläche fest. Das gewählte Raster 20 mal 20 sollte auf den meisten derzeitigen Rechner ohne weiteres flüssig darstellbar sein. Die sich daraus ergebenen 1600 Polygone sind nicht viel, aber werden vor jedem Rendern neu erzeugt und das kostet Rechenzeit. Aber dazu später mehr.
Die Member Variablen _waveStartX und _waveStartY, legen den aktuellen Startwert beim erzeugen der Wellen fest. Die restlichen Member werden allgemein benötigt, um die Fläche zu halten.
Die üblichen Methoden wie Initialize, Draw und Update sind hier weniger Interessant. Dafür ist die Erzeugung der Wellen ansprechender. Die Methode 'RegenerateVertexBuffer' ist relative lang, was dem geschuldet ist, das ich den Hauptteil in der doppelten Schleife nicht ausgelagert habe.

public class AnimatedWaterwaves  
   {  
     private readonly int _tilesX = 20;  
     private readonly int _tilesY = 20;  
     private readonly float _squareLength = 1f;  
     private float _waveStartX = 0f;  
     private float _waveStartY = 0f;  
     private VertexBuffer _vertexBuffer;  
     private IndexBuffer _indexBuffer;  
     private int _vertexCount;  
     private int _indexCount;  
     private BasicEffect _effect;  
     private readonly Texture2D _texture;  
     private List _vertexPositions;  
     public AnimatedWaterwaves(Texture2D texture)  
     {  
       this._texture = texture;  
     }  
     public void Initialize(GraphicsDevice graphicsDevice)  
     {  
       this._effect = new BasicEffect(graphicsDevice);  
       this._effect.World = Matrix.Identity;  
       this._effect.TextureEnabled = true;  
       this._effect.Texture = this._texture;  
       this._effect.EnableDefaultLighting();  
       this._vertexPositions = this.RegenerateVertexBuffer(graphicsDevice);  
     }  
     private List RegenerateVertexBuffer(GraphicsDevice graphicsDevice)  
     {  
       this.ClearBuffers();  
       this._waveStartX += .05f;  
       this._waveStartY += .2f;  
       List vertices = new List();  
       List index = new List();  
       float setHeight = 1.4f;  
       float waveLengthX = 10f;  
       float waveLengthY = 16f;  
       float aa = 5f / this._tilesY;  
       for (int iY = 0; iY < this._tilesY; iY++)  
       {  
         for (int iX = 0; iX < this._tilesX; iX++)  
         {  
           this.SetIndex(ref index, vertices.Count);  
           float waveX = iX + this._waveStartX;  
           float waveY = iY + this._waveStartY;  
           float heightY = (float)Math.Sin(((waveY + 1) * Math.PI) / waveLengthY);  
           float heigthX1 = (float)Math.Sin((float)(waveX * Math.PI) / waveLengthX);  
           float height1 = (heigthX1 + heightY) * setHeight;  
           float heightY2 = (float)Math.Sin(((waveY + 1) * Math.PI) / waveLengthY);  
           float heigthX2 = (float)Math.Sin((float)((waveX + 1) * Math.PI) / waveLengthX);  
           float height2 = (heigthX2 + heightY2) * setHeight;  
           float heightY3 = (float)Math.Sin((waveY * Math.PI) / waveLengthY);  
           float heigthX3 = (float)Math.Sin((float)(waveX * Math.PI) / waveLengthX);  
           float height3 = (heigthX3 + heightY3) * setHeight;  
           float heightY4 = (float)Math.Sin((waveY * Math.PI) / waveLengthY);  
           float heigthX4 = (float)Math.Sin((float)((waveX + 1) * Math.PI) / waveLengthX);  
           float height4 = (heigthX4 + heightY4) * setHeight;  
           float aaX = aa * iX;  
           float aaY = aa * iY;  
           float aaX1 = (aa * iX) + aa;  
           float aaY1 = (aa * iY) + aa;  
           if (aaX > 1f)  
           {  
             aaX = aaX % 1f;  
             aaX1 = aaX1 % 1f;  
           }  
           if (aaY > 1f)  
           {  
             aaY = aaY % 1f;  
             aaY1 = aaY1 % 1f;  
           }  
           var verticePos1 = new Vector3((iX * this._squareLength) + 0, (iY * this._squareLength) + this._squareLength, height1);  
           var verticePos2 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + this._squareLength, height2);  
           var verticePos3 = new Vector3((iX * this._squareLength) + 0, (iY * this._squareLength) + 0, height3);  
           var verticePos4 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + 0, height4);  
           vertices.Add(new VertexPositionNormalTexture(verticePos1, Vector3.Up, new Vector2(aaX, aaY1)));  
           vertices.Add(new VertexPositionNormalTexture(verticePos2, Vector3.Up, new Vector2(aaX1, aaY1)));  
           vertices.Add(new VertexPositionNormalTexture(verticePos3, Vector3.Up, new Vector2(aaX, aaY)));  
           vertices.Add(new VertexPositionNormalTexture(verticePos4, Vector3.Up, new Vector2(aaX1, aaY)));  
         }  
       }  
       this._vertexCount = vertices.Count;  
       this._indexCount = index.Count;  
       this.InitVertexBuffer(graphicsDevice, vertices);  
       this._indexBuffer.SetData(index.ToArray());  
       return vertices;  
     }  
     public void Update(GraphicsDevice graphicsDevice)  
     {  
       this._vertexPositions = this.RegenerateVertexBuffer(graphicsDevice);  
     }  
     internal void Draw(GraphicsDevice graphicsDevice, Matrix view, Matrix projection)  
     {  
       this._effect.View = view;  
       this._effect.Projection = projection;  
       graphicsDevice.SetVertexBuffer(this._vertexBuffer);  
       graphicsDevice.Indices = this._indexBuffer;  
       foreach (EffectPass pass in this._effect.CurrentTechnique.Passes)  
       {  
         pass.Apply();  
         graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, this._vertexCount);  
       }  
     }  
     private void InitVertexBuffer(GraphicsDevice graphicsDevice, List vertices)  
     {  
       this._vertexBuffer = new VertexBuffer(graphicsDevice, VertexPositionNormalTexture.VertexDeclaration, this._vertexCount, BufferUsage.WriteOnly);  
       this._vertexBuffer.SetData(vertices.ToArray());  
       this._indexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, this._indexCount, BufferUsage.WriteOnly);  
     }  
     private void ClearBuffers()  
     {  
       if (this._vertexBuffer != null)  
       {  
         this._vertexBuffer.Dispose();  
         this._vertexBuffer = null;  
       }  
       if (this._indexBuffer != null)  
       {  
         this._indexBuffer.Dispose();  
         this._indexBuffer = null;  
       }  
     }  
     private void SetIndex(ref List index, int localOffset)  
     {  
       index.Add(localOffset + 0);  
       index.Add(localOffset + 1);  
       index.Add(localOffset + 3);  
       index.Add(localOffset + 0);  
       index.Add(localOffset + 3);  
       index.Add(localOffset + 2);  
     }  
   }

Nun muß noch in ComponentMap mit der neuen Klasse umgestellt werden. Dort wo die 3d Objekte geladen wurden, wird nun die AnimatedWaterwaves instanziiert und initialisiert.

public class ComponentMap : GameComponent  
   {  
     private const float _speed = 0.3f;  
     private readonly ComponentInputs _componentInputs;  
     private AnimatedWaterwaves _animatedWaterwaves;  
     public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)  
     {  
       this._componentInputs = componentInputs;  
     }  
     public override void Initialize()  
     {  
       var texture = this.Game.Content.Load("rpgTile029");  
       this._animatedWaterwaves = new AnimatedWaterwaves(texture);  
       this._animatedWaterwaves.Initialize(this.Game.GraphicsDevice);  
     }  
     public override void Update(GameTime gameTime)  
     {  
       RasterizerState raster = new RasterizerState();  
       raster.CullMode = CullMode.CullCounterClockwiseFace;  
       raster.FillMode = FillMode.WireFrame;  
       this.Game.GraphicsDevice.RasterizerState = raster;  

       this._animatedWaterwaves.Update(this.Game.GraphicsDevice);  
     }  
     public void DrawContent(Matrix view, Matrix projection)  
     {  
       this.Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;  

       this._animatedWaterwaves.Draw(this.Game.GraphicsDevice, view, projection);  
     }  
   }  

Ausprobieren
Auf dem PC sollte nun nach dem Start die Animation flüssig abgespielt werden. Vergrößert man die Fläche und gibt mehr Polygone hinzu, dann wird irgendwann der Rechner in die Knie gehen. Wie immer testete ich dies auch auf dem  Raspberry Pi, das auf dem folgenden Video zu sehen ist.


Gleich verbessern
Ehrlich gesagt, hat mich das dann doch gestört. Also schrieb ich am Beispiel weiter und auch weil ich bereits eine Lösung im Kopf hatte, die ich ausprobieren wollte. Stellt man die Variablen _tilesX und _tilesY auf 100, dann kommen wir hier auf 400.000 Polygone, aber die Animation ruckelt.

  • Das Problem ist das ständig neu erzeugen der Wellen. 
  • Die Lösung hierfür wäre die Wellenanimation vorher zu generieren und dann die fertige Wellen abzuspielen.


Gitter Erstellen auslagern
Als nächstes wird ein Teil aus der Klasse AnimatedWaterwaves in eine neue Klasse ausgelagert und zwar der Bestandteil für die Polygonen. In diesen Zug wird dann auch erstmal aufgeräumt. Alles was doppelt erscheint wird zu einer Methode zusammengezogen. Damit wird auch die Hauptmethode für das erstellen der Fläche etwas kleiner. Einstellungen werden als const oder einfach als readonly Member angelegt. Der Konstruktor wird nicht gebraucht, auch wenn man den Teil in Initialize, dort hätte einsetzen können.

public class BufferedWave  
 {  
   private float _waveStepX = .05f;  
   private float _waveStepY = .2f;  
   private readonly int _tilesX = 20;  
   private readonly int _tilesY = 20;  
   private readonly float _squareLength = 1f;  
   private float _textureSize;  
   public VertexBuffer VertexBuffer;  
   public IndexBuffer IndexBuffer;  
   public int VertexCount;  
   private int _indexCount;  
   public List VertexPositions;  
   public const float WaveHeight = 1.4f;  
   public const int WaveLengthX = 10;  
   public const int WaveLengthY = 10;  
   public void Initialize(int step, GraphicsDevice graphicsDevice)  
   {  
     this._textureSize = 5f / this._tilesY;  
     this.VertexPositions = this.GenerateVertexBuffer(step, graphicsDevice);  
   }  
   private List GenerateVertexBuffer(int step, GraphicsDevice graphicsDevice)  
   {  
     this.ClearBuffers();  
     var waveStartX = this._waveStepX * step;  
     var waveStartY = this._waveStepY * step;  
     List vertices = new List();  
     List index = new List();  
     for (int iY = 0; iY < this._tilesY; iY++)  
     {  
       for (int iX = 0; iX < this._tilesX; iX++)  
       {  
         this.SetIndex(ref index, vertices.Count);  
         float waveX = iX + waveStartX;  
         float waveY = iY + waveStartY;  
         float height1 = this.GetHeight(waveX, waveY, 0, 1);  
         float height2 = this.GetHeight(waveX, waveY, 1, 1);  
         float height3 = this.GetHeight(waveX, waveY);  
         float height4 = this.GetHeight(waveX, waveY, 1, 0);  
         var verticePos1 = new Vector3((iX * this._squareLength), (iY * this._squareLength) + this._squareLength, height1);  
         var verticePos2 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength) + this._squareLength, height2);  
         var verticePos3 = new Vector3((iX * this._squareLength), (iY * this._squareLength), height3);  
         var verticePos4 = new Vector3((iX * this._squareLength) + this._squareLength, (iY * this._squareLength), height4);  
         float aaX = this.GetTextureCoordinate(iX);  
         float aaY = this.GetTextureCoordinate(iY);  
         float aaX1 = this.GetTextureCoordinate(iX) + this._textureSize;  
         float aaY1 = this.GetTextureCoordinate(iY) + this._textureSize;  
         vertices.Add(new VertexPositionNormalTexture(verticePos1, Vector3.Up, new Vector2(aaX, aaY1)));  
         vertices.Add(new VertexPositionNormalTexture(verticePos2, Vector3.Up, new Vector2(aaX1, aaY1)));  
         vertices.Add(new VertexPositionNormalTexture(verticePos3, Vector3.Up, new Vector2(aaX, aaY)));  
         vertices.Add(new VertexPositionNormalTexture(verticePos4, Vector3.Up, new Vector2(aaX1, aaY)));  
       }  
     }  
     this.VertexCount = vertices.Count;  
     this._indexCount = index.Count;  
     this.InitVertexBuffer(graphicsDevice, vertices);  
     this.IndexBuffer.SetData(index.ToArray());  
     return vertices;  
   }  
   private float GetHeight(double waveX, double waveY, int x = 0, int y = 0)  
   {  
     float heightY = (float)Math.Sin(((waveY + y) * Math.PI) / WaveLengthY);  
     float heigthX1 = (float)Math.Sin((float)((waveX + x) * Math.PI) / WaveLengthX);  
     return (heigthX1 + heightY) * WaveHeight;  
   }  
   private float GetTextureCoordinate(int index)  
   {  
     var textureCoordinate = this._textureSize * index;  
     if (textureCoordinate >= 1f)  
     {  
       textureCoordinate %= 1f;  
     }  
     return textureCoordinate;  
   }  
   private void InitVertexBuffer(GraphicsDevice graphicsDevice, List vertices)  
   {  
     this.VertexBuffer = new VertexBuffer(graphicsDevice, VertexPositionNormalTexture.VertexDeclaration, this.VertexCount, BufferUsage.WriteOnly);  
     this.VertexBuffer.SetData(vertices.ToArray());  
     this.IndexBuffer = new IndexBuffer(graphicsDevice, IndexElementSize.ThirtyTwoBits, this._indexCount, BufferUsage.WriteOnly);  
   }  
   private void ClearBuffers()  
   {  
     if (this.VertexBuffer != null)  
     {  
       this.VertexBuffer.Dispose();  
       this.VertexBuffer = null;  
     }  
     if (this.IndexBuffer != null)  
     {  
       this.IndexBuffer.Dispose();  
       this.IndexBuffer = null;  
     }  
   }  
   private void SetIndex(ref List index, int localOffset)  
   {  
     index.Add(localOffset + 0);  
     index.Add(localOffset + 1);  
     index.Add(localOffset + 3);  
     index.Add(localOffset + 0);  
     index.Add(localOffset + 3);  
     index.Add(localOffset + 2);  
   }   
}

In der AnimatedWaterwaves bleiben in den Member die Textur und der BasicEffect. Neu ist das Dictionary das später die alle erstellten schritte der Welle beinhaltet. Der index wird in der Update Methode hochgezählt oder zurück gesetzt, wenn das ende des Index Bereiches erreicht ist.
Mit der Methode Initialize werden nun alle schritte generiert. Der BufferIndexSize von 1600 ist mehr zufällig geschätzt, der genau paßt um die Animation in der Schleife abzubilden. Am Ende wird in der Methode Draw noch die bereits angelegten Vertex Werte zugewiesen, nach dem diese per Index Wert aus dem Dictionary abgerufen werden. In den Parametern kommt noch eine offsetPosition und ein offsetIndex rein und ermöglicht den Inhalt zu rendern, der gerade gebraucht wird.

public class AnimatedWaterwavesBuffered  
 {  
   private BasicEffect _effect;  
   private readonly Texture2D _texture;  
   private Dictionary _dictionaryVertexPositions = new Dictionary();  
   public int Index { get; private set; } = 0;  
   public AnimatedWaterwavesBuffered(Texture2D texture)  
   {  
     this._texture = texture;  
   }  
   public void Initialize(GraphicsDevice graphicsDevice)  
   {  
     this._effect = new BasicEffect(graphicsDevice);  
     this._effect.World = Matrix.Identity;  
     this._effect.TextureEnabled = true;  
     this._effect.Texture = this._texture;  
     this._effect.EnableDefaultLighting();  
     // is estimated  
     int bufferIndexSize = 1600;  
     for (int i = 0; i < bufferIndexSize; i++)  
     {  
       BufferedWave bufferedWave = new BufferedWave();  
       bufferedWave.Initialize(i, graphicsDevice);  
       this._dictionaryVertexPositions.Add(i, bufferedWave);  
     }  
   }  
   public void Update()  
   {  
     if (this.Index >= this._dictionaryVertexPositions.Count - 1)  
     {  
       this.Index = 0;  
     }  
     else  
     {  
       this.Index++;  
     }  
   }  
   internal void Draw(GraphicsDevice graphicsDevice, Matrix view, Matrix projection, Vector3 offsetPosition, int offsetIndex)  
   {  
     this._effect.View = view;  
     this._effect.Projection = projection;  
     var index = this.GetIndex(this.Index + offsetIndex);  
     var item = this._dictionaryVertexPositions[index];  
     graphicsDevice.SetVertexBuffer(item.VertexBuffer);  
     graphicsDevice.Indices = item.IndexBuffer;  
     this._effect.World = Matrix.CreateTranslation(offsetPosition);  
     foreach (EffectPass pass in this._effect.CurrentTechnique.Passes)  
     {  
       pass.Apply();  
       graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, item.VertexCount);  
     }  
   }  
   private int GetIndex(int index)  
   {  
     if (index >= this._dictionaryVertexPositions.Count)  
     {  
       index -= this._dictionaryVertexPositions.Count;  
       return this.GetIndex(index);  
     }  
     return index;  
   }  
 }  

Umstellung und Fläche vergrößern
In Update entfällt nun die Übergabe des GraphicsDevice und in DrawContent, wird mit einer doppelten Schleife die Fläche mehrmals an verschiedenen Positionen gerendert.

public class ComponentMap : GameComponent  
 {  
   private const float _speed = 0.3f;  
   private readonly ComponentInputs _componentInputs;  
   public AnimatedWaterwavesBuffered AnimatedWaterwavesBuffered;  
   public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)  
   {  
     this._componentInputs = componentInputs;  
   }  
   public override void Initialize()  
   {  
     var texture = this.Game.Content.Load("rpgTile029");  
     // the buffered waves  
     this.AnimatedWaterwavesBuffered = new AnimatedWaterwavesBuffered(texture);  
     this.AnimatedWaterwavesBuffered.Initialize(this.Game.GraphicsDevice);  
   }  
   public override void Update(GameTime gameTime)  
   {  
     RasterizerState raster = new RasterizerState();  
     raster.CullMode = CullMode.CullCounterClockwiseFace;  
     raster.FillMode = FillMode.WireFrame;  
     this.Game.GraphicsDevice.RasterizerState = raster;  
     // the buffered waves  
     this.AnimatedWaterwavesBuffered.Update();  
   }  
   public void DrawContent(Matrix view, Matrix projection)  
   {  
     this.Game.GraphicsDevice.DepthStencilState = DepthStencilState.Default;  
     // the buffered waves  
     for (int iY = 0; iY < 10; iY++)  
     {  
       for (int iX = 0; iX < 10; iX++)  
       {  
         this.AnimatedWaterwavesBuffered.Draw(this.Game.GraphicsDevice, view, projection, new Vector3(200 - (iX * 20), 200 - (iY * 20), 0), 400 * iY);  
       }  
     }  
   }  
 }  

Die CameraView sollte eine neue Start Position erhalten, damit die neu erzeugte Fläche in der Mitte des RenderScreens erscheint. Zuvor war es, das die Fläche bewegt wurde. In diesen Branch kann man allerdings auch die Kmaera mit den Tasten WASD bewegen.

Leistungsgewinn
Mit 400.000 Polygonen fing die nicht gepufferte Version schnell an zu Ruckeln. Diese Anzahl sollte für ein durchschnittlichen PC oder Notebook möglich sein. Leider existieren auf dem Markt Spiele, die bei sehr geringen Grafikdetails auf kleinen System nur rückelig angezeigt werden.
Hier muß man sich bewußt sein, daß der Teil nicht von der Grafikkarte berechnet wird. Die Umstellung mit gepufferten VertexBuffern führt zu einer geringeren CPU Last und wirkt sich dementsprechend mit einer flüssigen Animation aus.

Fazit
Eigentlich hatte ich nicht vor die gepufferte Version mit rein zu nehmen. Wie immer habe ich den Rapberry Pi verwendet, um zu sehen, wie sich diese Lösung auswirkt. Zwar ruckelt es dort, aber bei weiten nicht so schlimm wie vorher.
Im Grunde war dies auch eine Problem Darstellung und eine mögliche Lösung, ohne gleich HLSL Programmierung anzusetzen. Manipulationen von Drahtgitter Modellen lassen sich besser durch die Grafikarte berechnen und das ging schon bereits mit DirectX 8.1.




Sonntag, 24. Februar 2019

Splitscreen mit MonoGame


Spiele machen mehr Spaß, wenn man sie zu zweit spielen kann. Mit MonoGame lässt sich ein Splitscreen mit geringen Aufwand umsetzen. Hierfür verwende ich von mir bereits angelegten Beispiel Code "ExampleMoveOnMap3d" aus dem Blogpost "3d Karte Bewegen".

Eingabegeräte
Für die Steuerung werden als Beispiel Tastatur Eingaben und Xbox Controller aufgeteilt. Daher wird nun vom Typen InputData eine zweite Property angelegt und entsprechend benannt. Die Eingabe muss beim jeden Aufruf immer zurück gesetzt werden, deshalb kommt ein Reset, sowie eine Normalize Methode und halten damit unsere Übersicht in ComponentInput Klasse.

   public class InputData  
   {  
     public Vector2 Move => new Vector2(this.MoveX, this.MoveY);  
     public float MoveX { get; set; }  
     public float MoveY { get; set; }  
     public void Reset()  
     {  
       this.MoveX = 0;  
       this.MoveY = 0;  
     }  
     public void Normalize()  
     {  
       this.MoveX = Math.Min(1, Math.Max(-1, this.MoveX));  
       this.MoveY = Math.Min(1, Math.Max(-1, this.MoveY));  
     }  
   } 

Bei mehreren Gamepads, könnte man die Eingabeerfassung auch in der Schleife abfragen, aber bleiben wir zunächst beim Hartverdrahten.

public class ComponentInputs : GameComponent  
   {  
     public InputData Inputs1 { get; private set; } = new InputData();  
     public InputData Inputs2 { get; private set; } = new InputData();  
     public ComponentInputs(Game game) : base(game)  
     {  
     }  
     public override void Update(GameTime gameTime)  
     {  
       this.Inputs1.Reset();  
       this.Inputs2.Reset();  
       KeyboardState stateKeyboard = Keyboard.GetState();  
       this.Inputs1.MoveX += stateKeyboard.IsKeyDown(Keys.A) ? 1 : 0;  
       this.Inputs1.MoveX -= stateKeyboard.IsKeyDown(Keys.D) ? 1 : 0;  
       this.Inputs1.MoveY += stateKeyboard.IsKeyDown(Keys.W) ? 1 : 0;  
       this.Inputs1.MoveY -= stateKeyboard.IsKeyDown(Keys.S) ? 1 : 0;  
       GamePadState stateGamePad = GamePad.GetState(PlayerIndex.One);  
       this.Inputs2.MoveX += stateGamePad.ThumbSticks.Left.X;  
       this.Inputs2.MoveY += stateGamePad.ThumbSticks.Left.Y;  
       // invert   
       this.Inputs1.MoveX *= -1;  
       // Normalize  
       this.Inputs1.Normalize();  
       this.Inputs2.Normalize();  
     }  
   }  

Die Kartenbewegung
Die Karte muss nun zweimal geladen werden, damit beide Spieler eine Ansicht über ihre Karten Position erhalten. Eine "IF" Abfrage fragt die aktuelle Eingabe ab.
Die im Titelbild zu sehenden Fahrzeuge, habe ich hier nicht mit reingenommen, da der Inhalt ähnlich des Types PlateGrassTile ist. Allerdings ist es wiederum auf meinem Githab Repository zu finden.

public class ComponentMap : GameComponent  
   {  
     private const float _speed = 0.3f;  
     private readonly ComponentInputs _componentInputs;  
     private readonly int _player;  
     private PlateGrassTile[] _groundTiles = new PlateGrassTile[9];  
     public ComponentMap(Game game, ComponentInputs componentInputs, int player) : base(game)  
     {  
       this._componentInputs = componentInputs;  
       this._player = player;  
     }  
     public override void Initialize()  
     {  
       var grass = this.Game.Content.Load("Plate_Grass_01");  
       int index = 0;  
       for (int iY = 0; iY < 3; iY++)  
       {  
         for (int iX = 0; iX < 3; iX++)  
         {  
           this._groundTiles[index] = new PlateGrassTile(this.GetPosition(iX, iY),  
                                   0.5f,   
                                   grass);  
           index++;  
         }  
       }  
     }  
     public override void Update(GameTime gameTime)  
     {  
       Vector3 positon = new Vector3();  
       if (this._player == 1)  
       {  
         positon = new Vector3(this._componentInputs.Inputs1.MoveX, this._componentInputs.Inputs1.MoveY, 0) * _speed;  
       }  
       else  
       {  
         positon = new Vector3(this._componentInputs.Inputs2.MoveX, this._componentInputs.Inputs2.MoveY, 0) * _speed;  
       }  
       foreach (var item in this._groundTiles)  
       {  
         item.Position += positon;  
       }  
     }  
     public void DrawContent(Matrix view, Matrix projection)  
     {  
       foreach (var item in this._groundTiles)  
       {  
         item.Draw(view, projection);  
       }  
     }  
     private Vector3 GetPosition(int x, int y)  
     {  
       float mapLength = 3f;  
       float distance = .02f;  
       float centerMap = (mapLength + distance)   
                 * (float)Math.Sqrt(this._groundTiles.Length) / 2; ;  
       return new Vector3((y * (mapLength + distance)) - centerMap,  
                 (x * (mapLength + distance)) - centerMap,   
                 0);  
     }  
   }  

Von der 'ComponentMap' wird nun eine zweite Instance angelegt in der Game1.cs. Die 'UpdateOrder' muss dann entsprechend angepasst werden für den ComponentRender und im Konstruktor Parameter wird die zweite Instance eingereicht.

public class Game1 : Game  
   {  
     private readonly GraphicsDeviceManager _graphics;  
     private readonly ComponentInputs _componentInputs;  
     private readonly ComponentMap _componentMapPlayer1;  
     private readonly ComponentMap _componentMapPlayer2;  
     private readonly ComponentRender _componentRender;  
     public Game1()  
     {  
       this._graphics = new GraphicsDeviceManager(this);  
       this.Content.RootDirectory = "Content";  
       this.Window.Title = "Move on map 3d";  
       this.IsMouseVisible = true;  
       this._componentInputs = new ComponentInputs(this);  
       this._componentInputs.UpdateOrder = 1;  
       this.Components.Add(this._componentInputs);  
       this._componentMapPlayer1 = new ComponentMap(this, this._componentInputs, 1);  
       this._componentMapPlayer1.UpdateOrder = 2;  
       this.Components.Add(this._componentMapPlayer1);  
       this._componentMapPlayer2 = new ComponentMap(this, this._componentInputs, 2);  
       this._componentMapPlayer2.UpdateOrder = 3;  
       this.Components.Add(this._componentMapPlayer2);  
       this._componentRender = new ComponentRender(this, this._componentMapPlayer1, this._componentMapPlayer2);  
       this._componentRender.UpdateOrder = 4;  
       this.Components.Add(this._componentRender);  
     }  
   }  


Bildausgabe aufteilen
Nun kann auch schon der Render Bereich angepasst werden. Zunächst wird eine Anpassung an der 'CameraView' Klasse vorgenommen. In der Methode 'Initialize()' muss die Projektion auf die Hälfte reduziert werden. Für die Rechte Seite muss der zu anzufangende Viewport auf die Mitte des Bildschirms verschoben werden. In 'Draw()' muss dann der Eingerichtete ViewPort für den Renderbereich zugewiesen werden.

public class CameraView  
   {  
     private readonly Game _game;  
     private readonly ComponentMap _componentContent;  
     public Vector3 Position { get; set; }  
     public Matrix View { get; private set; }  
     public Matrix Projection { get; private set; }  
     private bool _isRightView = false;  
     private Viewport _viewPort;  
     public CameraView(Game game, ComponentMap componentMap, bool isRightView)  
     {  
       this._game = game;  
       this._componentContent = componentMap;  
       this._isRightView = isRightView;  
     }  
     public void Initialize()  
     {  
       this._viewPort = this._game.GraphicsDevice.Viewport;  
       this._viewPort.Width = this._viewPort.Width / 2;  
       if (this._isRightView)  
       {  
         this._viewPort.X = this._viewPort.Width;  
       }  
       var aspectRatio = this.GetAspectRatio();  
       var position = new Vector3(-1f, 5f, 5f);  
       var target = new Vector3(0, 0, 0);  
       var farPlaneDistance = 10000;  
       this.View = Matrix.CreateLookAt(position, target, Vector3.Backward);  
       this.Projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4,  
                                     aspectRatio,  
                                     1,  
                                     farPlaneDistance);  
     }  
     public void Draw()  
     {  
       this._game.GraphicsDevice.Viewport = this._viewPort;  
       this._componentContent.DrawContent(this.View, this.Projection);  
     }  
     private float GetAspectRatio()  
     {  
       var w = (float)ApplicationView.GetForCurrentView().VisibleBounds.Width / 2;  
       var h = (float)ApplicationView.GetForCurrentView().VisibleBounds.Height;  
       return w / h;  
     }  
   }

Aus einer 'CameraView' werden nun zwei. Beide haben nun den zusätzlichen Parameter mit der sich dann der Splitinhalt von Links oder Rechts einstellen lässt. Das Initialisieren der 'CamereView', muss dann über die zu überschreibende Methode 'Initialize()' abgearbeitet werden von der geerbten Klasse 'DrawableGameComponente'.

public class ComponentRender : DrawableGameComponent  
   {  
     private readonly CameraView _cameraViewLeft;  
     private readonly CameraView _cameraViewRight;  
     public ComponentRender(Game game, ComponentMap componentContent, ComponentMap componentContent2) : base(game)  
     {  
       this._cameraViewLeft = new CameraView(game, componentContent, false);   
       this._cameraViewRight = new CameraView(game, componentContent2, true);  
     }  
     public override void Initialize()  
     {  
       this._cameraViewLeft.Initialize();  
       this._cameraViewRight.Initialize();  
     }  
     public override void Draw(GameTime gameTime)  
     {  
       this.GraphicsDevice.Clear(Color.CornflowerBlue);  
       this._cameraViewLeft.Draw();  
       this._cameraViewRight.Draw();  
     }  
   }  

Nachwort
Wie zu sehen ist, ist die Umsetzung für Split Screen nicht sonderlich kompliziert und ist auch noch in wenigen Schritten machbar. Mit ein wenig mehr Aufwand kann das OpenClosePrinzip angewendet werden, womit dann anschließend eine Umsetzung eines vier Spieler Modus erleichtert.


Links ist die Tastatur, Rechts wird vom GamePad gesteuert.

Sonntag, 17. Februar 2019

Win 10 IoT und MonoGame auf dem Raspberry Pi 3 Testen


Ohne DirectX Hardware Beschleuniger ist die Performance eher auf dem Level, hmm.. Ich drücke das mal so aus: "Es geht". Dennoch ist interessant zu testen, was man eigentlich so auf dem Raspberry Pi erreichen kann. Für diese Tests verwende ich den Raspberry Pi 3 Model B+.

Testanwendung
Vom meinem letzten Blogpost Eintrag "3d Karte bewegen (MonoGame)" habe ich einen Branch angelegt, in dem ich die Inhalte für einen kleinen Benchmark aufbereite. Zum Beispiel wird die Eingabe mit dem GamePad oder Tastatur abgestellt. Hinzu kommen dann noch weitere 3D Objekte hinzu, die im Verlauf geändert werden. In der UI selbst wird dann die FPS und Durchschnittsergebnis der verschiedenen drei Durchläufen wiedergegeben.

Testumfeld, Quick and dirty
Der Test selbst ist nur sehr einfach gestrickt und sicherlich kann dort auch Optimiert werden. Aber das sollte diesmal nicht das Thema sein.

Durchläufe:
- 9 Objekte
- 100 Objekte
- 400 Objekte

Die Objekte sind auf 0.25 skaliert. Nur bei dem ersten können wirklich alle Kacheln gesehen werden. Bei den anderen Mengen, übertreten diese den Bildschirmrand.

Auf dem PC werden immer die 60 FPS erreicht. Der eingestellte Vsync beschränkt die FPS, was jedoch nicht weiter schlimm ist. Für den Raspberry Pi sollte die Beschränkung  kein Problem darstellen. Kommen wir zu den Testergebnissen

Ergebnis (Debug)

Die Anzahl der Bilder waren nicht sonderlich stabil und schwanke sogar relativ stark. Dennoch hat man erstmal so ein paar  Ergebnisse, die man versucht zu verbessern. Da wäre das Auslassen von 3d Objekten, die nicht zu sehen sind.

Was ist mit Core Application
Bisher noch nicht genutzt, aber ich dachte, es paßt gerade hier beim Testen. Also nahm ich den ganzen Code von der aktuellen Test Branch und erstellte ein Projekt mit Core Application. Klingt zunächst super.


Ergebnis (Debug)







Ergebnis (Release)







Nun, die Ergebnisse verhalten sich fast identisch wie der Normalen UWP Projekt. Teilweise hatte ich den Eindruck, daß die Ergebnisse schlechter waren. Das lag mit unter das die Ergebnisse bei mehreren Durchläufen sehr stark schwankten. Das änderte sich auch nicht, als ich den Test für eine Stunde laufen ließ.


Was ist mit 2D?
Vielleicht sollte man nicht zuviel auf 3D Inhalte setzen und lieber auf 2D setzen. Also habe ich von meinem vorigen Post und dessen Beispiel ebenfalls ein Branch erstellt und ähnliche Test Umgebung geschaffen in 2D und zunächst als UWP.


Ergebnis (Debug)







Zugegeben, das hatte ich nicht erwartet. Vielleicht liegt das an der zu rendernden Anzahl von Pixeln, die letztendlich in den Backbuffer landen. Offensichtlich  macht die Ausführung in 2D oder 3D keinen Unterschied. Die Schwankungen verhielten sich nicht anders.

Fazit
Das Testumfeld ist nicht sonderlich Realistisch, wenn man vom Optimieren absah. Die Frage ob man MonoGame verwenden kann auf dem Raspberry Pi, dann ist die Antwort ja. Wir stellen fest, die Antwort passt nicht zu den Ergebnissen. Warum ja? Mit Kompromissen, kann man durch aus MonoGame auf dem Raspberry Pi verwenden, wenn man Kompromisse eingeht. Mein Kompromiß ist Interesse.

Kompromisse die in erster Linie bestehen:
- Geringe FPS
- Geringe Auflösung um den FPS hoch zuhalten
- Ich will sehen, wie mein MonoGame Spiel auf dem Raspberry Pi (nicht) performt

Damit will ich das Thema Raspberry und MonoGame nicht schlecht schreiben. Denn nicht alle Spiele setzen auf hohe FPS. Das könnte ein 'Click and Point Adventure' sein.


Im Beispiel Video mit dem Raspberry Pi TFT, kann leider die Native Auflösung von 480x320 vom TFT nicht eingestellt werden.

Sonntag, 10. Februar 2019

3d Karte Bewegen (MonoGame)

Zur Vollständigkeit zu meinem letzten Blog-Post wird das Beispiel in 3d Abgebildet. Abgesehen von der 3d Ansicht und Verwendung einer 3d Kamera ist die Steuerung nahe zu gleich, so das ich hier die Ausführung des Inputs auslasse.


Welches 3d Format
Normalerweise werden *.fbx und *.x 3d Formate unterstützt. Für mich war es jedoch neu, daß auch Wavefront *.obj 3d Format unterstütz wird. Das erfreuliche ist, daß z.B. viele frei verwendete 3d Modell in diesem Format vorliegen. Das erspart fehlerhafte Konvertierungen mit anderen Programmen. Wie immer verwende ich hier die Assets von Kenny.nl die nicht nur Texturen anbieten, sondern auch 3d Modell in *.obj und teilweise in *.fbx.


Pipeline 3d Importer
Vermutlich ist das noch ein Fehler in MonoGame Version 3.7.1. Wenn das 3d Model im Pipline Tool hinzugefügt wird, dann werden einige initiale Einstellungen gesetzt die bei einen Punkt manuell umgestellt werden muss.
Bei dem Wavefront Format, liegt neben der *.obj Datei auch eine *.mtl. Diese brauchen wir zunächst nicht in das Pipeline Tool hinzufügen.



Klickt ihr auf die Datei, dann werden in Properties die Einstellungen angezeigt. Hier wurde eigentlich der richtige Importer Ausgewählt. Dies las ich auch in einigen Foren Einträgen.



Fehler beim Laden des 3d Models
Falls ihr einen Ausnahmefehler erhält, das beim Laden des 3d Models auftritt.
Could not find ContentTypeReader Type. Please ensure the name of the Assembly..... Naja, ist im folgenden Bild zu sehen.


Kommt also dieser Ausnahmefehler, dann sollte der Importer von Open Asset Import Library - MonoGame umgestellt werden auf Fbx Importer - MonoGame.


Und nicht vergessen, nach dem Hinzufügen einmal auf F6 oder Build (Rebuild) klicken.


Laden des 3d Models
In das Unterverzeichnis zu Component werden die ComponentMap.cs und PlateGrassTile.cs angelegt.


Das verwendete 3d Model in dem Beispiel ist einfach eine Grüne Platte und soll dann später eine größere Fläche Abbilden. Im folgenden Code ist zu sehen, dass das 3d Objekt gedreht wird um 90 Grad. Aus der Gewohnheit her, ist Z hier die Höhe und Y die Tiefe. In Büchern wird oft Z als Tiefe und Y als Höhe angeben, was auch Technisch gesehen auch richtig ist.
Das im Konstruktor der Parameter 'Scale' angegeben wird, findet erst in im nächsten Post eine Verwendung.

public class PlateGrassTile  
 {  
   private Vector3 _offsetPosition;  
   private readonly float _scale = 1;  
   private readonly Model _model;  
   private readonly Matrix[] _transform;  
   private readonly ModelMesh _mesh;  
   private readonly bool _onlyOneMesh = false;  
   private Vector3 _offsetRotation;  
   public Vector3 Position { set; get; } = new Vector3();  
   public PlateGrassTile(Vector3 offsetPosition, float scale, Model model)  
   {  
     this._offsetPosition = offsetPosition;  
     var offsetRotateX = MathHelper.ToRadians(90);  
     this._offsetRotation = new Vector3(offsetRotateX, 0, 0);   
     this._scale = scale;  
     this._model = model;  
     this._transform = new Matrix[this._model.Bones.Count];  
     this._model.CopyAbsoluteBoneTransformsTo(this._transform);  
     this._mesh = this._model.Meshes.First();  
     this._onlyOneMesh = !this._model.Meshes.Any();  
   }  
   public void Draw(Matrix view, Matrix projection)  
   {  
     foreach (ModelMesh mesh in this._model.Meshes)  
     {  
       foreach (BasicEffect effect in mesh.Effects)  
       {  
         foreach (EffectPass effectPass in effect.CurrentTechnique.Passes)  
         {  
           effectPass.Apply();  
         }  
         effect.EnableDefaultLighting();  
         this.SetTransform(effect, mesh);  
         effect.View = view;  
         effect.Projection = projection;  
       }  
       mesh.Draw();  
     }  
   }  
   private void SetTransform(BasicEffect effect, ModelMesh mesh)  
   {  
     effect.World = this._transform[mesh.ParentBone.Index] *  
             Matrix.CreateRotationX(this._offsetRotation.X) *  
             Matrix.CreateRotationY(this._offsetRotation.Y) *  
             Matrix.CreateRotationZ(this._offsetRotation.Z) *  
             Matrix.CreateTranslation(this.Position + this._offsetPosition) *  
             Matrix.CreateScale(this._scale);  
   }  
 } 

Karte erstellen
Die Kartenerstellung wird hier simple gehalten und besteht daher aus 3x3 Felder. Für das Beispiel haben die Objekte einen kleinen Abstand zu einander. So kann man sehen, daß die neun Grasflächen erzeugt wurden. Ansonsten einfach in der Methode 'GetPosition' die Variable 'distance' auf '0f' setzten. Das 3d Objekt wird hier Initial geladen und dessen Referenz zusammen mit der Position zugewiesen in das 'PlateGrassTile' Klassen Objekt.

public class ComponentMap : GameComponent  
 {  
   private const float _speed = 0.3f;  
   private readonly ComponentInputs _componentInputs;  
   private PlateGrassTile[] _groundTiles = new PlateGrassTile[9];  
   public ComponentMap(Game game, ComponentInputs componentInputs) : base(game)  
   {  
     this._componentInputs = componentInputs;  
   }  
   public override void Initialize()  
   {  
     var grass = this.Game.Content.Load("Plate_Grass_01");  
     int index = 0;  
     for (int iY = 0; iY < 3; iY++)  
     {  
       for (int iX = 0; iX < 3; iX++)  
       {  
         this._groundTiles[index] = new PlateGrassTile(this.GetPosition(iX, iY),  
                                 0.5f,   
                                 grass);  
         index++;  
       }  
     }  
   }  
   public override void Update(GameTime gameTime)  
   {  
     foreach (var item in this._groundTiles)  
     {  
       item.Position += new Vector3(this._componentInputs.Inputs.MoveX,   
                       this._componentInputs.Inputs.MoveY, 0)   
                       * _speed;   
     }  
   }  
   public void DrawContent(Matrix view, Matrix projection)  
   {  
     foreach (var item in this._groundTiles)  
     {  
       item.Draw(view, projection);  
     }  
   }  
   private Vector3 GetPosition(int x, int y)  
   {  
     float mapLength = 3f;  
     float distance = .02f;  
     float centerMap = (mapLength + distance)   
               * (float)Math.Sqrt(this._groundTiles.Length) / 2; ;  
     return new Vector3((y * (mapLength + distance)) - centerMap,  
               (x * (mapLength + distance)) - centerMap,   
               0);  
   }  
 }  

Kamera
So richtig entscheiden konnte ich mich an der stelle nicht, wie ich die Komponente nenne. Render oder Camera. Es wird gerendert, aber auch die Kameraausrichtung vorgenommen. Auftrennen wäre hier zunächst übertrieben, dennoch macht es Sinn die wesentlichen Aspekte der Kamera zu einer Klasse zusammen zufassen.



Die Klasse 'CameraView.cs' setz alle Einstellungen in der Initialize Methode fest. Initial muß auch die AspectRatio gesetzt werden, da mit der UWP vorher nicht festgelegt wird, welche Auflösung verwendet wird.

 public class CameraView  
 {  
   private readonly Game _game;  
   private readonly ComponentMap _componentContent;  
   public Matrix View { get; private set; }  
   public Matrix Projection { get; private set; }  
   public CameraView(Game game, ComponentMap componentMap)  
   {  
     this._game = game;  
     this._componentContent = componentMap;  
   }  
   public void Initialize()  
   {  
     var aspectRatio = this.GetAspectRatio();  
     var position = new Vector3(-1f, 5f, 5f);  
     var target = new Vector3(0, 0, 0);  
     var farPlaneDistance = 10000;  
     this.View = Matrix.CreateLookAt(position, target, Vector3.Backward);  
     this.Projection = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4,  
                                   aspectRatio,  
                                   1,  
                                   farPlaneDistance);  
   }  
   public void Draw()  
   {  
     this._componentContent.DrawContent(this.View, this.Projection);  
   }  
   private float GetAspectRatio()  
   {  
     var w = (float)ApplicationView.GetForCurrentView().VisibleBounds.Width;  
     var h = (float)ApplicationView.GetForCurrentView().VisibleBounds.Height;  
     return w / h;  
   }  
 } 

Die 'ComponentRender' Klasse ist hier sehr übersichtlich und hat hier auch sonst keine Besonderheit. Mit dem nächsten Blogpost wird jedoch dieser Bereich erweitert.

 public class ComponentRender : DrawableGameComponent  
 {  
   private readonly CameraView _cameraView;  
   public ComponentRender(Game game, ComponentMap componentContent) : base(game)  
   {  
     this._cameraView = new CameraView(game, componentContent);  
     this._cameraView.Initialize();  
   }  
   public override void Draw(GameTime gameTime)  
   {  
     this.GraphicsDevice.Clear(Color.CornflowerBlue);  
     this._cameraView.Draw();  
   }  
 }  

Der Rest des Codes ist nahe zu Identisch wie bei dem letzten Post, wie man eine 2d Karte bewegt.

Nachwort
Diese Ausführung funktioniert noch gut auf dem Raspberry Pi 3. Hektische Spiele sollten man denn noch nicht anversieren.


Sonntag, 20. Januar 2019

Nicht ich, sondern der Boden bewegt sich (MonoGame)


Für die Lösung, wie eine Figur auf der Karte Bewegt werden kann, ist davon abhängig, wieviel Leistung mein Ziel System hat. In den meisten Fällen hat man mehr als genügend Leistung, so dass man sich hierbei entscheiden kann. Für relativ schwache System, z.B. wie bei einem Arduino Esplora kann eine Figur auf dem Bildschirm bewegt werden, ohne einem starken flackern. Eine Karte auf dem Bildschirm zu bewegen sieht dagegen wieder schlecht aus, weil der Bildaufbau zu lange dauert. Und das bei einer Auflösung von 128x160 Pixeln.

Nicht die Figur, sondern die Karte bewegt sich
Nun zurück zum Eigentlichen. Die Figur ist immer mittig auf dem Bildschirm und bewegt sich auf einer Karte. Aber Technisch gesehen, wird die Karte bewegt und die Figur hat eine Laufanimation.

Neues MonoGame Projekt
Aktuell wird für dieses Beispiel eine Neue Solution angelegt mit dem Ziel einer UWP Anwendung. Die Zielplattform und Version hat jedoch keinen Effekt für das Beispiel. Der Grund für UWP ist, das die Lauffähigkeit auf PC, Tablet, Raspberry Pi 2&3 und wenn ich mich nicht irre, auch auf der Xbox funktionieren soll. Sollte die Anwendung/Spiel auch auf dem Windows Phone laufen, dann ist eine ältere Minimum Ziel Version von Windows 10 einzustellen.



Ordnung ist das halbe Leben
Als erstes brauchen wir eine Projekt Struktur und legen ein paar Ordner an. Components wird es später drei Komponenten geben, die für die Benutzereingaben, die Karteverarbeitung und das Zeichnen zuständig sind.





















Der Umfang für das Realisieren benötigt einige Zeilen Code, so das eine Aufteilung der Inhalte Notwendig sind. Deshalb kann zunächst in der 'Game1.cs' fast alles weg. Nur der Konstruktor bleibt.

 using Microsoft.Xna.Framework;  
 namespace ExampleWalkingOnMap  
 {  
   public class Game1 : Game  
   {  
     private GraphicsDeviceManager _graphics;  
     public Game1()  
     {  
       this._graphics = new GraphicsDeviceManager(this);  
       this.Content.RootDirectory = "Content";  
       this.Window.Title = "Example Walking on map";  
       this.IsMouseVisible = true;  
     }  
   }  
 } 

Eingabegerät
Für das erfassen von Tasteneingaben, wird die Klasse 'ComponentInputs.cs' und für die Steuerinformation 'InputData.cs' angelegt.








Zunächst muss die 'InputData.cs' bearbeitet werden, um die verarbeiteten Steuerinformationen weiter zutragen.

 using Microsoft.Xna.Framework;  
 namespace ExampleWalkingOnMap.Components.Inputs  
 {  
   public class InputData  
   {  
     public Vector2 Move => new Vector2(this.MoveX, this.MoveY);  
     public float MoveX { get; set; }  
     public float MoveY { get; set; }  
   }  
 } 

Mit der ‚InputData‘ können die Eingaben in ComponentInputs.cs aufgenommen werden. Im Beispiel wird die Tastatur und ein Xbox Controller eingelesen. Damit wäre die Eingabe Komponente fertig.

using Microsoft.Xna.Framework;  
 using Microsoft.Xna.Framework.Input;  
 using System;  
 namespace ExampleWalkingOnMap.Components.Inputs  
 {  
   public class ComponentInputs : GameComponent  
   {  
     public InputData Inputs { get; private set; } = new InputData();  
     public ComponentInputs(Game game) : base(game) {  }  
     public override void Update(GameTime gameTime)  
     {  
       this.Inputs.MoveX = 0;  
       this.Inputs.MoveY = 0;  
       KeyboardState stateKeyboard = Keyboard.GetState();  
       this.Inputs.MoveX += stateKeyboard.IsKeyDown(Keys.A) ? 1 : 0;  
       this.Inputs.MoveX -= stateKeyboard.IsKeyDown(Keys.D) ? 1 : 0;  
       this.Inputs.MoveY += stateKeyboard.IsKeyDown(Keys.W) ? 1 : 0;  
       this.Inputs.MoveY -= stateKeyboard.IsKeyDown(Keys.S) ? 1 : 0;  
       GamePadState stateGamePad = GamePad.GetState(PlayerIndex.One);  
       this.Inputs.MoveX += stateGamePad.ThumbSticks.Left.X * -1;  
       this.Inputs.MoveY += stateGamePad.ThumbSticks.Left.Y;  
       // Normalize  
       this.Inputs.MoveX = Math.Min(1, Math.Max(-1, this.Inputs.MoveX));  
       this.Inputs.MoveY = Math.Min(1, Math.Max(-1, this.Inputs.MoveY));  
     }  
   }  
 }

Die Karte
Die Realisierung der Karte wird in Kacheln unterteilt und ist auch relativ leicht um zusetzten. Jede Kachel hat eine eigene Position und eine Textur, womit schon mal das nächste Objekt definiert ist.





















Für die Textur mit verschiedenen Untergründen wird eine Bilddatei zusammen gefasst, die die Unterschiedlichen Bodentexturen beinhaltet. Jedes Objekt kennt dann nur den Ausschnitt aus der Bilddatei. Für das Beispiel verwende ich die Texturen von https://kenney.nl/assets.











Im Projekt wurde bereits mit der Vorlage, der Ordner 'Content' angelegt. Hier wird nun die Textur abgelegt.






Nun muss die Bilddatei noch mit dem Pipline Tool verarbeitet werden, indem ihr doppelkick auf 'Content.mgcb' ausführt. Im Ausschnitt Projekt wird die Bilddatei hinzugefügt.
















Wirkt leider etwas doppelt, aber dies ist notwendig, damit die Textur später im Programmcode geladen werden kann.

















Anschließend auf den Button 'Build' oder F6 drücken und fertig ist dieser Teil und könnt das Tool  wieder schließen.















Nun zurück zum Code für die Kachel Information. Wie bereits erwähnt, kennt die Kachel nur die Position der Textur, die später über die Methode Draw hinein gereicht wird. Mit dem weiteren Parameter 'offset' wird bestimmt, wo im Fenster gerendert wird.

using Microsoft.Xna.Framework;  
 using Microsoft.Xna.Framework.Graphics;  
 namespace ExampleWalkingOnMap.Components.Map  
 {  
   public class GroundTile  
   {  
     private Rectangle _texturePosition;  
     public GroundTile(int x, int y, int width, int height)  
     {  
       this._texturePosition = new Rectangle(x, y, width, height);  
     }  
     public void Draw(Texture2D textureMapTiles, SpriteBatch spriteBatch, Vector2 offset)  
     {  
       spriteBatch.Draw(textureMapTiles,  
                offset,  
                this._texturePosition,  
                Color.White,  
                0f,  
                new Vector2(0, 0),  
                new Vector2(1, 1),  
                SpriteEffects.None,  
                0f);  
     }  
   }  
 } 

Die Karte soll aus 10x10 Kacheln bestehen und wird in einer Hilfsklasse erstellt.






Mit dem zwei Dimensionalen Integer Array, lässt sich visuell schnell eine Karte dieser Größe  zusammenstellen, die wiederum noch übersichtlich ist. Zumindest im ersten Abschnitt der Methode. Anschließend wird hier in das Ziel Array gemappt mit den eigentlichen Ziel Objekt.

using System;  
 namespace ExampleWalkingOnMap.Components.Map  
 {  
   internal class MapHelper  
   {  
     internal static GroundTile[,] CreateMap()  
     {  
       int[][] map = new int[10][];  
       map[0] = new int[10] { 0, 0, 0, 0, 0, 0, 0, 0, 2, 2 };  
       map[1] = new int[10] { 0, 0, 0, 0, 0, 0, 0, 0, 2, 2 };  
       map[2] = new int[10] { 1, 1, 1, 1, 1, 1, 1, 0, 2, 2 };  
       map[3] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 2, 2 };  
       map[4] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 2, 2 };  
       map[5] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 2, 2 };  
       map[6] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 2, 2 };  
       map[7] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 0, 0 };  
       map[8] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 3, 3 };  
       map[9] = new int[10] { 0, 0, 0, 0, 0, 0, 1, 0, 3, 0 };  
       GroundTile[,] groundTiles = new GroundTile[10, 10];  
       for (int iY = 0; iY < 10; iY++)  
       {  
         for (int iX = 0; iX < 10; iX++)  
         {  
           groundTiles[iY, iX] = GetMappingGroundTile(map[iY][iX]);  
         }  
       }  
       return groundTiles;  
     }  
     private static GroundTile GetMappingGroundTile(int groundTile)  
     {  
       switch (groundTile)  
       {  
         case 0:  
           return new GroundTile(0, 0, 64, 64);  
         case 1:  
           return new GroundTile(64, 0, 64, 64);  
         case 2:  
           return new GroundTile(0, 64, 64, 64);  
         case 3:  
           return new GroundTile(64, 64, 64, 64);  
       }  
       throw new ArgumentException("groundTile can be only 0, 1, 2 and 3");  
     }  
   }  
 }  

Für die Verwaltung der Karte wird die Klasse 'ComponentMap.cs' erstellt. Dort werden die Karteninhalte gesteuert und gehalten.






Der Umfang der Klasse ist hier größer als bei den anderen, auch wenn dessen Ausführung sehr klein gehalten ist.

using ExampleWalkingOnMap.Components.Inputs;  
 using Microsoft.Xna.Framework;  
 using Microsoft.Xna.Framework.Graphics;  
 namespace ExampleWalkingOnMap.Components.Map  
 {  
   public class ComponentMap : GameComponent  
   {  
     private readonly ComponentInputs _inputs;  
     private GroundTile[,] _mapTiles;  
     private Texture2D _textureMapTiles;  
     private int _mapTileWidth, _mapTileHeight;  
     private Vector2 _screenOffset, _movePosition;  
     public Vector2 Position => new Vector2(this._movePosition.X + this._screenOffset.X,  
                         this._movePosition.Y + this._screenOffset.Y);  
     private int _mapWidth, _mapHeight;  
     public ComponentMap(Game game, ComponentInputs inputs) : base(game) => this._inputs = inputs;  
     internal void SetScreenOffset(float screenWidth, float screenHeight)  
     {  
       var centerScreenWidth = screenWidth / 2;  
       var centerScreenHeight = screenHeight / 2;  
       this._screenOffset = new Vector2(centerScreenWidth, centerScreenHeight);  
       this._movePosition = new Vector2(0, 0);  
     }  
     public override void Initialize()  
     {  
       using (var stream = TitleContainer.OpenStream("Content/MapTiles.png"))  
       {  
         this._textureMapTiles = Texture2D.FromStream(this.Game.GraphicsDevice, stream);  
       }  
       this._mapTiles = MapHelper.CreateMap();  
       this._mapTileWidth = this._textureMapTiles.Width / 2;  
       this._mapTileHeight = this._textureMapTiles.Height / 2;  
       this._mapWidth = this._mapTileWidth * this._mapTiles.GetLength(0);  
       this._mapHeight = this._mapTileHeight * this._mapTiles.GetLength(1);  
     }  
     public override void Update(GameTime gameTime)  
     {  
       this._movePosition += this._inputs.Inputs.Move * 2f;  
     }  
     public void DrawMapTiles(SpriteBatch spriteBatch)  
     {  
       for (int iY = 0; iY < this._mapTiles.GetLength(0); iY++)  
       {  
         for (int iX = 0; iX < this._mapTiles.GetLength(1); iX++)  
         {  
           Vector2 textureOffset = new Vector2(iX * this._mapTileWidth, iY * this._mapTileHeight);  
           Vector2 offset = this.Position + textureOffset;  
           this._mapTiles[iY, iX].Draw(this._textureMapTiles, spriteBatch, offset);  
         }  
       }  
     }  
   }  
 }

In der Update Methode wird die Bewegung mit 2 Multipliziert. Wenn die Bewegung zu langsam erscheint, dann kann hier der Wert erhöht werden. Wie bereits angemerkt, zeigt dieser Teil das Nötigste, um eine Karte anzulegen und für das Rendern vorzubereiten.

Rendern
Am Ende muss die Karte auf dem Bildschirm gerendert werden. Dazu wird noch eine weitere Komponente angelegt mit dem Namen 'ComponentRender.cs'.




Damit die Karte auf die Start Position in die Mitte zentriert wird, wird die Größe des Fensters eingelesen und dessen Werte für den Offset der Karte zugewiesen.

using ExampleWalkingOnMap.Components.Map;  
 using Microsoft.Xna.Framework;  
 using Microsoft.Xna.Framework.Graphics;  
 using Windows.UI.ViewManagement;  
 namespace ExampleWalkingOnMap.Components.Render  
 {  
   public class ComponentRender : DrawableGameComponent  
   {  
     private readonly ComponentMap _componentMap;  
     private SpriteBatch _spriteBatch;  
     public ComponentRender(Game game, ComponentMap componentMap) : base(game)  
     {  
       this._componentMap = componentMap;  
     }  
     public override void Initialize()  
     {  
       this._spriteBatch = new SpriteBatch(this.GraphicsDevice);  
       var screenWidth = (float)ApplicationView.GetForCurrentView().VisibleBounds.Width;  
       var screenHeight = (float)ApplicationView.GetForCurrentView().VisibleBounds.Height;  
       this._componentMap.SetScreenOffset(screenWidth, screenHeight);  
     }  
     public override void Draw(GameTime gameTime)  
     {  
       this.GraphicsDevice.Clear(Color.DarkBlue);  
       this._spriteBatch.Begin();  
       this._componentMap.DrawMapTiles(this._spriteBatch);  
       this._spriteBatch.End();  
     }  
   }  
 }  

Zurück zum Start
Am Ende müssen die Komponenten in die GameComponentCollection hinzugefügt werden, damit die Inhalte ausgeführt werden. Dazu geht es wieder in die 'Game1.cs' Code Datei. Hier werden in den Member die Komponenten eingetragen und im Konstruktor wird die 'UpdateOrder' zugewiesen und schließlich in die Collection aufgenommen.

 using ExampleWalkingOnMap.Components.Inputs;  
 using ExampleWalkingOnMap.Components.Map;  
 using ExampleWalkingOnMap.Components.Render;  
 using Microsoft.Xna.Framework;  
 namespace ExampleWalkingOnMap  
 {  
   public class Game1 : Game  
   {  
     private GraphicsDeviceManager _graphics;  
     private ComponentInputs _componentInputs;  
     private ComponentMap _componentMap;  
     private ComponentRender _componentRender;  
     public Game1()  
     {  
       this._graphics = new GraphicsDeviceManager(this);  
       this.Content.RootDirectory = "Content";  
       this.Window.Title = "Example Walking on map";  
       this.IsMouseVisible = true;  
       this._componentInputs = new ComponentInputs(this);  
       this._componentInputs.UpdateOrder = 1;  
       this.Components.Add(this._componentInputs);  
       this._componentMap = new ComponentMap(this, this._componentInputs);  
       this._componentMap.UpdateOrder = 2;  
       this.Components.Add(this._componentMap);  
       this._componentRender = new ComponentRender(this, this._componentMap);  
       this._componentRender.UpdateOrder = 3;  
       this.Components.Add(this._componentRender);  
     }  
   }  
 }  

Wenn ich hier im Blog-Post keinen Code ausgelassen habe, dann sollte sich die Anwendung starten lassen und die Karte mit den Tasten AWSD sich bewegen lassen. Oder auch mit dem Xbox Controller.



Nachwort
Mit ein wenig mehr Code, kann man dann einige Unreinheiten beheben, wie z.B. die Übergänge von Kachel zu Kachel sehen nicht immer sauber aus oder das der Untergrund Einfluss auf die Laufgeschwindigkeit hat. Und Überhaupt fehlt die Laufende Figur in der Mitte.

Die Anwendung läuft soweit auch auf einem Raspberry Pi mit Windows 10 IoT. Jedoch sollte man keine hohen Performance erwarten, da hier keine DirectX Unterstützung vorhanden ist. Im Folgenden Video zeige ich die Anwendung  auf einem Bildschirm mit einer Eingestellten Auflösung von 640x350 Pixeln (Nativ sind jedoch 480x320). In HD, also 1920x1080p, sind weniger als fünf Bilder pro Sekunde zu erwarten. Zudem ist der Boot Vorgang relativ lang und bis die MonoGame App gestartet ist. Da vergehen schon einige Minuten.
Der Aufbau und das Prinzip funktioniert auch mit Windows Forms, WPF und XAML. Auch wenn diese UI Technologien nicht dafür gedacht sind, kann man sehen zumindest sehen wie Leistungsfähig die anderen sind.

Die fertige Solution habe ich wie immer auf dem GitHub geladen. Zusätzlich sind die meisten Inhalte kommentiert.




Ameisen Simulation und andere Dinge

Wer lange sich schon mit C# beschäftig, hat sicherlich schon mal was von AntMe gelesen oder gehört. Diese Idee hatte ich aufgegriffen und mi...