5
\$\begingroup\$

Follow up to this question : Snake Game in C#

The game plays like it did in my first question. The design is altered.

Gameplay Video

Some of the changes:

  • Polymorphism is removed, replaced by Interfaces (see interfaces.cs)
  • CreateGame, DeleteGame and GameOver are now methods in Game
  • Snake movement is handled differenly, moved from SnakePart to Snake
  • Removed DirectionObj, it is no longer needed
  • Database is now Grid
  • Created MapCreator
  • Before(), After(), Next() are now just Update(Game game)
  • Created Graphicand GraphicInstantiator
  • Direction is now just an enum
  • GameObjectInstantiator is now GraphicInstantiator

Overview

  • class Game : Monobehaviour
  • class Snake

    Interfaces, all are in interfaces.cs

  • interface IUpdatable
  • interface IGraphical
  • interface IGridable : IGraphical

    Classes with IGridable

  • class Apple : IGridable, IUpdatable
  • class Barrier : IGridable, IUpdatable
  • class SnakePart : IGridable

    Utilities

  • class InputHandler

  • class MapCreator
  • class Score
  • enum Direction {Left, Right, Up, Down, None}

  • class Grid : MultipleValuesDictionary<IVector2, IGridable>

  • class MultipleValuesDictionary<TKey,TValue> : Dictionary<TKey,HashSet<TValue>>

  • class GraphicInstantiator

  • class Graphic

  • struct IVector2 (Not in the project itself, is a 2D coordinate with int x and int y)

Game.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    //Monobehaviour is from Unity
    public class Game : MonoBehaviour
    {
        public GameObject cube;

        public Grid Grid { get; private set; }
        public Score Score { get; private set; }
        public MapCreator MapCreator { get; private set; }

        //Used to handle graphics in Unity
        public GraphicInstantiator GraphicInstantiator { get; private set; }

        InputHandler input;
        Snake snake;


        Direction inputDirection;

        float timer = 1.0f;

        public void Start()
        {
            Grid = new Grid();
            input = new InputHandler();
            Score = new Score();
            snake = new Snake(this);
            MapCreator = new MapCreator();
            GraphicInstantiator = new GraphicInstantiator(cube);

            Create();
        }

        //Called every frame
        public void Update()
        {
            timer -= Time.deltaTime;
            inputDirection = input.HandleArrows(inputDirection);

            //timer on 0.1 seconds
            if (timer < 0)
            {
                timer = 0.1f;

                input.UseInput(snake, ref inputDirection);

                //Logic
                snake.Update();
                Grid.CallAllUpdates(this);

                Debug.Log("The Current Score Is: " + Score.GetScore().ToString());
            }
        }

        public void Create()
        {
            snake.CreateSnake(new IVector2(20, 20), 7);
            MapCreator.Create(this);
        }

        public void Delete()
        {
            Grid.Clear();
            Score.ResetScore();
        }

        public void GameOver()
        {
            Reset();
        }

        public void Reset()
        {
            Delete();
            Create();
        }
    }
}

