Tutorial: Writing a Recorder Program
Glossary
- What Is A Recorder Program?
- Builder Pattern
- Imports (API)
- Setup
- Globals
- KeyframeRecorder
- TriggerRecorder
- Start Keyframe
- Physics Simulator
- Easing Simulator
- Exporter
- Gotchas and Bugs
What Is a Recorder Program?
The Recorder Program is a script used to simulate your coaster and create a Train Animation.
Think of the plugin as a creative framework for simulating roller coasters.
The program must be a ModuleScript that returns a function. It takes one argument, which is the api passed in. Your script should something like:
return function (api)
-- program goes here
end
If you created a new project using
Initialize Project
, you should have been given a template script that will allow you to get up and running quickly. It should already have everything from API import, start, simulation, and export. The following should explain what each of those do, especially if you are creating one from scratch.
Before we get into the specifics, first a note on how the API is designed.
Builder Pattern
https://en.wikipedia.org/wiki/Builder_pattern
Many of the classes feature a builder pattern constructor. Anything that has the postfix Builder
is a builder pattern.
Most will implement the following methods:
Build
Will create a new class of whatever the builder is building. Does not Destroy the builder! This is useful if you clone the builder after editing it's properties.
Finish
Will build, returning the object, and destroy the builder. This is useful for single use builders.
Clone
Clones the builder. A select few implement this method.
Destroy
The deconstructor. Destroys the builder.
Imports (API)
As much as I would have liked to bake it into the script or have it as global variables, everything you need is given to you in one big table. I did the due to the fact that Roblox Studio does not offer an option to turn off lints for certain keywords and you would have seen annoying errors in Script Analysis
.
Program API
--- API
-- name : type
api {
Compiler [Deprecated] : TrainAnimationCompiler;
Recorder : TrainAnimationRecorder;
CFrameTrain : CFrameTrain;
CFrameTrack : {
CFrameTrack : CFrameTrack,
PointToPointCFrameTrack : PointToPointCFrameTrack,
PointToPointCFrameTrack2 : PointToPointCFrameTrack2,
TurntableCFrameTrack : TurntableCFrameTrack,
MovingSectionCFrameTrack : MovingSectionCFrameTrack,
TweenedPointsCFrameTrack : TweenedPointsCFrameTrack,
CreateFromInstanceData : function (
trackData : Instance
) -> CFrameTrack;
};
CreateCFrameTrack : function (
trackData : Instance
) -> CFrameTrack;
TrackSectionsData : TrackSectionsData;
TrackType : TrackType;
TypeSection : TypeSection;
DoCompile [Deprecated] : function (
compiler : TrainAnimationCompiler,
trackSectionsData : TrackSectionData,
stopPosition : number,
?stopExactly : boolean = compiler.StopExactlyAtStopPosition,
?numPasses : number = 0
) -> nil;
ExportAnimation [Deprecated] : function (
compiler : TrainAnimationCompiler,
parent : Instance,
name : string
) -> nil;
}
Compiler
is Deprecated, but still accessible. I found it to be too simple for what I wanted to do and difficult to update.
Recorder API
Recorder is the newest way of creating TrainAnimationPlayers and thus the currently supported one.
Many of the classes in the Recorder API have constructors that use the Builder pattern.
--- Recorder
-- name : type
Recorder {
KeyframeData : KeyframeData;
KeyframeDataBuilder : KeyframeDataBuilder;
PhysicsState : PhysicsState;
KeyframeRecorder : KeyframeRecorder;
TriggerRecorder : TriggerRecorder;
GetPassedTriggers : function (
recorder : KeyframeRecorder,
tracksData : Dictionary<CFrameTrack>,
triggerData : TriggerInfo []
) -> TriggerRecorder;
PhysicsSimulator : PhysicsSimulator;
EasingSimulator : EasingSimulator;
Exporter : Exporter;
KEYFRAME_TRANSITION_TYPE : KEYFRAME_TRANSITION_TYPE;
}
To get setup quickly, copy and paste this into your script:
local Recorder = api.Recorder
local PhysicsState = TrainAnimationRecorder.PhysicsState
local KeyframeRecorder = TrainAnimationRecorder.KeyframeRecorder
local TriggerRecorder = TrainAnimationRecorder.TriggerRecorder
local PhysicsSimulator = TrainAnimationRecorder.PhysicsSimulator
local EasingSimulator = TrainAnimationRecorder.EasingSimulator
local GetPassedTriggersBuilder = TrainAnimationRecorder.GetPassedTriggersBuilder
local Exporter = TrainAnimationRecorder.Exporter
local CFrameTrain = api.CFrameTrain
local CFrameTrack = api.CFrameTrack
local CreateCFrameTrack = api.CreateCFrameTrack
local TrackSectionsData = api.TrackSectionsData
local TrackType = api.TrackType
local TypeSection = api.TypeSection;
Setup
Setup the Track
CFrameTrack contains the positions for a Track for every position from 0 to it's track length. It can also return the vertical angle for a track position which is used for physics.
There are many different types of CFrameTracks. Each have different properties and uses, but they all inheirit the base class CFrameTrack.
Track Classes
PointToPoint
The oldest type of track and used for most kinds of normal coaster track. Each point must be the same distance from one another and is defined in the value DistanceBetweenPoints
.
Discouraged in favor of PointToPoint2
.
PointToPoint2
Recommended for most kinds of normal coaster track.
Compared to the first version, each point can be any distance away from another. Uses a hash to accomplish this with a minimal increase in computation from version 1.
Turntable
Used to define turntables or places where the train is rotated along one axis. You can also use this for tilt tracks. Must define 1 point which is the position it rotates around.
MovingSection
Useful for vertical lifts, drop tracks, and transfer tracks. Any track where the train is on a section of track that moves.
TweenedPoints
Useful for trackless dark ride type rides when you want to move the train in a particular way.
SpacekBezier (Not Implemented)
Instead of compiling the points, you can use the bezier spline itself. With this method, the track would most likely be smoother.
CreateCFrameTrack
CreateCFrameTrack is a function that takes the Instance data and, using the TrackClass value in it, creates and returns a CFrameTrack using the specified Track class.
local cframeTrack = CreateCFrameTrack(instanceData)
Tracks Data Table
To export, you need a table (a dictionary) containing all of the CFrameTracks used in the TrainAnimation. The indexes must match the name of the track used when simulating.
local tracksData = {
Main = cframeTrack;
["Track Name 2"] = cframeTrack2;
}
Setup the TrackSectionsData
TrackSectionsData defines where track physics such as acceleration and deceleration are applied along a track. One is required to use the PhysicsSimulator
.
There are two ways to create a TrackSectionsData:
Using a table
local trackSectionsDataTable = {
DefaultTrackType = {
Speed = 10;
Type = 0;
Acceleration = 1;
Deceleration = 1;
UsePhysics = true;
ClampToTargetSpeed = false;
},
TypeSections = {
{
Start = 0;
End = 1000;
Speed = 10;
Type = 1;
Acceleration = 1;
Deceleration = 1;
UsePhysics = true;
ClampToTargetSpeed = true;
},
-- ...
}
}
local trackSectionsData = TrackSectionsData.new(trackSectionsDataTable)
Using an Instance
local trackSectionsData = TrackSectionsData.FromInstanceData(instanceData)
The Instance Data is laid out similarly to the table version.
TrackSectionsData : Instance
DefaultTrackType : Instance
Type : IntValue
Acceleration : NumberValue
Deceleration : NumberValue
UsePhysics : BoolValue
ClampToTargetSpeed : BoolValue
TypeSections : Instance
[ANY NAME] : Instance
Start : NumberValue
End : NumberValue
Type : IntValue
Acceleration : NumberValue
Deceleration : NumberValue
UsePhysics : BoolValue
ClampToTargetSpeed : BoolValue
...
[]
Setup the Train PhysicsState
CFrameTrain contains the CFrame position data for a train.
PhysicsState contains the current physics state for the train. This includes the current speed and move direction. Additionally, with the CFrameTrain, it contains the WheelSet offsets using to calculate physics.
You can utilize your TrainModel in the TrainAnimationPlayer RideData to easily create a CFrameTrain.
The folllowing code should be included in your template.
local rideData = ReplicatedStorage:FindFirstChild(RIDE_NAME .. "RideData")
assert(rideData, "RideData not found! Name = " .. RIDE_NAME .. "RideData")
local trainAnimationPlayersData = rideData:FindFirstChild("TrainAnimationPlayers")
local cframeTrainPlayerData = trainAnimationPlayersData:FindFirstChild("Train1")
local cframeTrainModel = cframeTrainPlayerData:FindFirstChild("TrainModel")
local cframeTrain = CFrameTrain.FromInstanceData(cframeTrainModel)
local physicsState = PhysicsState.new(cframeTrain)
Globals
local TIME_INTERVAL = 1/24 -- to be used in the future
local RECORD_INTERVAL = 1/24
local GRAVITY = 7.5 ^ 2
local FRICTION = 0.125
RECORD_INTERVAL
This is passed into everything that returns a KeyframeRecorder
.
At the moment, the TrainAnimation
only supports a fixed timestep, so varying the record interval is discouraged.
GRAVITY
Used for the PhysicsSimulator
FRICTION
Also used for the PhysicsSimulator
KeyframeRecorder
KeyframeRecorder holds all of the keyframes, KeyframeData, for an animation.
local rideRecorder = KeyframeRecorder.new()
KeyframeData
KeyframeData {
Position : number,
TrackSpeed : number,
Track : string,
IsTrainBackwards : boolean,
HasPassedTrackEnd : boolean,
HasPassedTrackEndDirection : boolean,
TransitionType : KEYFRAME_TRANSITION_TYPE
Length : number
}
KEYFRAME_TRANSITION_TYPE
An Enum that determines how to tween between two keyframes. The first keyframe's TransitionType is used.
Useful Methods
Append
Adds the keyframes from one KeyframeRecorder to the end of itself.
keyframeRecorder:Append(
otherKeyframeRecorder : KeyframeRecorder
) -> nil
Example:
keyframeRecorder:Append(otherKeyframeRecorder)
GetLastTrackPosition
Gets track position value of the last keyframe. Useful for setting up the simulation state's start position.
keyframeRecorder:GetLastTrackPosition() -> number
Example:
local position = keyframeRecorder:GetLastTrackPosition()
AddTimeAmountOfKeyframe
Because varying keyframe lengths is not currently supported, to add a section where the train stays in one spot, you must add the necessary amount of keyframes. This can be a bit tedious. Luckily I added a method to assist with this.
keyframeRecorder:AddTimeAmountOfKeyframe(
keyframe : Keyframe,
timeAmount : number,
timeInterval : number
) -> nil
Example:
keyframeRecorder:AddTimeAmountOfKeyframe(startKeyframe, 30, RECORD_INTERVAL)
TriggerRecorder
TriggerRecorder records all of the triggers for an animation.
A trigger is used to notify when the animation player has passed a certain time stamp, usually the the train has passed a certain positioin on the track. This is useful for scripting events on the ride.
local triggerRecorder = TriggerRecorder.new()
Recording Triggers
Using Recorder Length
This is useful if you have already determined where in your animation you want to add a trigger.
triggerRecorder:Add(
name : string,
timePosition : number
)
triggerRecorder:Add("TRIGGER_NAME_HERE", rideRecorder:GetLength())
Using Get Passed Triggers
Instead of stopping a simulation to add a trigger at the time position, you can do it after you have done your simulation.
The GetPassedTriggers function looks through all the keyframes in your KeyframeRecorder and finds the time positions where it passes any trigger in the given list.
GetPassedTriggers (
recorder: KeyframeRecorder,
triggerData: TriggerInfo [],
cframeTrack: CFrameTrack,
?trackName: string = cframeTrack.Name,
?rangeStart: number = 1,
?rangeEnd: number = #recorder.Keyframes
) -> TriggerRecorder
TriggerInfo {
Name : string,
Position : number,
}
There is also a Builder pattern implementation that aids in creating your trigger list.
local passedTriggerRecorder = GetPassedTriggersBuilder.new()
:WithRideRecorder(rideRecorder)
:WithCFrameTrack(mainCFrameTrack)
:WithTrigger("TRIGGER_NAME", 100)
:Finish()
rideRecorder:Append(passedTriggerRecorder)
Start Keyframe
Our animation needs a start keyframe
We can use the KeyframeDataBuilder
in order to build a new keyframe to add to our system.
local START_POSITION = 10
local startKeyframe = TrainAnimationRecorder.KeyframeDataBuilder.new()
:WithPosition(START_POSITION)
:WithTrack(mainCFrameTrack.Name)
:WithTrackSpeed(0)
:WithTrainDirection(true)
:WithLength(RECORD_INTERVAL)
:WithTransitionType(TrainAnimationRecorder.KEYFRAME_TRANSITION_TYPE.LINEAR)
:Finish()
-- set start keyframe
rideRecorder:AddKeyframe(startKeyframe)
Additionally, you can use the BuildKeyframe
method of KeyframeRecorder
to build and add in one.
rideRecorder:BuildKeyframe()
:WithPosition(START_POSITION)
:WithTrack(mainCFrameTrack.Name)
:WithTrackSpeed(0)
:WithTrainDirection(true)
:WithLength(RECORD_INTERVAL)
:WithTransitionType(TrainAnimationRecorder.KEYFRAME_TRANSITION_TYPE.LINEAR)
:Finish()
-- no AddKeyframe necessary
Physics Simulator
Simulates physics for a singular track and train. This is how you make your trains move realistically (kinda).
PhysicsSimulator.Simulate(
simulationState : PhysicsSimulationState,
physicsState : PhysicsState
) -> KeyframeRecorder, SimulationStopData
In the SimulationState, you can determine where to stop the simulation in the following ways: - Adding a StopPosition: The simulation will stop when it passes the given position. - Stopping When the Direction Changes: Will stop when it's move direction has changed. Useful to prevent valleying.
There are also certain times where it will stop automatically:
- MaxValleyTime: When the train valleys, or slows to under a given speed for a certain amount of time, it will be stopped early. This usually won't be fired most of the time as it will reach the MaxSimulationTime before this happens.
- MaxSimulationTime: If all else fails, it will stop when the MaxSimulationTime is reached. The default is 600 seconds, or 5 minutes. Please do not set this to math.huge
O_O
- StoppedByTrackType: If you have a Brake section with it's speed set to zero, when the train's speed reaches zero on this section, the simulation will stop and this will be returned.
Along with a KeyframeRecorder, the Simulate function returns data on how it stopped. The data should be treated like an Enum in the Rust programming language where data can also be attached.
SimulationStopReason {
Type : string
}
-- the names of these are SimulationStopReason's Type value
PassedStopPosition {
Name : string,
Position : number,
TimeDifference : number,
}
DirectionChanged {
Direction : boolean
}
StoppedByTrackType {
}
HasValleyed {
}
ReachedMaxCompileTime {
}
Use:
local simulationState = PhysicsSimulator.SimulationStateBuilder.new()
:WithCFrameTrack(mainCFrameTrack) -- Sets CFrameTrack
:WithStartPosition(rideRecorder:GetLastTrackPosition()) -- Sets the start position; You can use KeyframeRecorder:GetLastTrackPosition() to get the position of the last Keyframe
:WithTrackSectionsData(mainTrackSectionsData) -- Sets TrackSectionsData
:WithTrainDirection(true) -- Sets whether the TrainModel faces forwards or backwards
:WithPositionClampedToTrackLength(true) -- If true, the position keeps in the range [0, CFrameTrack.Length)
:WithGravity(GRAVITY) -- Sets Gravity
:WithFriction(FRICTION) -- Sets Friction
:WithTimeInterval(TIME_INTERVAL) -- Sets interval for updating physics (not used)
:WithRecordInterval(RECORD_INTERVAL) -- Sets interval for recording
:UntilStopPosition(100, "Stop") -- Adds a StopPosition
:UntilStopPosition(200, "Stop2") -- Adds another StopPosition. You can add as many as you want
:UntilDirectionChanges() -- Sets StopWhenDirectionChanges to true
:Finish()
local physicsRecorder, simulationStopReason = PhysicsSimulator.Simulate(
simulationState,
physicsState
)
-- checking stop reason
if (simulationStopReason.Type == "PassedStopPosition") then
if (simulationStopReason.Name == "Stop") then
print(simulationStopReason.Position)
elseif (simulationStopReason.Name == "Stop2") then
print(simulationStopReason.TimeDifference)
end
else -- it did not stop how we wanted
warn(("Stop Reason: %s"):format(simulationStopReason.Type))
end
rideRecorder:Append(physicsRecorder)
Easing Simulator
Simulates an easing for a given change in position. Simular styles as in TweenService.
I like using this to smoothly stop a train in the station.
https://github.com/EmmanuelOga/easing
EasingSimulator.Simulate(
simulationState : EasingSimulationState,
physicsState : PhysicsState
) -> KeyframeRecorder
Easing Types (Converted to PascalCase):
EASING {
Linear,
InQuad,
OutQuad,
InOutQuad,
OutInQuad,
InCubic,
OutCubic,
InOutCubic,
OutInCubic,
InQuart,
OutQuart,
InOutQuart,
OutInQuart,
InQuint,
OutQuint,
InOutQuint,
OutInQuint,
InSine,
OutSine,
InOutSine,
OutInSine,
InExpo,
OutExpo,
InOutExpo,
OutInExpo,
InCirc,
OutCirc,
InOutCirc,
OutInCirc,
InElastic,
OutElastic,
InOutElastic,
OutInElastic,
InBack,
OutBack,
InOutBack,
OutInBack,
InBounce,
OutBounce,
InOutBounce,
OutInBounce,
}
Use:
local lastTrackPosition = rideRecorder:GetLastTrackPosition()
local EasingSimulate = EasingSimulator.Simulate
local EasingSimulationStateBuilder = EasingSimulator.SimulationStateBuilder
local tweenRecorder = EasingSimulate(
EasingSimulationStateBuilder.new()
:WithCFrameTrack(mainCFrameTrack)
:WithTweenType(EasingSimulator.EASING.OutSine)
:WithTimeInterval(RECORD_INTERVAL)
:WithStartPosition(lastTrackPosition)
:WithPositionChange(5)
:WithDuration(3)
:WithPositionClampedToTrackLength(true)
:Finish(),
physicsState
)
Exporter
The Exporter API contains methods to export your animation as data that can be read at your game's runtime.
To use it, you need the following: - The KeyframeRecorder containing all of the keyframes for this animation - The TriggerRecorder containing all of the triggers for this animation. Make sure they positioned appropriately, otherwise they may fire in the wrong spot. - A Dictionary of the Tracks used in the Animation. It will error if any are missing. - You can also add each manually and overwrite the Track name
Like the other classes, it contains a builder class it aid in creating.
Additionally, you can specify the start and end keyframe indexes you want to export.
local exporter = Exporter.ExporterBuilder.new()
:WithKeyframeRecorder(rideRecorder) -- KeyframeRecorder
:WithTriggerRecorder(triggerRecorder) -- TriggerRecorder
:WithCFrameTracks(tracksData) -- Dictionary<CFrameTracks>
:Finish()
Once an Exporter
object is created, you can choose which method you want to use to export.
AsModuleScript
Exporter:AsModuleScript() -> ModuleScript
AsModuleScript
exports it as mostly human readable Roblox lua.
AsBitBufferModule
Exporter:AsBitBufferModule() -> ModuleScript
AsBitBufferModule
exports it as BitBuffered data. It's not readable, but it's more compact than AsModuleScript
Finally, once you created your animation data instance, just add it to your RideData.
local trainAnimationsData = rideData:FindFirstChild("TrainAnimations")
local module = exporter:AsModuleScript()
module.Name = "RideAnimation"
module.Parent = trainAnimationsData
Gotchas and Bugs
-
The length between keyframes must be the same meaning that the whole animation must use the same timestep. Currently, it is set to
1/24
. In the future, it will be determined by the first keyframe. Later, if I figure it out, I'll allow varying time intervals. -
If a recorded animation is too long, the Exporter will error with
String too long
. This also happens if it valleys on track end because the times it passes the end of the track is recorded.