// MIT License - Copyright (c) 2023 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Core.DataStructure
{
using System;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Helper;
using ProtoBuf;
using UnityEngine;
using WallstopStudios.UnityHelpers.Utils;
///
/// Fixed-capacity ring buffer that overwrites old entries when full, ideal for rolling logs, recent inputs, or telemetry windows.
/// Preserves allocation-free iteration while remaining serializable for debugging and tooling.
///
///
/// trail = new CyclicBuffer(32);
/// trail.Add(transform.position);
/// foreach (Vector3 sample in trail)
/// {
/// DrawDebugPoint(sample);
/// }
/// ]]>
///
[Serializable]
[ProtoContract(IgnoreListHandling = true)]
public sealed class CyclicBuffer : IReadOnlyList
{
[ProtoIgnore]
private PooledResource> _serializedItemsLease;
public struct CyclicBufferEnumerator : IEnumerator
{
private readonly CyclicBuffer _buffer;
private int _index;
private T _current;
internal CyclicBufferEnumerator(CyclicBuffer buffer)
{
_buffer = buffer;
_index = -1;
_current = default;
}
public bool MoveNext()
{
if (++_index < _buffer.Count)
{
_current = _buffer._buffer[_buffer.AdjustedIndexFor(_index)];
return true;
}
_current = default;
return false;
}
public T Current => _current;
object IEnumerator.Current => Current;
public void Reset()
{
_index = -1;
_current = default;
}
public void Dispose() { }
}
[ProtoMember(1)]
[field: SerializeField]
public int Capacity { get; private set; }
[ProtoMember(2)]
[field: SerializeField]
public int Count { get; private set; }
[ProtoMember(3)]
private List _serializedItems;
[SerializeField]
[ProtoIgnore]
private List _buffer;
[SerializeField]
[ProtoMember(4)]
private int _position;
public T this[int index]
{
get
{
BoundsCheck(index);
return _buffer[AdjustedIndexFor(index)];
}
set
{
BoundsCheck(index);
_buffer[AdjustedIndexFor(index)] = value;
}
}
private CyclicBuffer()
{
Capacity = 0;
_position = 0;
Count = 0;
_buffer = new List();
}
public CyclicBuffer(int capacity, IEnumerable initialContents = null)
{
if (capacity < 0)
{
throw new ArgumentException(nameof(capacity));
}
Capacity = capacity;
_position = 0;
Count = 0;
_buffer = new List();
if (initialContents != null)
{
foreach (T item in initialContents)
{
Add(item);
}
}
}
public CyclicBufferEnumerator GetEnumerator()
{
return new CyclicBufferEnumerator(this);
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public void Add(T item)
{
if (Capacity == 0)
{
return;
}
if (_position < _buffer.Count)
{
_buffer[_position] = item;
}
else
{
_buffer.Add(item);
}
_position = _position.WrappedIncrement(Capacity);
if (Count < Capacity)
{
++Count;
}
}
public bool Remove(T element, IEqualityComparer comparer = null)
{
if (Count == 0)
{
return false;
}
comparer ??= EqualityComparer.Default;
using PooledResource> listResource = Buffers.List.Get(out List temp);
for (int i = 0; i < Count; ++i)
{
temp.Add(_buffer[AdjustedIndexFor(i)]);
}
bool removed = false;
for (int i = 0; i < temp.Count; ++i)
{
if (comparer.Equals(temp[i], element))
{
temp.RemoveAt(i);
removed = true;
break;
}
}
if (!removed)
{
return false;
}
RebuildFromCache(temp);
return true;
}
public int RemoveAll(Predicate predicate)
{
if (Count == 0)
{
return 0;
}
using PooledResource> listResource = Buffers.List.Get(out List temp);
for (int i = 0; i < Count; ++i)
{
temp.Add(_buffer[AdjustedIndexFor(i)]);
}
int removedCount = temp.RemoveAll(predicate);
if (removedCount == 0)
{
return 0;
}
RebuildFromCache(temp);
return removedCount;
}
public void RemoveAt(int index)
{
BoundsCheck(index);
int capacity = Capacity;
int physicalIndex = AdjustedIndexFor(index);
int rightCount = Count - index - 1;
if (index <= rightCount)
{
int headIndex = GetHeadIndex();
int current = physicalIndex;
for (int i = 0; i < index; ++i)
{
int previous = current - 1;
if (previous < 0)
{
previous += capacity;
}
_buffer[current] = _buffer[previous];
current = previous;
}
_buffer[headIndex] = default;
}
else
{
int current = physicalIndex;
for (int i = 0; i < rightCount; ++i)
{
int next = current + 1;
if (next >= capacity)
{
next -= capacity;
}
_buffer[current] = _buffer[next];
current = next;
}
_buffer[current] = default;
_position = current;
}
--Count;
if (Count == 0)
{
_position = 0;
}
}
private void RebuildFromCache(List temp)
{
_buffer.Clear();
_buffer.AddRange(temp);
Count = temp.Count;
_position = Count < Capacity ? Count : 0;
}
public void Clear()
{
Count = 0;
_position = 0;
_buffer.Clear();
}
public void Resize(int newCapacity)
{
if (newCapacity == Capacity)
{
return;
}
if (newCapacity < 0)
{
throw new ArgumentException(nameof(newCapacity));
}
// Handle zero-capacity explicitly
if (newCapacity == 0)
{
Capacity = 0;
Clear();
return;
}
int toKeep = Math.Min(Count, newCapacity);
if (toKeep == 0)
{
// No items to retain, just update capacity and clear storage
Capacity = newCapacity;
Clear();
return;
}
// Retain the most recent entries (drop the oldest when shrinking)
// Build a contiguous list of the last 'toKeep' logical items in order
using (PooledResource> lease = Buffers.List.Get(out List temp))
{
int start = Count - toKeep;
for (int i = 0; i < toKeep; ++i)
{
temp.Add(_buffer[AdjustedIndexFor(start + i)]);
}
_buffer.Clear();
_buffer.AddRange(temp);
}
Capacity = newCapacity;
Count = toKeep;
_position = Count < Capacity ? Count : 0;
}
public bool Contains(T item)
{
return _buffer.Contains(item);
}
///
/// Attempts to remove and return the element at the front of the buffer in O(1) time.
///
/// The element at the front if the buffer is not empty.
/// True if an element was removed, false if the buffer is empty.
public bool TryPopFront(out T result)
{
if (Count == 0)
{
result = default;
return false;
}
int frontIndex = GetHeadIndex();
result = _buffer[frontIndex];
_buffer[frontIndex] = default; // Clear reference for GC
Count--;
if (Count == 0)
{
_position = 0;
}
return true;
}
///
/// Attempts to remove and return the element at the back of the buffer in O(1) time.
///
/// The element at the back if the buffer is not empty.
/// True if an element was removed, false if the buffer is empty.
public bool TryPopBack(out T result)
{
if (Count == 0)
{
result = default;
return false;
}
int backIndex = AdjustedIndexFor(Count - 1);
result = _buffer[backIndex];
_buffer[backIndex] = default; // Clear reference for GC
Count--;
_position = Count == 0 ? 0 : backIndex;
return true;
}
[ProtoBeforeSerialization]
private void OnProtoSerialize()
{
if (Count == 0)
{
// Ensure any previous lease is returned
_serializedItemsLease.Dispose();
_serializedItems = null;
return;
}
// Return any previous lease before renting a new one
_serializedItemsLease.Dispose();
// Rent a temporary list to avoid allocations during serialization
_serializedItemsLease = Buffers.List.Get(out List buffer);
for (int i = 0; i < Count; i++)
{
buffer.Add(_buffer[AdjustedIndexFor(i)]);
}
_serializedItems = buffer;
}
[ProtoAfterSerialization]
private void OnProtoSerialized()
{
// Release rented list back to pool
_serializedItemsLease.Dispose();
_serializedItems = null;
}
[ProtoAfterDeserialization]
private void OnProtoDeserialized()
{
int itemCount = _serializedItems?.Count ?? 0;
int capacity = Capacity;
if (capacity < itemCount)
{
capacity = itemCount;
}
Capacity = capacity;
if (_buffer == null)
{
_buffer = new List();
}
else
{
_buffer.Clear();
}
if (itemCount > 0)
{
_buffer.AddRange(_serializedItems);
}
Count = itemCount;
_position = Count < Capacity ? Count : 0;
_serializedItems = null;
// Ensure no outstanding lease remains
_serializedItemsLease.Dispose();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int GetHeadIndex()
{
int capacity = Capacity;
if (capacity == 0 || Count == 0)
{
return 0;
}
return (_position - Count).PositiveMod(capacity);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int AdjustedIndexFor(int index)
{
int capacity = Capacity;
if (capacity == 0 || Count == 0)
{
return 0;
}
int adjustedIndex = GetHeadIndex() + index;
if (adjustedIndex >= capacity)
{
adjustedIndex -= capacity;
}
return adjustedIndex;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void BoundsCheck(int index)
{
if (!InBounds(index))
{
throw new IndexOutOfRangeException($"{index} is outside of bounds [0, {Count})");
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool InBounds(int index)
{
return 0 <= index && index < Count;
}
}
}