Snake.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    public class Snake 
    {
        public Direction HeadDirection { get; set; }

        List<SnakePart> snakeParts;

        SnakePart head;
        SnakePart lastPart;
        Color headColor;
        Color snakeColor;


        Game game;
        Grid grid;
        Score score;

        //Prevent CreateNewPart to be called twice in one step
        bool canEatAgain = false;

        public Snake(Game game)
        {
            this.game = game;
            grid = game.Grid;
            score = game.Score;
            headColor = new Color(0, 1, 0, 1);
            snakeColor = new Color(0, 0.5f, 0, 1);
            //snakeParts = new List<SnakePartObj>();
        }


        public void Update()
        {
            canEatAgain = true;
            Move();
            if (BittenHimself())
                game.GameOver();
        }

        public void CreateSnake(IVector2 headPos, int NumbOfParts)
        {
            HeadDirection = Direction.Up;
            snakeParts = new List<SnakePart>();
            head = CreateSnakeObj(headPos, headColor,null);
            head.Graphic.SetColor(headColor);

            IVector2 pos = headPos;
            pos.y--;
            SnakePart previousSnakePart = head;
            for(int y = 1; y < (NumbOfParts-1); y++)
            {
                SnakePart part = CreateSnakeObj(pos, snakeColor,previousSnakePart);
                previousSnakePart = part;
                pos.y--;
            }

            lastPart = CreateSnakeObj(pos, snakeColor,previousSnakePart);
        }

        SnakePart CreateSnakeObj(IVector2 pos, Color col,SnakePart nextSnakePart)
        {
            SnakePart obj = new SnakePart(game.GraphicInstantiator.CreateInstance(),this, nextSnakePart);
            game.Grid.Add(pos, obj);
            snakeParts.Add(obj);
            obj.Graphic.SetColor(snakeColor);
            return obj;
        }


        //New move method
        public void Move()
        {
            game.Grid.Move(lastPart, head.Position + HeadDirection.GetVector());

            SnakePart newLastPart = lastPart.NextSnakePart;
            SnakePart newHeadPart = lastPart;

            newHeadPart.NextSnakePart = null;
            head.NextSnakePart = lastPart;

            lastPart.Graphic.SetColor(headColor);
            head.Graphic.SetColor(snakeColor);


            head = newHeadPart;
            lastPart = newLastPart;
        }

        public bool BittenHimself()
        {
            HashSet<IVector2> positions = new HashSet<IVector2>();
            foreach(var snakePart in snakeParts)
            {
                IVector2 pos = snakePart.Position;
                //There exists more than one snakePart at a position, therefore he has bitten himself
                if (positions.Contains(pos))
                {
                    return true;
                }
                else
                    positions.Add(pos);
            }
            return false;
        }


        public void CreateNewPart()
        {
            if(canEatAgain == false)
            {
                return;
            }
            canEatAgain = false;

            score.AddPoint();
            lastPart = CreateSnakeObj(lastPart.Position, snakeColor,lastPart);

        }
        public IVector2 GetHeadPos()
        {
            return head.Position;
        }


    }
}

Interfaces.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    public interface IUpdatable
    {
        void Update(Game game);
    }

    public interface IGraphical
    {
        Graphic Graphic { get; set; }
    }

    public interface IGridable : IGraphical
    {
        IVector2 Position { get; set; }
    }
}

Apple.cs

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    public class Apple : IGridable, IUpdatable
    {

        public Graphic Graphic { get; set; }
        public IVector2 Position { get; set; }

        public Apple(Graphic graphic)
        {
            this.Graphic = graphic;
            graphic.SetColor(new Color(1, 0, 0, 1));
        }

        public void Update(Game game)
        {
            SnakePart snakePart = game.Grid.GetValueOfTypeAtPosition<SnakePart>(Position);

            //If there is a snake part at the apples position, then make the snake longer and place the apple in another position
            if (snakePart != null)
            {
                snakePart.GetSnake().CreateNewPart();
                IVector2 newPos = RandomPos(game);
                game.Grid.Move(this, newPos);
            }
        }

        IVector2 RandomPos(Game game)
        {
            return new IVector2(UnityEngine.Random.Range(1, game.MapCreator.MapSize.x), UnityEngine.Random.Range(1, game.MapCreator.MapSize.y));
        }
    }
}

Barrier.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
using System;

namespace SnakeGameSOLID
{
    public class Barrier : IGridable, IUpdatable
    {
        public IVector2 Position { get; set; }
        public Graphic Graphic { get; set; }

        public Barrier(Graphic graphic) 
        {
            this.Graphic = graphic;
            graphic.SetColor(new Color(0, 0, 0, 1));
        }

        public void Update(Game game)
        {
            SnakePart snakePart = game.Grid.GetValueOfTypeAtPosition<SnakePart>(Position);
            if (snakePart != null)
            {
                game.GameOver();
            }
        }
    }
}

