Board Game Programming Tutorial – Event System

The Event system is the “Controller” part of the Model/View/Controller pattern in our games. The event system has two parts: the “Timeline” class which executes Events in order and the “Event” classes which are responsible for updating the game model, possibly creating other events, telling the View to animate the player action, and re-drawing the GUI.

In a board game, there are few enough events that it is possible to save the game by saving all the events to a file and to load by re-processing all the events. Undo can be implemented by simply re-processing all the events except the last one.

The “Timeline” class stores all the planned and executed events to support load/save/undo and has a place in the main game loop to execute planned events.

The “Events” that the timeline stores and executes have a common base class that stores information about how/whether to undo, and defines Do and Act functions. “Do” is called to update the Model and Act is called to update the View. When a game is loaded, only the Do functions are called.

Some events shouldn’t be undone, so we have an Undoable flag in the event. This is used both to prevent undo the game-setup events and to prevent undo after some random events where players shouldn’t be able to change their minds.

Some events are created by the player interacting with the GUI and other are created by the game itself. When a player presses the Undo button, they want to go back to the last thing the _player_ did, so we want to undo all the internal events plus the last player event. We keep a QContinueUndo flag in the event to support this capability.

Timeline

The Timeline is a singleton MonoBehaviour that starts a Coroutine to process newly created events. When an event is processed, the event can request a delay before the next event is processed. This allows time for animations. The timeline has the capability to save and load sets of events and to undo the last event.


public class Timeline : MonoBehaviour
{

  public static Timeline theTimeline;
  bool QReprocessingEvents = false;

  protected List<TimelineEvent> myPendingEvents = new List<TimelineEvent>();
  protected List<TimelineEvent> myEvents = new List<TimelineEvent>();

  private bool myQProcessingEvent = false;

  void Awake()
  {
    theTimeline = this;
    StartCoroutine( processEvents() );
  }
  void OnDestroy()
  {
    theTimeline = null;
  }
  IEnumerator processEvents()
  {
    while( true )
    {
      if (myPendingEvents.Count > 0)
      {
        myQReady = false;
        TimelineEvent e = myPendingEvents.Pop(0);
        myEvents.Add(e);

        myQProcessingEvent = true;
        try { e.Do(this); }
        catch ( Exception exception )
        {
          Debug.LogException(exception);
        }
        myQProcessingEvent = false;
        float delay = e.Act();
        if (delay > 0)
          yield return new WaitForSeconds(delay);
      }
      else
      {
        myQReady = true;
      }
      yield return new WaitForEndOfFrame();
    }
  }

The View or another event can add a new event to be executed. The QReprocessing check is to prevent events from adding child events while loading. The child events are already in the save file and don’t need to be added again.

  public void addEvent(TimelineEvent e)
  {
    if (QReprocessingEvents) return;

    myPendingEvents.Add(e);

    if (myPendingEvents.Count > 100)
      Debug.Log("Control::addEvent - Warning: There are " + myPendingEvents.Count + " pending events.");
  }

To Undo an event we find all the events that need to be undo and then re-execute all the events up to the prior event.

  public void undo()
  {
    if (myEvents.Count == 0)
      return;

    // Find the first event
    TimelineEvent eventToUndo = myEvents[myEvents.Count - 1];

    if( !eventToUndo.QUndoable )
    {
      Debug.Log( "Control::undo - Attempting to undo an event that can't be undone." );
      return;
    }

    myPendingEvents.Clear();

    if (eventToUndo.QContinueUndo)
    {
      for (int i = myEvents.IndexOf(eventToUndo);
            eventToUndo.QContinueUndo && i >= 0; --i)
        eventToUndo = myEvents[i];
    }
    lazyUndo(eventToUndo);
    return;
  }
  public void lazyUndo(TimelineEvent eventToUndo)
  {
    int index = myEvents.IndexOf(eventToUndo);
    List<TimelineEvent> toProcess = new List<TimelineEvent>();
    toProcess.AddRange(myEvents.GetRange(0, index));
    myEvents.Clear();

    reprocessEvents( toProcess );
  }

Load and save is handled the same way as Undo, by reprocessing a list of events. This does require that all events be serializable. We use the Newtonsoft JSON library for serialization. This allows lists, dictionaries, and inheritance.

