0
\$\begingroup\$

I am developing a bullet-hell game and struggling to implement the snake movement logic. I try to record the head's positions and update bodies with these positions. screenshot1

using Saltyfish.Util;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Saltyfish.Logic
{
    public class SnakeMovement : MonoBehaviour
    {

        [SerializeField]
        private SnakePath m_SnakePath = new SnakePath(10);

        [SerializeField]
        private List<Unit> m_Bodies = new List<Unit>();

        public List<Unit> Bodies => m_Bodies;


        [SerializeField]
        [Range(0, 5)]
        private float m_BodySpace;

        public Unit Head => m_Bodies.SafeGet(0);

        // Update is called once per frame
        void Update()
        {
            OnUpdate();
        }

        protected virtual void OnUpdate()
        {
            UpdatePath();
            UpdateBodies();
        }

        protected void UpdateMovement(Vector2 direction)
        {
            if (Head == null)
                return;
            Vector2 curPos = transform.position;
            var nextPos = curPos + direction * Head.GetSpeed() * Time.deltaTime;
            transform.position = new Vector3(nextPos.x, nextPos.y, transform.position.z);
        }


        private void UpdatePath()
        {
            if (m_SnakePath.Count == 0)
            {
                if(Head != null)
                {
                    m_SnakePath.AddPoint(Head.transform.position);
                }
            }
            else
            {
                var tailPoint = m_SnakePath.Head.Value;
                var distance = Vector2.Distance(Head.transform.position, tailPoint);
                if(distance >= m_BodySpace)
                {
                    m_SnakePath.AddPoint(Head.transform.position);
                }
            }
        }

        public void SetBodyCapacity(int capacity)
        {
            m_SnakePath?.Resize(capacity);
        }

        private void UpdateBodies()
        {
            if (m_Bodies.Count <= 1)
                return;
            var headPoint = m_SnakePath.GetPoint(0);
            if (!headPoint.HasValue)
                return;
            float headMoveDistance = (Head.transform.position - headPoint.Value).magnitude;
            for (int i = 1;i < m_Bodies.Count; ++i)
            {
                if (m_Bodies[i].IsDead)
                    continue;
                var prevPoint = m_SnakePath.GetPointOrTail(i);
                var nextPoint = m_SnakePath.GetPointOrTail(i - 1);
                if (!nextPoint.HasValue || !prevPoint.HasValue)
                    continue;
                var nextPos = nextPoint.Value;
                var prevPos = prevPoint.Value;
                m_Bodies[i].transform.position = Vector3.Lerp(prevPos, nextPos, headMoveDistance);
            }
        }

        public void InitBodies(IList<Unit> units)
        {
            m_Bodies.Clear();
            if(units.Count > m_SnakePath.MaxCapacity)
            {
                Debug.LogWarning("body is too long");
            }
           for(int i = 0;i < units.Count && i < m_SnakePath.MaxCapacity; i++)
            {
                m_Bodies.Add(units[i]);
            }
        }


        public void PushBody(Unit unit)
        {
            if(m_Bodies.Count >= m_SnakePath.MaxCapacity)
            {
                Debug.LogWarning("not enough capacity for new body");
                return;
            }
            if(m_Bodies.Contains(unit))
            {
                return;
            }
            m_Bodies.Add(unit);
        }

        public void InsertBody(Unit unit, int pos = 0)
        {
            if (pos < 0 || pos >= m_Bodies.Count)
                return;
            if (m_Bodies.Count >= m_SnakePath.MaxCapacity)
            {
                Debug.LogWarning("not enough capacity for new body");
                return;
            }
            m_Bodies.Insert(pos, unit);
        }


        public Unit GetBody(int pos)
        {
            var unit = m_Bodies.SafeGet(pos);
            return unit;
        }

        public bool RemoveBody(Unit unit)
        {
            return m_Bodies.Remove(unit);
        }

        public void RemoveAt(int pos)
        {
            m_Bodies.RemoveAt(pos);
        }

#if UNITY_EDITOR

        private void OnDrawGizmos()
        {
            for(int i = 0;i < m_SnakePath.Count; i++)
            {
                Gizmos.DrawSphere(m_SnakePath.GetPoint(i).Value, 1);
            }
        }
#endif
    }
}
using Saltyfish.Util;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

