Atoms

Denna artikel kommer inte att ha något färdigt projekt att ladda hem i slutet. Tanken är att du som läsare ska kunna följa artikeln och skapa det som i slutändan kommer att bli en spelidé som vi kallar ”atoms”. Spelidén är inte färdig men innehåller ett moment av ASMR-ljud. Ungefär som när du inte kan sluta ”ploppa” plastbubblor. Låter det lite knasigt? Kolla videon nedan så förstår du nog vad vi menar.

Denna artikel passar dig som redan har gjort några enkla MonoGame projekt och vill testa något som innehåller lite klasser och fysik.

Design

Som videon ovan visar så ska vi leka lite med enkel fysik och grafik och skapa ”atomer”. Det finns 4 olika tillstånd för våra atomer; trippel blå, trippel röd, röd och död. Trippel röd är den atom vi styr med musen innan den delas till 3 röda delar. De röda delarna kan i sin tur dela trippel blå till nya röda. De röda delarna lever en kort stund innan de sönderfaller och dör.

Vi kommer att behöva några olika klasser.

  • Atom – en beskrivning av en atom oavsett vilket tillstånd den befinner sig i.
  • AtomComparer – en hjälpklass för att sortera atomer. Denna klass kommer först att användas i slutskedet av projektet.
  • AtomManager – en hanterare för alla atomer som sköter kollisionshantering och uppritning.
  • AtomState – en enum för att beskriva de tillstånd atomerna kan befinna sig i.
  • AtomGame – en standard MonoGame Game-klass som vi bara bytt namn på.

Ett nytt projekt & några klasser

  • Skapa ett nytt MonoGame projekt, t.ex. ”MonoGame Windows Projekt”.
  • Byt namn på Game1.cs till AtomGame.cs
  • Lägg till nya tomma klasser; AtomComparer.cs, Atom.cs, AtomManager.cs, AtomState.cs

Du bör nu ha en Solution Explorer som liknar bilden. Vi har inte lagt till en enda rad kod än, vi har bara förberett strukturen på projektet så det blir lättare att följa guiden.

Content (grafik, ljud, fonter)

Nästa steg är att lägga till all grafik och ljud som vi kommer att använda. Det är bara 4 filer; ball.bmp, redball.bmp, gameFont.spritefont och bloop.wav.

1. Ladda hem filen atoms_content.zip vi länken ovan.

2. Extrahera (packa upp) filerna, t.ex. på ditt Skrivbord. De kan inte ligga kvar i zip-filen om du ska använda dem.

3. Starta MonoGame Pipeline Tool via Visual Studio (dubbelklicka på Content.mgcb). Högerklicka och lägg till alla filer med ”Add”->”Existing Item..”.

Om alla filer är inlagda ska du kunna bygga din Content antingen via menyn ”Build” eller snabbtangent F6. Resultatet borde bli grönt och se ut som:

Atomernas tillstånd

Den första kodfil vi ska bearbeta är AtomState.cs. Vi behöver kunna skilja på de olika tillstånd som atomerna kommer att befinna sig i. Vi har redan nämnt dem en gång. En smidig variant är att använda enum för sådana fasta vädren. Filen blir ganska kort:

AtomState.cs
namespace Atoms
{
    public enum AtomState
    {
        TRIPPLE_BLUE,
        RED,
        TRIPPLE_RED,
        DEAD
    }
}

Atomen

Vi behöver sedan lägga in kod som beskriver hur en atom (oavsett tillstånd) ska fungera i filen Atom.cs.

Atomen behöver givetvis data om vilken position den har (Position), hastighet (_speed), rotation (Angle), vilket tillstånd den befinner sig i (Type), etc. Beroende på tillstånd så kommer atomen ha en olika stor radie (Radius). Atomen kommer också att rotera med en hastighet (_angle_speed) antingen mot- eller medsols. När uppritningen sedan ska ske så måste vi veta i vilken ordning atomerna ska ritas, därför har varje atom en Zorder. Om du tänker dig ett koordinatsystem med X- och Y-axel så för vi in en tänkt tredje Z-axel. I detta fall är det bara en tankemodell där vi ger varje atom ett Z-värde för sortering när den sen ska ritas ut.

Vi kikar på den färdiga klassen och tittar sen på fler detaljer.

Atom.cs
using System;
using Microsoft.Xna.Framework;

namespace Atoms
{
    public class Atom
    {
        public Vector2 Position { get; set; }
        public double Angle { get; set; }
        public float Radius { get; set; }
        public AtomState Type { get; set; }
        public float Zorder { get; set; }

        private Vector2 _speed;
        private double _angle_speed;

        public Atom()
        {
            Radius = 10;
            Type = AtomState.TRIPPLE_BLUE;
            _angle_speed = 0.05;
            if (AtomGame.Random.NextDouble() > 0.5)
                _angle_speed = 0.05;
            else
                _angle_speed = -0.05;

            Position = new Vector2(AtomGame.Random.Next(AtomGame.Width), 
                AtomGame.Random.Next(AtomGame.Height));
            double direction = AtomGame.Random.NextDouble() * 2 * Math.PI;
            _speed = new Vector2(0.5f * (float)Math.Cos(direction), 
                0.5f * (float)Math.Sin(direction));
        }

        public Atom(AtomState type)
            : this()
        {
            Type = type;
            _speed = new Vector2();

            _angle_speed = 0.05;

            if (type == AtomState.RED)
                Radius = 5;
        }

        public Atom(Vector2 position, Vector2 speed, AtomState type)
            : this(type)
        {
            Position = position;
            _speed = speed;
        }

        public virtual void Update(GameTime gameTime)
        {
            Position += _speed;

            if (Type == AtomState.RED)
            {
                _speed *= 0.98f;
                if (_speed.Length() < 0.10f)
                    Type = AtomState.DEAD;
            }

            Angle += _angle_speed;

            if (Position.X - Radius < 0)
            {
                _speed.X = -_speed.X;
                Position = new Vector2(Radius, Position.Y);
            }
            if (Position.X + Radius > AtomGame.Width)
            {
                _speed.X = -_speed.X;
                Position = new Vector2(AtomGame.Width - Radius, Position.Y);
            }
            if (Position.Y - Radius < 0)
            {
                _speed.Y = -_speed.Y;
                Position = new Vector2(Position.X, Radius);
            }
            if (Position.Y + Radius > AtomGame.Height)
            {
                _speed.Y = -_speed.Y;
                Position = new Vector2(Position.X, AtomGame.Height - Radius);
            }

        }
    }
}

Konstruktorer

Efter våra data-attribut i klassen så kommer 3 olika konstruktorer. Varför 3 olika kan man undra? Jo ibland underlättar det att kunna skapa objekten på olika sätt. Vi har först den så kallade ”baskonstruktorn” (default constructor) Atom() som inte har några inparametrar. Denna konstruktor gör inställningar som gäller alla sorter atomer. I konstruktorn sätter vi radien till 10, tillståndet till TRIPPLE_BLUE, slumpar en rotationsriktning (rad 22-25), slumpar en startposition (rad 27), slumpar en riktning (rad 29) och sätter en hastighet baserad  på riktningen (rad 30).

Riktningen är lite speciell då värdet kommer att variera mellan 0 och 2π. Detta då datorer (och vetenskap generellt) räknar med radianer istället för grader (0-360°). Men det visste du säkert redan.

Svaret på varför vi vill ha 3 olika konstruktioner är just att vi kan göra fler sätt som kanske gör lite speciella inställningar. Ibland vill vi skapa en annan typ än TRIPPLE_BLUE. Vi har därför Atom(AtomState type) som variant där vi kan skicka in vilken typ vi vill skapa. Det som är praktiskt med vår design är att vi återanvänder Atom()-konstruktorn genom att på rad 35 ange : this(). På så viss kommer Atom()-konstruktorn köras innan Atom(AtomState type)-konstruktorn körs.

Sista varianten är Atom(Vector2 position, Vector2 speed, AtomState type) som också anger position och hastighet. Märk väl att denna i sin tur använder Atom(AtomState type) via rad 47 : this(type) som i sin tur använder Atom().

Update

Kvar har vi Update-metoden. Här sker själva förflyttningen av atomen (rad 55). Om typen är RED så sjunker hastigheten varje frame med 2%. Om sedan hastigheten blir för låg så sönderfaller atomen helt och försvinner, vi sätter då tillståndet till DEAD. Rotationen uppdateras på rad 64.

Rad 66-85 genomför koll så att atomen inte åker utanför skärmen. Om man hamnar utanför i någon riktning så vänds respektive hastighet i X- eller Y-led.

Sortering av atomer

Vi kommer att behöva sortera våra atomer för att optimera kollisionsberäkningarna. Om vi inte sorterar dem så kommer vi att få problem! Vi kommer att ha två listor med atomer, de blåa atomerna och de röda atomerna. Vi ska nu försöka förklara själva problemet först.

Här har vi 180 atomer. När bilden tas finns det ca 120 blåa och ca 120 röda atomer. För att kontrollera kollision behöver varje blå atom kollas mot varje röd atom. Hur många kombinationer blir det?
Jo, det blir 120*120 = 14 400 kollar varje frame!

Här hade vi 1000 blåa atomer. Innan alla sprängdes så hade vi vid en tidpunkt 500 blåa och 1500 röda atomer. Antal kombinationer blir 500*1500 = 750 000 varje frame!

Problemet som beskrivs ovan är att antalet beräkningar växer kvadratiskt med antalet atomer. På dataspråk brukar man då tala om ett ”Ordo”, O(n2) problem. Resultatet blir att CPU’n blir överbelastad och hinner inte med, vilket i sin tur resulterar i ”frame drops”, alltså att uppritningen hackar.

I filen AtomComparer.cs skriver vi en klass som talar om hur vi kan sortera atomerna. Tanken är att vi sorterar dem efter vilket X-värde de har på skärmen. Det kommer att kunna ge oss två sorterade listor på blåa respektive röda atomer när vi sedan ska börja jämföra och se om de kolliderar. Kodfilen kommer att se ut såhär:

AtomComparer.cs
using System.Collections.Generic;

namespace Atoms
{
    public class AtomComparer : IComparer<Atom>
    {
        public int Compare(Atom b1, Atom b2)
        {
            if (b1.Position.X > b2.Position.X)
                return -1;
            else if (b1.Position.X == b2.Position.X)
                return 0;
            else
                return 1;
        }
    }
}

När vi sedan kollar kollision så går vi precis som tidigare igenom varje blå atom men istället för att kolla mot alla röda atomer så kan vi nu göra ett urval. Om vi håller reda på ett startIndex i den röda listan för vilka atomer som rent X-värdemässigt skulle kunna kollidera. Då kan vi utgå från startIndex och fortsätta ”framåt” i listan tills vi når X-värde som är större än den blå atomens X-värde + radie. Låter konstigt? Kolla bilden nedan.

Eftersom alla atomer är sorterade efter X-värde i våra listor så kommer vi aldrig att behöva kolla alla blåa atomer mot alla röda atomer längre. Vi kan nu kolla alla blåa atomer mot alla röda atomer som befinner sig inom samma gula ”remsa” som bilden ovan visar.

Antalet beräkningar i fallet med 1000 atomer blir nu annorlunda! Hur många röda atomer kan vi tänka oss i snitt per ”remsa”? Om det finns 1500 röda så kanske 20 ungefär. Totalt alltså 500*20 = 10 000 beräkningar. Inte illa! Även om vi  gissar fel och det är 40 per ”remsa” så blir det 500*40 = 20 000 vilket inte är mycket mer beräkningar än när vi hade 180 atomer utan optimering.

AtomManager

Vi är nu redo att diskutera hur en klass som håller reda på alla atomer skulle kunna se ut. Klassen ska ha ansvar för att uppdatera, rita ut, kolla kollision samt dela atomer. Resultatet är AtomManager.cs

AtomManager.cs
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using System;
using System.Collections.Generic;

namespace Atoms
{
    class AtomManager
    {
        public int BlueAtomsCount => _blueAtoms.Count;

        private List<Atom> _blueAtoms = new List<Atom>();
        private List<Atom> _redAtoms = new List<Atom>();
        private Atom _startBall;
        private SoundEffect _soundFx;
        private Texture2D _textureBlue;
        private Texture2D _textureRed;
        private Matrix _rot120Deg = Matrix.CreateFromAxisAngle(Vector3.UnitZ, 
            (float)(2 * Math.PI / 3.0));
        private int _ballCount;
        private int _soundDelay;
        MouseState _previousMouseState = new MouseState();

        public void LoadContent(ContentManager content)
        {
            _textureBlue = content.Load<Texture2D>("ball");
            _textureRed = content.Load<Texture2D>("redball");
            _soundFx = content.Load<SoundEffect>("bloop");
        }

        public void Restart()
        {
            _redAtoms.Clear();
            _blueAtoms.Clear();
            _ballCount = 0;

            for (int i = 0; i < 180; i++)
            {
                _ballCount++;
                var atom = new Atom();
                atom.Zorder = _ballCount / 100000.0f;
                _blueAtoms.Add(atom);
            }

            _startBall = new Atom(AtomState.TRIPPLE_RED);
            _startBall.Zorder = 1.0f;
        }

        public virtual void Update(GameTime gameTime)
        {
            _soundDelay--;
            if (_soundDelay < 0)
                _soundDelay = 0;

            MouseState mouseState = Mouse.GetState();
            _redAtoms.ForEach(a => a.Update(gameTime));
            _redAtoms.RemoveAll(a => a.Type == AtomState.DEAD);

            int startIndex = 0;

            for (int i = 0; i < _blueAtoms.Count; i++)
            {
                if (_blueAtoms[i].Type == AtomState.DEAD)
                {
                    _blueAtoms.RemoveAt(i);
                    i--;
                    continue;
                }
                _blueAtoms[i].Update(gameTime);

                for (int j = startIndex; j < _redAtoms.Count; j++)
                {
                    if (_blueAtoms[i].Position.X + _blueAtoms[i].Radius < 
                        _redAtoms[j].Position.X - _redAtoms[j].Radius)
                    {
                        startIndex = j;
                        continue;
                    }
                    else if (_blueAtoms[i].Position.X - _blueAtoms[i].Radius > 
                        _redAtoms[j].Position.X + _redAtoms[j].Radius)
                        break;
                    else
                        CheckCollision(_blueAtoms[i], _redAtoms[j]);
                }
            }

            if (_startBall != null)
            {
                _startBall.Update(gameTime);
                _startBall.Position = new Vector2(mouseState.X, mouseState.Y);
            }

            if (_previousMouseState.LeftButton == ButtonState.Pressed
                && mouseState.LeftButton == ButtonState.Released
                && _startBall != null)
            {
                Explode(_startBall);
                _startBall = null;
            }

            _redAtoms.Sort(new AtomComparer());
            _blueAtoms.Sort(new AtomComparer());

            _previousMouseState = mouseState;
        }

        public virtual void Draw(SpriteBatch spriteBatch)
        {

            foreach (Atom atom in _blueAtoms)
                DrawAtom(atom, spriteBatch);

            foreach (Atom atom in _redAtoms)
                DrawAtom(atom, spriteBatch);

            if (_startBall != null)
                DrawAtom(_startBall, spriteBatch);
        }

        public void CheckCollision(Atom atom1, Atom atom2)
        {
            if (!((atom1.Type == AtomState.RED && atom2.Type == AtomState.TRIPPLE_BLUE)
                || (atom2.Type == AtomState.RED && atom1.Type == AtomState.TRIPPLE_BLUE)))
                return;

            float dist = (atom1.Position - atom2.Position).LengthSquared();
            if (dist < (atom1.Radius + atom2.Radius) * (atom1.Radius + atom2.Radius))
            {
                Explode(atom1);
                Explode(atom2);

                if (atom1.Type == AtomState.RED)
                    atom1.Type = AtomState.DEAD;
                if (atom2.Type == AtomState.RED)
                    atom2.Type = AtomState.DEAD;
            }
        }