SnakePart.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    public class SnakePart : IGridable
    {
        public Graphic Graphic { get; set; }
        public IVector2 Position { get; set; }
        //The next snake part from this snake part, if this is a headpart, this will be null
        public SnakePart NextSnakePart { get; set; }

        Snake snake;

        public SnakePart(Graphic graphic, Snake snake, SnakePart nextSnakePart) 
        {
            this.Graphic = graphic;
            this.snake = snake;
            NextSnakePart = nextSnakePart;
        }

        public Snake GetSnake()
        {
            return snake;
        }
    }
}

InputHandler.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace SnakeGameSOLID
{
    public class InputHandler
    {
        public Direction HandleArrows(Direction direction)
        {
            if (Input.GetKeyUp(KeyCode.UpArrow))
            {
                direction = Direction.Up;
            }
            if (Input.GetKeyUp(KeyCode.DownArrow))
            {
                direction = Direction.Down;
            }
            if (Input.GetKeyUp(KeyCode.LeftArrow))
            {
                direction = Direction.Left;
            }
            if (Input.GetKeyUp(KeyCode.RightArrow))
            {
                direction = Direction.Right;
            }
            return direction;
        }

        public void UseInput(Snake snake,ref Direction direction)
        {
            if (direction == Direction.None)
                return;

            //Dont use oppisite input
            if (snake.HeadDirection.IsOppisiteDirection(direction))
            {
                return;
            }

            snake.HeadDirection = direction;


            direction = Direction.None;
        }

    }
}

MapCreator.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    public class MapCreator
    {
        public IVector2 MapSize { get; private set; }

        public MapCreator()
        {
            MapSize = new IVector2(30, 30);
        }

        public void Create(Game game)
        {
            CreateEdgeBarriers(game);
            CreateApples(game);
        }

        public void CreateEdgeBarriers(Game game)
        {
            for (int x = 0; x < MapSize.x; x++)
                for (int y = 0; y < MapSize.y; y++)
                {
                    if (x == 0 || y == 0 || x == (MapSize.x - 1) || y == (MapSize.y - 1)) {
                        Barrier barrier = new Barrier(game.GraphicInstantiator.CreateInstance());
                        game.Grid.Add(new IVector2(x, y), barrier);
                }
                }
        }

        public void CreateApples(Game game)
        {
            HashSet<IVector2> pos = new HashSet<IVector2>();
            //500 apples madness
            for (int i = 0; i < 500; i++)
            {
                IVector2 p = new IVector2(Random.Range(1, 29), Random.Range(1, 29));
                if (pos.Contains(p))
                    continue;
                pos.Add(p);

                Apple apple = new Apple(game.GraphicInstantiator.CreateInstance());
                game.Grid.Add(p, apple);
            }
        }

    }
}

Score.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace SnakeGameSOLID
{
    public class Score
    {
        int score = 0;
        public int GetScore() { return score; }
        public void AddPoint() { score++; }
        public void ResetScore() { score = 0; }
    }
}

Direction.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    public enum Direction { Left, Right, Up, Down, None }

    public static class DirectionMethods
    {
        public static IVector2 GetVector(this Direction direction)
        {
            switch (direction)
            {
                case Direction.Left:
                    return new IVector2(-1, 0);
                case Direction.Right:
                    return new IVector2(1, 0);
                case Direction.Up:
                    return new IVector2(0, 1);
                case Direction.Down:
                    return new IVector2(0, -1);
            }
            return new IVector2(0, 0);
        }

        public static bool IsOppisiteDirection(this Direction direction1, Direction direction2)
        {
            if (direction1 == Direction.Up && direction2 == Direction.Down)
            {
                return true;
            }

            if (direction1 == Direction.Down && direction2 == Direction.Up)
            {
                return true;
            }

            if (direction1 == Direction.Left && direction2 == Direction.Right)
            {
                return true;
            }

            if (direction1 == Direction.Right && direction2 == Direction.Left)
            {
                return true;
            }
            return false;
        }
    }
}