namespace Saltyfish.Logic
{
    [Serializable]
    class SnakePath
    {
        [SerializeField]
        private List<Vector3> m_PathPoints = new List<Vector3>();

        public int MaxCapacity { get;  private set; }

        public int Count => m_PathPoints.Count;

        public Vector3? Head
        {
            get
            {
                if (m_PathPoints.Count == 0)
                    return null;
                return m_PathPoints[0];
            }
        }


        public Vector3? Tail
        {
            get
            {
                if (m_PathPoints.Count == 0)
                    return null;
                return m_PathPoints[m_PathPoints.Count - 1];
            }
        }


        public SnakePath(int capacity)
        {
            MaxCapacity = capacity;
        }

        public void Resize(int capacity)
        {
            if (capacity <= 0)
                return;
            if (m_PathPoints.Count > capacity)
                m_PathPoints.RemoveRange(capacity, m_PathPoints.Count - capacity);
            MaxCapacity = capacity;
        }


        public void AddPoint(Vector3 point)
        {
            m_PathPoints.Insert(0, point);
            if (m_PathPoints.Count >= MaxCapacity)
                RemoveAt(m_PathPoints.Count - 1);
        }

        public void RemoveAt(int idx)
        {
            m_PathPoints.RemoveAt(idx);
        }

        public Vector3? GetPoint(int idx)
        {
            if (idx < 0 || idx >= m_PathPoints.Count)
                return null;
            return m_PathPoints[idx];
        }

        public Vector3? GetPointOrTail(int idx)
        {
            if (m_PathPoints.Count == 0)
                return null;
            var point = GetPoint(idx);
            if (point == null)
                point = m_PathPoints[m_PathPoints.Count - 1];
            return point;
        }
    }
}

But the current movement is less smooth if the gap(bodyspace) becomes larger.

screenshot

So I searched a lot for a better implementation and I found these below.

Snakemovement tutorial on youtube

Snake tail follow

Another Snake follow solution

They are very helpful, but I think there are still some defects in these solutions.

Defect 1. All of the create-body 's scripts in these solutions used a delay time to keep a fixed gap between bodies. What if the snake's speed is various, which leads to different gaps between body parts?

Defect 2. How could I insert a body to the snake or swap a body with another and makes the snake apply the change automatically? ( all of these solutions are simply appending a body to the tail, which will also cause same problem in defect 1 if the snake speed has changed).

How could I implement a smooth snake movement with a dynamic body management?

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

I found a solution that uses backtracking.

It records the snake route every frame, and the bodies trace the route back to find their property position.