  public void reprocessEvents(List<TimelineEvent> events)
  {
    theTimeline.StopAllCoroutines();
    myEvents.Clear();
    myPendingEvents.Clear();

    QReprocessingEvents = true;
    while( events.Count > 0 )
    {
      TimelineEvent e = events.Pop(0);
      myEvents.Add( e );
      e.Do( this );
 //if ( PlayerList.playerAtPosition(0) != null )
   //Debug.Log("e: "+e.Id + "," + e.PlayerPosition+" stack=" + string.Join(",", PlayerList.playerAtPosition(0).StateStack.Select(s => s.ToString()).ToArray()));
    }
    QReprocessingEvents = false;
    StartCoroutine(processEvents());

    this.ExecuteLater(0.1f, OnEventsReprocessed);
  }
  virtual public void OnEventsReprocessed()
  {
    // Draw GUI
    GameGUI.theGameGUI.draw();
    this.ExecuteLater(0.2f, () => GameGUI.theGameGUI.LoadOverlay.gameObject.SetActive(false));
  }

  public static string defaultSaveName()
  {
    return System.DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss");
  }
  public string save( string saveName )
  {
    if (saveName == null)
      saveName = defaultSaveName();

    string dirName = Application.persistentDataPath + "/savedGames/" + saveName;
    System.IO.Directory.CreateDirectory( dirName );

    var settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto };

    using ( StreamWriter fs = new StreamWriter( dirName + "/Events", false ) )
      fs.Write( JsonConvert.SerializeObject( myEvents, Formatting.Indented, settings ) );

    using( StreamWriter fs = new StreamWriter( dirName + "/Pending", false ) )
      fs.Write( JsonConvert.SerializeObject( myPendingEvents, Formatting.Indented, settings ) );

    return saveName;
  }

  public void saveScreenshot( string name )
  {
    string dirName = Application.persistentDataPath + "/savedGames/" + name;
    Application.CaptureScreenshot( dirName + "/Snapshot.png" );
  }

  public static List<TimelineEvent> load(string name)
  {
    List<TimelineEvent> rv = new List<TimelineEvent>();

    string dirName = Application.persistentDataPath + "/savedGames/" + name;

    using( StreamReader fs = new StreamReader( dirName + "/Events" ) )
    {
      string obj = fs.ReadToEnd();
      rv.AddRange(
        JsonConvert.DeserializeObject<List<TimelineEvent>>(obj, new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto })
                  );
    }

    using( StreamReader fs = new StreamReader( dirName + "/Pending" ) )
    {
      string obj = fs.ReadToEnd();
      rv.AddRange(
        JsonConvert.DeserializeObject<List<TimelineEvent>>(obj, new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.Auto })
                  );
    }

    return rv;
  }
}

TimelineEvent

TimelineEvent is the base class for all the events.

[System.Serializable]
public abstract class TimelineEvent
{
  // These default values are great for Initialize and AddPlayer Events.
  [Flags]
  public enum Attribute
  {
    None = 0,
    Undoable = 1,
    ContinueUndo = 2
  }
  [JsonIgnore]
  public bool QUndoable {
    get { return (Flags & Attribute.Undoable) == Attribute.Undoable; }
    set { if (value) Flags |= Attribute.Undoable; else Flags &= ~Attribute.Undoable; } }
  [JsonIgnore]
  public bool QContinueUndo
  {
    get { return (Flags & Attribute.ContinueUndo) == Attribute.ContinueUndo; }
    set { if (value) Flags |= Attribute.ContinueUndo; else Flags &= ~Attribute.ContinueUndo; }
  }

  public Attribute Flags = Attribute.None;
  abstract public void Do(Timeline timeline);
  public virtual float Act( bool qUndo = false ) { return 0; }
}

We also have two derived classes for Player created events and Internal (engine) events.

[System.Serializable]
public abstract class EngineEvent : TimelineEvent
{
  public EngineEvent()
  {
    QUndoable = true;
    QContinueUndo = true;
  }
}

[System.Serializable]
public abstract class PlayerEvent : TimelineEvent
{
  public int PlayerPosition = -1;
  [JsonIgnore] protected Player _player { get { return PlayerList.playerAtPosition(PlayerPosition); } }
  [JsonIgnore] protected PlayerGUI _gui { get { return GameGUI.playerPadForPosition(PlayerPosition); } }
  public PlayerEvent() { }
  public PlayerEvent(Player player)
  {
    PlayerPosition = player != null ? player.Position : -1;
    QUndoable = true;
    QContinueUndo = false;
  }
}