Grid.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using System.Linq;
using Misc;
namespace SnakeGameSOLID
{
    public class Grid : MultipleValuesDictionary<IVector2,IGridable>
    {
        public void CallAllUpdates(Game game)
        {
            foreach (var o in GetValuesOfType<IUpdatable>())
            {
                o.Update(game);
            }
        }

        public void Move(IGridable value,IVector2 newPosition)
        {
            Move(value.Position, newPosition, value);
            value.Position = newPosition;
            value.Graphic.SetPosition(newPosition);
        }


        public new void Add(IVector2 pos, IGridable value)
        {
            base.Add(pos, value);
            value.Position = pos;
            value.Graphic.SetPosition(pos);
        }

        public new void Clear()
        {
            foreach(var o in GetValues())
            {
                o.Graphic.Destroy();
            }
            base.Clear();
        }

        public List<T> GetValuesOfType<T>()
        {
            return GetValues().OfType<T>().ToList();
        }




        public List<T> GetValuesOfTypeAtPosition<T>(IVector2 key) where T : IGridable
        {

            HashSet<IGridable> values = GetValuesAtKey(key);
            if (values == null)
                return null;

            return values.OfType<T>().ToList();
        }

        public T GetValueOfTypeAtPosition<T>(IVector2 key) where T : IGridable
        {
            List<T> values = GetValuesOfTypeAtPosition<T>(key);
            if (values == null)
                return default(T);
            if (values.Count == 0)
                return default(T);
            return values[0];
        }

    }
}

MultipleValuesDictionary.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using System;
using Misc;

namespace SnakeGameSOLID
{
    public class MultipleValuesDictionary<TKey,TValue> : Dictionary<TKey,HashSet<TValue>>
    {
        public void Add(TKey key, TValue value)
        {
            HashSet<TValue> values;
            if (TryGetValue(key, out values))
            {
                if (values.Contains(value))
                {
                    //Exception?
                    return;
                }
                values.Add(value);
                return;

            }
            values = new HashSet<TValue>();

            values.Add(value);
            Add(key, values);
        }

        public void Remove(TKey key, TValue value)
        {
            HashSet<TValue> values = GetValuesAtKey(key);
            if (values == null)
                return;
            values.Remove(value);

            //If there are no values at this key anymore, then remove the key from the dictionary
            if (values.Count == 0)
                RemoveKey(key);
        }

        public void RemoveKey(TKey key)
        {
            Remove(key);
        }

        public HashSet<TValue> GetValuesAtKey(TKey key)
        {
            HashSet<TValue> values;
            TryGetValue(key, out values);
            return values;
        }

        public void Move(TKey oldKey, TKey newKey, TValue value)
        {
            Remove(oldKey, value);
            Add(newKey,value);
        }

        public List<TValue> GetValues()
        {
            return Values.SelectMany(x => x).ToList();
        }




    }
}

GraphicInstantiator.cs

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

namespace SnakeGameSOLID
{
    //Handles Instantiation of Unity graphics
    public class GraphicInstantiator
    {
        GameObject prefab;
        public GraphicInstantiator(GameObject prefab)
        {
            this.prefab = prefab;
        }
        public Graphic CreateInstance()
        {
            return new Graphic((GameObject)GameObject.Instantiate(prefab));
        }

    }
}

Graphic.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Misc;
namespace SnakeGameSOLID
{
    //Handles graphics in Unity
    public class Graphic
    {
        GameObject graphic;

        public Graphic(GameObject graphicInstance)
        {
            graphic = graphicInstance;
        }


        public void SetPosition(IVector2 newPosition)
        {
            graphic.transform.position = new Vector3(newPosition.x,newPosition.y);
        }

        public void SetColor(Color col)
        {
            graphic.GetComponent<MeshRenderer>().material.color = col;
        }