using Saltyfish.Util;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace Saltyfish.Logic
{
    public class SnakeMovement : MonoBehaviour
    {
        [SerializeField]

        private List<SnakePoint> m_SnakePath = new List<SnakePoint>();

        [SerializeField]
        private List<Unit> m_Bodies = new List<Unit>();

        public List<Unit> Bodies => m_Bodies;


        [SerializeField]
        [Range(0.1f, 5)]
        private float m_BodySpace;

        public Unit Head => m_Bodies.SafeGet(0);

        private bool m_Moved;


        // Update is called once per frame
        void Update()
        {
            OnUpdate();
        }

        protected virtual void OnUpdate()
        {
            UpdateBodies();
        }

        protected void UpdateMovement(Vector2 direction)
        {
            if (Head == null)
                return;
            Vector2 curPos = transform.position;
            var nextPos = curPos + direction * Head.GetSpeed() * Time.deltaTime;
            transform.position = new Vector3(nextPos.x, nextPos.y, transform.position.z);
            // Optimization 1 : Only update path when moved
            if(direction != Vector2.zero)
            {
                UpdatePath();
            }
            // Optimization 1
        }


        private void UpdatePath()
        {
            if (Head == null)
                return;
            var curPoint = new SnakePoint(Head.transform.position);
            if (m_SnakePath.Count > 0)
            {
                var lastPoint = m_SnakePath[m_SnakePath.Count - 1];
                curPoint.deltaDistance = Vector3.Distance(curPoint.Position, lastPoint.Position);
            }
            m_SnakePath.Add(curPoint);
        }
        private void UpdateBodies()
        {
            if (m_Bodies.Count <= 1)
                return;
            for (int i = 1; i < m_Bodies.Count; ++i)
            {
                if (m_Bodies[i].IsDead)
                    continue;
                float remainDistance = Mathf.Clamp(m_BodySpace, 0.1f, 5) * i;
                for (int j = m_SnakePath.Count - 1; j > 0; j--)
                {
                    if (remainDistance <= m_SnakePath[j].deltaDistance)
                    {
                        float LerpProgress = 0;
                        if (m_SnakePath[j].deltaDistance > 0)
                        {
                            LerpProgress = remainDistance / m_SnakePath[j].deltaDistance;
                        }
                        m_Bodies[i].transform.position = Vector3.Lerp(
                            m_SnakePath[j].Position,
                            m_SnakePath[j - 1].Position,
                            LerpProgress);
                        // Optimization 2 : remove the points before the waypoint that last body has reached
                        if (i == m_Bodies.Count - 1)
                        {
                            m_SnakePath.RemoveRange(0, j - 1);
                        }
                        // Optimization 2
                        break;
                    }
                    remainDistance -= m_SnakePath[j].deltaDistance;
                }
            }
        }

        public void InitBodies(IList<Unit> units)
        {
            m_Bodies.Clear();
            for (int i = 0; i < units.Count; i++)
            {
                m_Bodies.Add(units[i]);
            }
        }


        public void PushBody(Unit unit)
        {
            if (m_Bodies.Contains(unit))
            {
                return;
            }
            m_Bodies.Add(unit);
        }

        public void InsertBody(Unit unit, int pos = 0)
        {
            if (pos < 0 || pos >= m_Bodies.Count)
                return;
            m_Bodies.Insert(pos, unit);
        }


        public Unit GetBody(int pos)
        {
            var unit = m_Bodies.SafeGet(pos);
            return unit;
        }

        public bool RemoveBody(Unit unit)
        {
            return m_Bodies.Remove(unit);
        }

        public void RemoveAt(int pos)
        {
            m_Bodies.RemoveAt(pos);
        }

        private void OnGUI()
        {
            GUIStyle textStyle = new GUIStyle();
            textStyle.fontSize = 35;
            GUI.Label(new Rect(25, 25, 200, 100),"BodySpace", textStyle);
            m_BodySpace = GUI.HorizontalSlider(new Rect(25, 100, 200, 80), m_BodySpace, 0.1F, 5);
        }

    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;

namespace Saltyfish.Logic
{
    [Serializable]
    public class SnakePoint
    {
        public Vector3 Position;

        public float deltaDistance;

        public SnakePoint(Vector3 pos)
        {
            Position = pos;
        }
    }
}

sc2

Although the route point list grows endlessly, the memory cost is acceptable because a single game lasts only 5 minutes. And I can clear the list before the next game begins.

EDIT:

  1. The list does not need to be updated if the movement is stopped.
  2. The points before the last body point can be removed as Zibelas suggested.
\$\endgroup\$
3
  • \$\begingroup\$ Cant you remove the point as well once the last body part reached the waypoint? \$\endgroup\$
    – Zibelas
    May 27 at 10:32
  • \$\begingroup\$ @Zibelas Thx for your inspiration! I have changed the code so that it has a better space complexity. \$\endgroup\$
    – 47 kk
    May 27 at 11:03
  • \$\begingroup\$ Furthermore, I can add a object pool for the temporarily removed snake points and reuse them. \$\endgroup\$
    – 47 kk
    May 27 at 11:10

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .