Using PlayCanvas Events to build a Sequential Timeline
I’m working on a VR side project that can be summed up as a narrative experience. The app will consist of approximately ten minutes of an audio discussion, with scene entities giving the appearance of reacting to the dialog.
I needed a timeline to keep it all coordinated, so I built one. I made a separate project for the timeline. Check it out and let me know if you have suggestions.
Before we get to the timeline, I want to cover a few assumptions and features of the app I’m building.
- Each entity in the app will handle its own state and behavior.
- Each entity can publish one or more events that can be called by the timeline component.
- These events will call functions on the entity script that can modify the state, activate animations, play sounds, etc.
- The timeline has no knowledge of the internal working of these functions. It can simply fire the events.
- The timeline is not meant to be a replacement for the animation features in PlayCanvas. It’s just a simple data processing script that will fire events at set intervals.
Entities and Events
An example of a script attached to an entity:
var BlueBoxController = pc.createScript('blueBoxController');
BlueBoxController.prototype.initialize = function () {
// Publish the events
this.app.on("blue:placeBlueBox", this.placeBlueBox, this);
this.app.on("blue:scaleBlueBox", this.scaleBlueBox, this);
};
BlueBoxController.prototype.placeBlueBox = function () {
// Handle the internal logic of placing the blue box
};
BlueBoxController.prototype.scaleBlueBox = function () {
// Handle the internal logic of scaling the blue box
};
All the entities in the app will have scripts like this one, each one publishing as many events as needed. Publishing the events this way allows me to call them with this.app.fire()
. For complex apps with hundreds of events/actions, this may not be wise. In my case though, the app only contains a handful of entities and around thirty unique events.
Timeline Data & Script
I’m storing the timeline data as JSON and loading it into the Timeline script as an asset. The JSON consists of an outer array of arrays. The outer array is the entire timeline, each child array is a sequence or collection of steps.
- Timeline of sequences (outer array)
- Sequence (child arrays)
- Step
- Time: when to run the actions
- Actions: an array of strings, the events to call with
this.app.fire()
- Step
- Sequence (child arrays)
Instead of writing one massive timeline, I decided to break it into sequences, which are broken into steps. This gives me some flexibility with how and when I run sequences. For example, I could run a sequence, then wait for the player to complete an objective before running the next sequence. I can also chain sequences together.
An example of a sequence:
Time | Actions |
0 | Start audio clip that will play for the duration of the sequence |
2 seconds | Spawn an actor in the scene Play spawn sound effect |
5 seconds | Start movement Start walking animation |
The Timeline script itself is simple. All it really does is load data from the JSON file and process it over time. The timeline:
- Uses some internal variables to keep track of time and running status.
- Loads a sequence into a focus variable and pre-calculates the index for the following sequence.
- Loads a step, time, and actions into focus variables and pre-calculates the index for the following steps.
- Executes each steps action when the time is reached. The update function uses the data in the focus variables to determine what to run and when.
- Stops the timeline when all steps have been run for a sequence. The exception to this is chaining. I can add an action to the last step of a sequence that will load the next sequence. This lets me chain and loop sequences together.
I decided to use the focus/cache variables (currentSequence, currentStep
, etc.) to reduce the amount of processing that happens in the update loop.
var Timeline = pc.createScript('timeline');
Timeline.attributes.add('timelineJson', {
type: 'asset',
assetType: 'json'
});
// initialize code called once per entity
Timeline.prototype.initialize = function () {
// Accumulate time during a sequence, used to determine when to run each step
this.timelineTime = 0;
this.timelineIsRunning = false;
// Get the timeline data from the JSON file
this.timelineData = this.timelineJson.resources;
// Sequence: A collection of steps that will be fun over time (think of these as small timelines)
this.currentSequence = null;
this.nextSequenceIndex = 0;
// A step in a sequence with a time and actions
this.currentStepTime = 0; // When to run the next step
this.currentStepAction = []; // The actions to run for the next step
this.nextStepIndex = 0;
// Register events
this.app.on("debug:startNextSequence", this.startNextSequence, this); // used for debugging only
this.app.on("timeline:startNextSequence", this.startNextSequence, this);
};
// update code called every frame
Timeline.prototype.update = function (dt) {
// Accumulate time only while the timeline is running
this.timelineTime = this.timelineIsRunning ? this.timelineTime += dt : this.timelineTime;
if (this.timelineIsRunning && this.timelineTime >= this.currentStepTime) {
if (this.currentStepAction.length > 0) {
this.currentStepAction.forEach(element => this.app.fire(element));
}
this.queueNextStep();
}
};
// This can be called on game start to kick off a sequence
// This can also be called as an event, which is useful to chain sequences together
Timeline.prototype.startNextSequence = function () {
// Use the next sequence index to load a sequence into focus
this.currentSequence = this.timelineData[this.nextSequenceIndex];
// Calculate the index for the next sequence. Overflow back to 0 at the end of all sequences
if (this.nextSequenceIndex < (this.timelineData.length - 1)) {
this.nextSequenceIndex = this.nextSequenceIndex + 1;
} else {
this.nextSequenceIndex = 0;
}
console.info("SEQUENCE", this.currentSequence, "next sequence", this.nextSequenceIndex);
// Reset step variables
this.nextStepIndex = 0;
this.currentStepTime = 0;
this.currentStepAction = [];
// Reset the time and start the timeline
this.timelineTime = 0;
this.timelineIsRunning = true;
};
Timeline.prototype.queueNextStep = function () {
// Get the next step from the current sequence and load it into the step variables
var step = this.currentSequence[this.nextStepIndex];
if (step !== null && step !== undefined) {
this.currentStepTime = step.time;
this.currentStepAction = step.actions;
console.log("step", this.nextStepIndex, step);
this.nextStepIndex++;
} else {
// If we have reached the end of the steps array, then stop the timeline
console.log("STOPPING TIMELINE");
this.timelineIsRunning = false;
}
};
The timeline component is far enough along to serve the need that I have for it, but if you have suggestions or changes that you think would be helpful, please let me know.