Code/Timeline.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Timeline;
/// <summary>
/// Simple timeline event - uses absolute time in seconds
/// </summary>
[System.Serializable]
public class TimelineEvent
{
/// <summary>
/// Time position in absolute seconds
/// </summary>
[Property]
public float Time { get; set; } = 0.0f;
public string EventId { get; set; }
public TimelineEvent() { }
public TimelineEvent(float time)
{
Time = time;
}
}
/// <summary>
/// Container for timeline events - single track with an event ID and its own duration
/// </summary>
[System.Serializable]
public class EventTracks
{
/// <summary>
/// The event ID that will be triggered for all events on this track
/// </summary>
[Property]
public string EventId { get; set; } = "event";
/// <summary>
/// All events on this track
/// </summary>
[Property]
public List<TimelineEvent> Events { get; set; } = new List<TimelineEvent>();
/// <summary>
/// Duration of this specific track in seconds (independent of main timeline)
/// </summary>
[Property]
public float Duration { get; set; } = 10.0f;
public EventTracks()
{
Events = new List<TimelineEvent>();
}
public EventTracks(params TimelineEvent[] events)
{
Events = new List<TimelineEvent>(events);
SortEvents();
}
/// <summary>
/// Add an event to the timeline
/// </summary>
public void AddEvent(TimelineEvent timelineEvent)
{
if (timelineEvent == null)
return;
Events ??= new List<TimelineEvent>();
Events.Add(timelineEvent);
SortEvents();
}
/// <summary>
/// Add an event at a specific time (in seconds)
/// </summary>
public void AddEvent(float timeInSeconds)
{
AddEvent(new TimelineEvent(timeInSeconds));
}
/// <summary>
/// Remove an event from the timeline
/// </summary>
public bool RemoveEvent(TimelineEvent timelineEvent)
{
Events ??= new List<TimelineEvent>();
return Events.Remove(timelineEvent);
}
/// <summary>
/// Clear all events
/// </summary>
public void Clear()
{
Events ??= new List<TimelineEvent>();
Events.Clear();
}
/// <summary>
/// Get events at a specific time (with tolerance)
/// </summary>
public IEnumerable<TimelineEvent> GetEventsAtTime(float timeInSeconds, float tolerance = 0.001f)
{
Events ??= new List<TimelineEvent>();
return Events.Where(e => Math.Abs(e.Time - timeInSeconds) <= tolerance);
}
/// <summary>
/// Get events within a time range (in seconds)
/// </summary>
public IEnumerable<TimelineEvent> GetEventsInRange(float startTimeInSeconds, float endTimeInSeconds)
{
Events ??= new List<TimelineEvent>();
return Events.Where(e => e.Time >= startTimeInSeconds && e.Time <= endTimeInSeconds);
}
/// <summary>
/// Get the next event after a given time
/// </summary>
public TimelineEvent GetNextEvent(float timeInSeconds)
{
Events ??= new List<TimelineEvent>();
return Events.FirstOrDefault(e => e.Time > timeInSeconds);
}
/// <summary>
/// Get the previous event before a given time
/// </summary>
public TimelineEvent GetPreviousEvent(float timeInSeconds)
{
Events ??= new List<TimelineEvent>();
return Events.LastOrDefault(e => e.Time < timeInSeconds);
}
/// <summary>
/// Sort events by time
/// </summary>
private void SortEvents()
{
Events ??= new List<TimelineEvent>();
Events.Sort((a, b) => a.Time.CompareTo(b.Time));
}
/// <summary>
/// Get event count
/// </summary>
public int Count => Events?.Count ?? 0;
public override string ToString()
{
return $"EventTracks '{EventId}' ({Count} events)";
}
}
/// <summary>
/// Wrapper for a float curve with metadata
/// </summary>
[System.Serializable]
public class TimelineFloatCurve
{
/// <summary>
/// The name/ID of this float curve
/// </summary>
[Property]
public string CurveId { get; set; } = "curve";
/// <summary>
/// The actual curve data
/// </summary>
[Property]
public Curve Curve { get; set; } = new Curve();
/// <summary>
/// Whether this curve is enabled
/// </summary>
[Property]
public bool Enabled { get; set; } = true;
[Property]
public bool Loop { get; set; } = false;
public TimelineFloatCurve()
{
Curve = new Curve();
}
public TimelineFloatCurve(string curveId)
{
CurveId = curveId;
Curve = new Curve();
}
/// <summary>
/// Evaluate the curve at a specific time
/// </summary>
public float Evaluate(float timeInSeconds)
{
if ( Loop )
{
return Curve.Evaluate( timeInSeconds.UnsignedMod( Curve.TimeRange.y) );
}
return Curve.Evaluate(timeInSeconds);
}
public override string ToString()
{
return $"FloatCurve '{CurveId}'";
}
}
/// <summary>
/// Component that listens for timeline events and float curve updates
/// </summary>
public class TimelineEventDispatcher : Component
{
// Dictionary to store event bindings
private Dictionary<string, Action> _eventBindings = new();
private Dictionary<string, Action<TimelineEvent>> _eventBindingsWithData = new();
private Dictionary<string, Action<float>> _floatCurveBindings = new();
protected override void OnAwake()
{
base.OnAwake();
// Subscribe to global timeline events
Timeline.OnEventTriggered += OnTimelineEvent;
Timeline.OnFloatCurveUpdated += OnFloatCurveUpdated;
// Register events manually (no reflection needed)
RegisterEvents();
}
protected override void OnDestroy()
{
base.OnDestroy();
Timeline.OnEventTriggered -= OnTimelineEvent;
Timeline.OnFloatCurveUpdated -= OnFloatCurveUpdated;
}
private void OnTimelineEvent(GameObject gameObject, string eventId, TimelineEvent evt)
{
// Only handle events for our GameObject
if (gameObject.Root != GameObject.Root)
return;
// Try simple action first
if (_eventBindings.TryGetValue(eventId, out var action))
{
action?.Invoke();
}
// Try action with event data
if (_eventBindingsWithData.TryGetValue(eventId, out var actionWithData))
{
actionWithData?.Invoke(evt);
}
}
private void OnFloatCurveUpdated(GameObject gameObject, string curveId, float value)
{
// Only handle curves for our GameObject
if (gameObject.Root != GameObject.Root)
return;
// Try float curve binding
if (_floatCurveBindings.TryGetValue(curveId, out var action))
{
action?.Invoke(value);
}
}
/// <summary>
/// Bind an event ID to a simple action
/// </summary>
public void BindEvent(string eventId, Action action)
{
_eventBindings[eventId] = action;
}
/// <summary>
/// Bind an event ID to an action that receives event data
/// </summary>
public void BindEvent(string eventId, Action<TimelineEvent> action)
{
_eventBindingsWithData[eventId] = action;
}
/// <summary>
/// Bind a float curve ID to an action that receives the curve value
/// </summary>
public void BindFloatCurve(string curveId, Action<float> action)
{
_floatCurveBindings[curveId] = action;
}
/// <summary>
/// Unbind an event
/// </summary>
public void UnbindEvent(string eventId)
{
_eventBindings.Remove(eventId);
_eventBindingsWithData.Remove(eventId);
}
/// <summary>
/// Unbind a float curve
/// </summary>
public void UnbindFloatCurve(string curveId)
{
_floatCurveBindings.Remove(curveId);
}
/// <summary>
/// Override this method to register your timeline events and curves
/// </summary>
protected virtual void RegisterEvents()
{
// Override this in derived classes to register events and curves
// Example:
// BindEvent("jump", OnJump);
// BindFloatCurve("volume", OnVolumeChanged);
}
}
/// <summary>
/// Main timeline component with reverse play support
/// </summary>
public class Timeline : Component
{
[Property]
public List<EventTracks> EventTracks { get; set; } = new List<EventTracks>();
[Property, InlineEditor]
public List<TimelineFloatCurve> FloatCurves { get; set; } = new List<TimelineFloatCurve>();
[Property]
public bool AutoPlay { get; set; } = false;
[Property]
public bool Loop { get; set; } = false;
[Property]
public float Duration { get; set; } = 5.0f;
/// <summary>
/// Playback speed multiplier. Negative values play in reverse.
/// </summary>
[Property]
public float PlaybackSpeed { get; set; } = 1.0f;
public float CurrentTime { get; private set; }
public bool IsPlaying { get; private set; }
public bool IsReversePlaying => PlaybackSpeed < 0;
/// <summary>
/// Event fired when a timeline event is triggered
/// </summary>
public static event Action<GameObject, string, TimelineEvent> OnEventTriggered;
/// <summary>
/// Event fired when a float curve value is updated
/// </summary>
public static event Action<GameObject, string, float> OnFloatCurveUpdated;
private float _lastTime = -1;
private List<TimelineEvent> _triggeredEvents = new();
protected override void OnAwake()
{
base.OnAwake();
if (EventTracks == null)
EventTracks = new List<EventTracks>();
if (FloatCurves == null)
FloatCurves = new List<TimelineFloatCurve>();
if (AutoPlay)
Play();
}
protected override void OnUpdate()
{
if (!IsPlaying)
return;
CurrentTime += Time.Delta * PlaybackSpeed;
// Clamp current time to bounds
CurrentTime = Math.Clamp(CurrentTime, 0, Duration);
CheckForEvents();
UpdateFloatCurves();
// Handle end of timeline
if ((PlaybackSpeed > 0 && CurrentTime >= Duration) ||
(PlaybackSpeed < 0 && CurrentTime <= 0))
{
if (Loop)
{
CurrentTime = PlaybackSpeed > 0 ? 0 : Duration;
_triggeredEvents.Clear();
}
else
{
Stop();
}
}
}
/// <summary>
/// Start playing forward
/// </summary>
public void Play()
{
PlaybackSpeed = Math.Abs(PlaybackSpeed); // Ensure positive
IsPlaying = true;
_triggeredEvents.Clear();
}
/// <summary>
/// Start playing in reverse
/// </summary>
public void PlayReverse()
{
PlaybackSpeed = -Math.Abs(PlaybackSpeed); // Ensure negative
IsPlaying = true;
_triggeredEvents.Clear();
}
/// <summary>
/// Toggle between forward and reverse play
/// </summary>
public void ToggleDirection()
{
PlaybackSpeed = -PlaybackSpeed;
_triggeredEvents.Clear(); // Clear to allow re-triggering events
}
public void Pause()
{
IsPlaying = false;
}
public void Stop()
{
IsPlaying = false;
CurrentTime = PlaybackSpeed < 0 ? Duration : 0;
_triggeredEvents.Clear();
}
public void Seek(float timeInSeconds)
{
CurrentTime = Math.Clamp(timeInSeconds, 0, Duration);
_triggeredEvents.Clear();
// Re-trigger events that should have happened by now
// This depends on playback direction
foreach (var eventTrack in EventTracks)
{
IEnumerable<TimelineEvent> eventsToTrigger;
if (PlaybackSpeed >= 0)
{
// Forward: trigger events from 0 to current time
eventsToTrigger = eventTrack.GetEventsInRange(0, CurrentTime);
}
else
{
// Reverse: trigger events from duration to current time
eventsToTrigger = eventTrack.GetEventsInRange(CurrentTime, Duration);
}
foreach (var evt in eventsToTrigger)
{
TriggerEvent(evt, eventTrack.EventId);
}
}
// Update float curves immediately
UpdateFloatCurves();
_lastTime = CurrentTime;
}
private void CheckForEvents()
{
foreach (var eventTrack in EventTracks)
{
IEnumerable<TimelineEvent> eventsToTrigger;
if (PlaybackSpeed >= 0)
{
// Forward playback: trigger events between last time and current time
eventsToTrigger = eventTrack.Events
.Where(e => e.Time > _lastTime && e.Time <= CurrentTime)
.Where(e => !_triggeredEvents.Contains(e));
}
else
{
// Reverse playback: trigger events between current time and last time
eventsToTrigger = eventTrack.Events
.Where(e => e.Time >= CurrentTime && e.Time < _lastTime)
.Where(e => !_triggeredEvents.Contains(e))
.OrderByDescending(e => e.Time); // Trigger in reverse order
}
foreach (var evt in eventsToTrigger)
{
TriggerEvent(evt, eventTrack.EventId);
}
}
_lastTime = CurrentTime;
}
private void UpdateFloatCurves()
{
foreach (var floatCurve in FloatCurves.Where(c => c.Enabled))
{
var value = floatCurve.Evaluate(CurrentTime);
OnFloatCurveUpdated?.Invoke(GameObject, floatCurve.CurveId, value);
}
}
private void TriggerEvent(TimelineEvent evt, string eventId)
{
if (_triggeredEvents.Contains(evt))
return;
_triggeredEvents.Add(evt);
OnEventTriggered?.Invoke(GameObject, eventId, evt);
}
/// <summary>
/// Add a new event track
/// </summary>
public EventTracks AddEventTrack(string eventId)
{
EventTracks ??= new List<EventTracks>();
var track = new EventTracks { EventId = eventId };
EventTracks.Add(track);
return track;
}
/// <summary>
/// Add a new float curve
/// </summary>
public TimelineFloatCurve AddFloatCurve(string curveId)
{
FloatCurves ??= new List<TimelineFloatCurve>();
var curve = new TimelineFloatCurve(curveId);
FloatCurves.Add(curve);
return curve;
}
/// <summary>
/// Get an event track by ID
/// </summary>
public EventTracks GetEventTrack(string eventId)
{
EventTracks ??= new List<EventTracks>();
return EventTracks.FirstOrDefault(t => t.EventId == eventId);
}
/// <summary>
/// Get a float curve by ID
/// </summary>
public TimelineFloatCurve GetFloatCurve(string curveId)
{
FloatCurves ??= new List<TimelineFloatCurve>();
return FloatCurves.FirstOrDefault(c => c.CurveId == curveId);
}
}
/// <summary>
/// Extended example with reverse play handling
/// </summary>
public class ExampleTimelineListener : TimelineEventDispatcher
{
protected override void RegisterEvents()
{
// Register your timeline events here
BindEvent("jump", OnJump);
BindEvent("footstep", OnFootstep);
BindEvent("explosion", OnExplosion);
BindEvent("door_open", OnDoorOpen);
BindEvent("door_close", OnDoorClose);
// Register float curve handlers
BindFloatCurve("volume", OnVolumeChanged);
BindFloatCurve("speed", OnSpeedChanged);
}
public void OnJump()
{
var timeline = GameObject.Components.Get<Timeline>();
var direction = timeline?.IsReversePlaying == true ? "landed" : "jumped";
Log.Info($"Player {direction}!");
// You can handle reverse differently if needed
if (timeline?.IsReversePlaying == true)
{
// Handle reverse jump (landing)
Sound.Play("land.sound");
}
else
{
// Handle forward jump
Sound.Play("jump.sound");
}
}
public void OnFootstep(TimelineEvent evt)
{
var timeline = GameObject.Components.Get<Timeline>();
var direction = timeline?.IsReversePlaying == true ? "←" : "→";
Log.Info($"Footstep {direction} at {evt.Time:F1}s");
Sound.Play("footstep.sound");
}
public void OnDoorOpen()
{
var timeline = GameObject.Components.Get<Timeline>();
if (timeline?.IsReversePlaying == true)
{
// When playing reverse, "door_open" event should close the door
Log.Info("Door closing (reverse)");
// Close door animation/sound
}
else
{
Log.Info("Door opening");
// Open door animation/sound
}
}
public void OnDoorClose()
{
var timeline = GameObject.Components.Get<Timeline>();
if (timeline?.IsReversePlaying == true)
{
// When playing reverse, "door_close" event should open the door
Log.Info("Door opening (reverse)");
// Open door animation/sound
}
else
{
Log.Info("Door closing");
// Close door animation/sound
}
}
public void OnExplosion()
{
var timeline = GameObject.Components.Get<Timeline>();
if (timeline?.IsReversePlaying == true)
{
Log.Info("Explosion reversing (implosion?)");
// Maybe play reverse explosion effect
}
else
{
Log.Info("Explosion!");
Sound.Play("explosion.sound");
}
}
public void OnVolumeChanged(float volume)
{
// Float curves work the same in both directions
//Sound.SetVolume(volume);
}
public void OnSpeedChanged(float speed)
{
// You might want to handle negative speeds for reverse
var timeline = GameObject.Components.Get<Timeline>();
var actualSpeed = timeline?.IsReversePlaying == true ? -speed : speed;
Log.Info($"Speed changed to: {actualSpeed}");
}
}