        public void Explode(Atom atom)
        {
            if (!(atom.Type == AtomState.TRIPPLE_RED || 
                atom.Type == AtomState.TRIPPLE_BLUE))
                return;

            atom.Type = AtomState.DEAD;
            if (_soundDelay == 0)
            {
                _soundFx.Play();
                _soundDelay = 1;
            }

            float radius = 5;
            Vector2 v1 = new Vector2((float)Math.Cos(atom.Angle), 
                (float)Math.Sin(atom.Angle)) * radius;
            Vector2 v2 = Vector2.Transform(v1, _rot120Deg);
            Vector2 v3 = Vector2.Transform(v2, _rot120Deg);

            _redAtoms.Add(new Atom(atom.Position + v1, 0.4f * v1, AtomState.RED));
            _redAtoms.Add(new Atom(atom.Position + v2, 0.4f * v2, AtomState.RED));
            _redAtoms.Add(new Atom(atom.Position + v3, 0.4f * v3, AtomState.RED));
        }

        private void DrawAtom(Atom atom, SpriteBatch spriteBatch)
        {
            if (atom.Type == AtomState.DEAD)
                return;

            float radius = 5;
            Vector2 v1 = new Vector2((float)Math.Cos(atom.Angle), 
                (float)Math.Sin(atom.Angle)) * radius;
            Vector2 v2 = Vector2.Transform(v1, _rot120Deg);
            Vector2 v3 = Vector2.Transform(v2, _rot120Deg);

            Texture2D texture = atom.Type == AtomState.TRIPPLE_BLUE ? _textureBlue : _textureRed;

            if (atom.Type == AtomState.RED)
            {
                spriteBatch.Draw(_textureRed, atom.Position, null, Color.White, 0, 
                    new Vector2(atom.Radius, atom.Radius), 1.0f, SpriteEffects.None, atom.Zorder);

            }
            else
            {
                spriteBatch.Draw(texture, atom.Position + v1, null, Color.White, 0,
                    new Vector2(atom.Radius, atom.Radius), 1.0f, SpriteEffects.None, atom.Zorder);
                spriteBatch.Draw(texture, atom.Position + v2, null, Color.White, 0,
                    new Vector2(atom.Radius, atom.Radius), 1.0f, SpriteEffects.None, atom.Zorder);
                spriteBatch.Draw(texture, atom.Position + v3, null, Color.White, 0,
                    new Vector2(atom.Radius, atom.Radius), 1.0f, SpriteEffects.None, atom.Zorder);
            }
        }
    }
}

För att klassen ska kunna sköta om alla atomer så har vi dem i listorna _blueAtmos och _redAtoms. Vi har en LoadContent() som ser till att ladda grafik och ljud som behövs (_soundFx, _textureRed, _textureBlue). Vi har en _startBall som är den trippel röda atomen som vi styr med muspekaren. Vi har en _ballCount för att beräkna Z-värde på atomerna samt en _soundDelay som förhindrar att vi spelar upp ljudet för ofta.

Vi har en Matrix _rot120Deg som är lite speciell. Det är en rotationsmatris som är fast inställd på att rotera vektorer 120°. Varför just 120°, jo när vi rutar upp trippel atomer så ritar vi tre små atomer fast med 120° mellan. När sedan atomerna ska delas så är det också användbart att kunna räkna ut och rotera så de får rätt hastighet.

Restart

Här nollställs allt. Det Skapas 180 atomer och ses till att vi har en _startBall.

Update

Här görs de typiska sakerna som förflyttning via varje atoms Update (rad 59). Tar bort döda atomer (rad 60).

Rad 62-88 sköte vår kollisionshantering. Denna är lite speciell då den bygger på diskussionen om att sortera atomerna efter X-värde som vi tidigare nämnt. Vi anropar här CheckCollision() vid behov mellan blåa atomer och röda atomer.

På rad 90 så kollas om vi har en _startBall, i så fall uppdateras den och ser till så att den följer muspekaren.

Rad 96-102 undersöker om vi ska spränga _startBall och påbörja kedjereaktionen.