        public void Destroy()
        {
            GameObject.Destroy(graphic);
        }
    }
}
\$\endgroup\$
  • \$\begingroup\$ There seems to be little critizism left. Is it fair for me to assume that this code is good SOLID code without bad practices? I want to have this project as an example for myself of what good SOLID code looks like. It would be great if anyone could confirm that for me. \$\endgroup\$ – Vermacian55 Jan 9 '17 at 20:43
  • \$\begingroup\$ No it's not. I see it's well-intentioned but believe me, if you keep studying these topics and thinking about how your code does or does not meet the principles - as well as looking at good code that does - you'll get better and better at it and it will come more naturally, and then you'll look back on your early efforts and see how "amateur" (I don't mean that in a bad way) they were. Also don't only focus on SOLID, learn about all aspects of OO design, and your code will start showing "maturity". You definitely wouldn't want to use your own early efforts as a benchmark for good design. \$\endgroup\$ – 404 Jan 9 '17 at 23:22
  • \$\begingroup\$ @eurotrash I thought my code was gods work haha. I feel blind because i am incapable to see any major issues with my code. It would be fantastic if you could link me any good articles on oo/SOLID. Also if you know about some code with "maturity" i would be very interested to read it. Maybe a github project or something \$\endgroup\$ – Vermacian55 Jan 9 '17 at 23:50
  • 1
    \$\begingroup\$ I don't know about articles but I can list some good books which have all contributed to my education design-wise - not that I'm a great designer, I'm always improving, but it wasn't long ago that I was struggling with making my code SOLID after learning about it - I knew it wasn't "right", just didn't know what was wrong. Anyway some good books about design in general that I can remember are: Adaptive Code via C#; Clean Code; Refactoring; Domain Driven Design; Growing Object Oriented Software; Dependency Injection in .NET. Oh and you'll want to learn something about design patterns. \$\endgroup\$ – 404 Jan 10 '17 at 0:25
5
\$\begingroup\$

I can see some FPS drop in your game each time you eat or spawn new food object. This is important because that's a game and FPS is crucial factor for a pleasant experience, not only code-style, architecture etc.. You should consider applying the object pooling concept to your project.

Which works like this :

  1. It creates a list of GameObject at the start of the game (optionally when requested) and sets all of them to inactive (gameObject.SetActive(false)).

  2. If you need to show an object you take it from the "pool" of already created GameObjects, and set it to active (gameObject.SetActive(true)).

  3. Once you are done using the object you set it back to inactive state (gameObject.SetActive(false)).

This technique allows you avoid calling methods such as Instantiate and Destroy which are quite expensive to call and perform. All of the objects are created at the beginning so there will be almost no fps drop when activating one of them.

Note

Most Object Poolers allow dynamic increment of the size of the List<GameObject> in case you need an object but you don't have any "free"(inactive) objects.

Here is an example of Object Pooler class (reference).

/// <summary>
/// Information holder for a pooled object
/// </summary>
[Serializable]
public class PooledObject
{
    [Tooltip(@"Name is used to differ the objects from one another")]
    public string Name;

    [Tooltip(@"What object should be created ?")]
    public GameObject Object;

    [Range(1, 10000)] [Tooltip(@"How much objects should be created ?")]
    public int Amount;

    [Tooltip(@"Can new objects be created in case there are none left ?")]
    public bool CanGrow;

    [Tooltip(@"False - objects must be created manually using Populate method
True - objects will be created automatically on awake")]
    public bool CreateOnAwake;
}

And this is the actual object pooling class :

/// <summary>
/// Object pooler class which can hold a single type of objects.
/// </summary>
public class MonoObjectPooler : MonoBehaviour
{
    /// <summary>
    /// Object to be pooled.
    /// </summary>
    public PooledObject PooledObject;

    /// <summary>
    /// List to store all the objects that will be pooled.
    /// </summary>
    private readonly List<GameObject> pooledObjects = new List<GameObject>();

    private void Awake()
    {
        //Create the pooled object if the CreateOnAwake variable is set to true.
        if (PooledObject.CreateOnAwake)
        {
            Populate();
        }
    }

    /// <summary>
    /// Populates the list of pooled objects with PooledObjects.
    /// </summary>
    public void Populate()
    {
        //Clear the previous items in the list.
        pooledObjects.Clear();

        //Load the items again
        for (int i = 0; i < PooledObject.Amount; i++)
        {
            GameObject obj = Instantiate(PooledObject.Object);
            obj.SetActive(false);
            pooledObjects.Add(obj);
        }
    }

    /// <summary>
    /// Returns a PooledObject.
    /// </summary>
    public GameObject GetPooledObject()
    {
        for (int i = 0; i < pooledObjects.Count; i++)
        {
            if (!pooledObjects[i].activeInHierarchy)
            {
                //we have an available object
                return pooledObjects[i];
            }
        }
        if (PooledObject.CanGrow)
        {
            //we ran out of objects but we can create more
            GameObject obj = Instantiate(PooledObject.Object);
            pooledObjects.Add(obj);
            return obj;
        }
        //We ran out of objects and we cant create more
        return null;
    }
}

Just attach as much MonoObjectPooler objects you need, to some GameManager GameObject in your scene, set up the values and you are good to go. Note that you will need to attach separate MonoObjectPooler script to the GameManager for each different object type. IF you like to have everything in one place you can check out the PolyObjectPooler class in the reference link, but keep in mind that many MonoObjectPoolers will be more performant than a single PolyObjectPooler.

After you obtain free object just set it to active and adjust the position and the other properties you need to.

\$\endgroup\$
  • \$\begingroup\$ Thanks for the tip. However, the game does not have any performance difficulties, it was due to the recording software fraps. The game runs at a steady 200 frames per second without it. Yes instantiating and destroying is expensive, thought it is barly used in the game. \$\endgroup\$ – Vermacian55 Jan 9 '17 at 16:06
  • 1
    \$\begingroup\$ @Vermacian55 It can easily go out of hand, whether it's frequent is not determined by the amount of places you call Destroy()/Instantiate(), but rather how often you call them. You might have a single Instantiate() call but if it's called every 50-100 ms your FPS will suffer. Object pooling is rarely an overkill solution as it's short to implement and easy to use. Tho I agree the objects you spawn are simple and don't cost that much memory, I'd still prefer having an object pooler to deal with that, this way you can make them as complex as you want, without thinking about FPS drops. \$\endgroup\$ – Denis Jan 9 '17 at 16:11
  • \$\begingroup\$ I've never seen that [Tooltip] attribute before; is it similar to xml-doc comments? \$\endgroup\$ – D. Ben Knoble Jan 9 '17 at 17:53
  • \$\begingroup\$ @DavidBenKnoble I think it's Unity specific, it displays additional info in the Inspector View. \$\endgroup\$ – Denis Jan 9 '17 at 17:54
2
\$\begingroup\$

MultipleValuesDictionary.cs

public List<TValue> GetValues()
{
    return Values.SelectMany(x => x).ToList();
}

Grid.cs

public List<T> GetValuesOfType<T>()
{
    return GetValues().OfType<T>().ToList();
}

public new void Clear()
{
    foreach(var o in GetValues())
    {
        o.Graphic.Destroy();
    }
    base.Clear();
}

public void CallAllUpdates(Game game)
{
    foreach (var o in GetValuesOfType<IUpdatable>())
    {
        o.Update(game);
    }
}

Every time you call GetValues or GetValuesOfType (which in turn calls GetValues) you are selecting everything into a new list. This seems quite inefficient given you can simply iterate over IEnumerable that SelectMany returns. And it becomes even worse when you create a new list holding everything, only to then filter out what you don't need, and once again stick the filtered items into a new list. Which is what GetValuesOfType does. It's like running a SELECT statement on a table to return all data, and then filtering at the client end to display only the specific items you wanted in the first place.

The only reason I can think of for this behaviour is when you want to modify the collection you're iterating over. But it doesn't look like that's happening in Clear/CallAllUpdates (which you should probably rename to UpdateAll or UpdateAll[object name]).


GraphicInstantiator

This is called a factory.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.