Rad 104 och 105 ser till att våra listor på atomer hela tiden är sorterade efter X-värde med hjälp av AtomComparer.

Draw

Denna metod är kanske mins intressant. Här ritas alla blåa och röda atomer samt _startBall om vi har en sådan fortfarande.

CheckCollision

Denna hjälpmetod undersöker först så att vi verkligen jämför en blå med en röd atom (rad 125). Sedan sker en beräkning på cirklarnas avstånd med hjälp av atomernas position samt deras radier. Om avståndet mellan atomerna är mindre än deras sammanlagda radier så har vi en kollision (rad 130). Då sprängs båda atomerna (rad 132-133) samt sätter tillståndet DEAD om någon av atomerna var en röd ensam atom.

Explode

Detta är en hjälpmetod till CheckCollision. Det är endast trippel blåa atomer som kan sönderdelas (rad 144). Här spelas ljudeffekten max en gång per frame (rad 149), därav behovet av _soundDelay. Vi räknar ut de tre olika riktningarna som atomen delas upp i. Vi räknar ut en riktning med v1 (rad 156) sedan kan vi enkelt rotera denna riktning två gånger med _rot120Deg och matrisens magi så att vi får v2 och v3.

Sist så skapar vi tre nya röda små atomer och sätter deras hastighet baserat på v1-v3 (rad 161-163).

DrawAtom

Detta är en hjälpmetod till Draw(). Här räknar vi ut tre riktningar v1-v3 på samma vis som i Explode(). Vi använder sedan denna data om vi ska rita upp en trippel röd eller en trippel blå atom. Det sista alternativet är en ensam röd atom.

Allt sätts ihop

Du har redan skapat alla klasser som behövs nu. Det sista som återstår är att använda allt via AtomGame.cs. Tänk på att du ska ha döpt om Game1.cs till AtomGame.cs.

AtomGame.cs
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;

namespace Atoms
{
    public class AtomGame : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager _graphics;
        SpriteBatch _spriteBatch;
        AtomManager _atomManager = new AtomManager();
        SpriteFont _gameFont;

        public static Random Random = new Random();
        public static int Width, Height;

        public AtomGame()
        {
            _graphics = new GraphicsDeviceManager(this);
            _graphics.PreferredBackBufferWidth = 720;
            _graphics.PreferredBackBufferHeight = 480;
            _graphics.IsFullScreen = false;
            Content.RootDirectory = "Content";
        }

        protected override void Initialize()
        {
            base.Initialize();
        }

        protected override void LoadContent()
        {
            Width = _graphics.GraphicsDevice.Viewport.Width;
            Height = _graphics.GraphicsDevice.Viewport.Height;

            _spriteBatch = new SpriteBatch(GraphicsDevice);
            _atomManager.LoadContent(Content);
            _gameFont = Content.Load<SpriteFont>("gameFont");

            _atomManager.Restart();
        }

        protected override void UnloadContent()
        {
        }

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

            KeyboardState ks = Keyboard.GetState();

            if (ks.IsKeyDown(Keys.R))
                _atomManager.Restart();

            _atomManager.Update(gameTime);   

            base.Update(gameTime);
        }

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

            _spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend);
            _atomManager.Draw(_spriteBatch);
            _spriteBatch.DrawString(_gameFont, "Atoms: " + _atomManager.BlueAtomsCount, 
                new Vector2(10, 10), Color.White);
            _spriteBatch.End();

            base.Draw(gameTime);
        }
    }
}

Vi gör egentligen inte mycket i själva spelklassen. Vi laddar en font som vi använder för att rita ut hur många atomer som finns kvar (rad 69). Vi skapar såklart en AtomManager, ser till att den får ladda sina prylar (rad 38), uppdaterar den (rad 58) samt ritar upp den (rad 68). Vi har också en koll på om vi trycker ned knappen ”R” (rad 55), i så fall startar vi om allt.

Du ska nu har ett körbart projekt. Kan du ”ploppa” alla atomer? Testa annars med att öka antalet på skärmen.

/Lycka till

Scroll to top