-
Notifications
You must be signed in to change notification settings - Fork 0
Technical Design
This is the technical design for the game mechanics of Derailed Deliveries.
-
Framework
-
Gameplay
The PlayerMovement works by listening to the event OnMove
of the PlayerInputParser. The Vector2 _playerInput
get's updated when the PlayerInputParser invokes the event OnMove
. The PlayerMovement updates the movement of the player's velocity every OnTick
of the TimeManager.
Each player has a Interactor
script on the player that uses a Physics.OverlapSphere()
to retrieve classes of type Interactable
infront of it when trying to interact. The Interactor
can send a Interact(Interactor interactor)
request to the Interactable
. Each Interactable
can have their own implementation of the method.
The Interactable class is the base class for all Interactables. This is what the Interactor class calls to when trying to Interact()
. The Interactor can also call the Use()
method. The Use()
method and Interact()
method both have their own checks; CheckIfInteractable()
and CheckIfUseable()
. The base Interactable's CheckIfUseable()
always returns false and is only used in parent classes.
The CoalOvenInteractable is a Interactable class that has it's own implementations of CheckIfUseable()
, CheckIfInteractable()
, Use()
and Interact()
. These are to communicate with the CoalOven when a player uses a CoalGrabbable on it.
The CoalPileInteractable is a Interactable class that as it's own implementation of CheckIfUseable()
and Interact()
. The Interact()
implementation is to spawn a CoalGrabbable and forward a InteractAsServer()
method to act as a mediator between the player's Interactor and the newly spawned CoalGrabbable.
The DeliveryBeltInteractable is a Interactable class that has it's own implementation of CheckIfInteractable()
, Interact(Interactor)
and Interact(UseableGrabbable)
. The CheckIfInteractable()
will only allow the origin Interactor if they are holding a Grabbable. When calling Interact()
the Grabbable gets checked if its of type BoxGrabbable. This accepted target will be sent lerping over the 2 transforms _startTransform
and _endTransform
. This is done using a DOTween.TO()
method of the DOTween plugin. When this 'animation' is complete it will get sent to the LevelTracker.
The ExitInteractable class is a Interactable class. This class has it's own implementation of CheckIfUseable()
and Use()
. It will only return true on CheckIfUseable()
if the origin Interactor does not have a current InteractingTarget
and the players have visited 2 unique stations before. When these conditions are met the player that Interact()
s with the interactable will call DespawnPlayer()
on the PlayerManager. If all players are despawned the class will call EndGame()
which is a method for ending the session.
The ShelfInteractable is a Interactable that has it's own implementation CheckIfInteractable()
, CheckIfUseable()
and Interact()
. This is a class that only returns true on CheckIfInteractable()
if the class is holding a UseableGrabbable or if the player is holding a InteractingTarget
, if theses conditions are met the InteractingTarget
s new parent will be the ShelfInteractable. Else the method will call a Interact()
on the _heldGrabbable
of the ShelfInteractable.
The TrainDirectionLevelInteractable is a Interactable class that has it's own implementation of CheckIfUseable()
and Use()
. The Use()
implementation only calls the method ToggleTrainDirection
on the TrainEngine.
-
TrainSpeedButtonInteractable
class
The TrainSpeedButtonInteractable is a Interactable class that has its own implementation ofUse()
andCheckIfUseable()
. TheUse()
implementation calls the methodAdjustSpeed()
on the TrainEngine.
The Grabbable is a Interactable class that implements it's own methods of the base Interactable class and expands upon it to allow for parenting to take place, simulating grabbing. The implementation of UseGrabbable()
holds the functionality to allow the grabbing behaviour. We trace if it's being grabbed with a bool IsBeingInteracted
and a reference to the origin Interactor _originInteractor
.
The UseableGrabbable is a Grabbable class that expands on the base functionality to allow this class to be used on other Interactable classes. This new class has a new method called GetCollidingInteracble()
that can be used to get a target. This method uses another new abstract method, the CheckCollidingType
method. This method is used to filter what kind of class the UseableGrabbable class should target. This way the classes that inherit from this class should only have to implement the abstract method CheckCollidingType()
. There are also 2 new methods that forward the Interact()
and Use()
, these are RunInteract()
and RunUse()
.
The BoxGrabbable is a UseableGrabbable class that only holds the implementation of CheckCollidingType()
. It's target is that of the ShelfInteractable class.
The CoalGrabbable is a UseableGrabbable class that only holds the implementation for CheckCollidingType()
. It's target is that of the CoalOvenInteractable.
The HammerGrabbable is a UseableGrabbable class that holds it's own implementation of GetCollidingInteractable()
, CheckCollidingType()
, CheckIfUseable()
and RunUse()
. It uses the IRepairable interface to check CanBeRepaired()
and when that returns true it calls Repair()
.
Since the train is a fairly large mechanic, the underlying technical components are divided here in this document for an easier reading experience. More general info about the train mechanic in the functional design.
- Controller
-
Wagons
The train consists of multiple wagons. We have to control the rotation and position of each wagon separately to make sure all wagons are traveling correctly after each other. A spacing value between wagons called_wagonSpacing
is used to maintain a predefined amount of space between wagons when moving the whole train. We also have an_wagonFollowDistance
value, which adds an overall position offset to all wagons, except the front wagon (this is used because the front wagon is way smaller than the other following wagon, this messes up the_wagonSpacing
.
-
Wagons
-
-
Networking
The train needs to be synced as close as possible between all remote clients and the server, that's why we are going to do the actual train moving using the Fishnet OnTick callback. This callback action is called whenever a tick occurs. We have the tick interval set at its default value, 30hz.
Using the OnTick callback ensures that the train position is synced correctly. It is important to note that we can't useUnityEngine.Time.DeltaTime
since we're not using the default Update loop anymore. That's why we have to use its equivalentTimeManager.TickDelta
to smooth out the train movement over the game frames.
-
Networking
-
-
Movement
To actually move the train along the spline track (aka the rails), the train keeps track of a float value calledDistanceAlongSpline
. This value is always clamped between 0-1 (because the spline line data is also claimed between 0-1) A constant velocity from the TrainEngine gets added to this value in the Fishnet OnTick callback to simulate movement.
To get the next world position of the train, we evaluate the world position usingSpline.EvaluatePosition
on the spline track to get an interpolated position value based on the time of the spline data (which in this case isDistanceAlongSpline
)
Same goes for the rotation of each wagon, which we can evaluate usingSpline.EvaluateTangent
which returns an interpolated direction of ratio t (DistanceAlongSpline
).
-
Movement
-
Rails
-
Rail tracks
The train needs rails to ride along. We use Unity Splines Tool package to easily draw a path for the rails to be instantiated on. We use the splines built-in componentSplineInstantiate
which automatically takes care of the rotation, scale, and placement of each rail segment. Using this spline tool we can build level layouts very fast and easily.
-
Rail tracks
![](./Images/TechnicalDesign/spline_manager.gif)
-
-
Rail splits
The train needs to be able to switch between different rail tracks when it encounters a rail split in order to choose a new path. These different connected paths also need to be reversible from any point. Internally we keep track of two different types of rail split which we have defined as:- Branch splits
-
Funnel splits
Branch splits splits takes a main track and branches it off into two different splits. Whereas Funnel splits takes two different splits and merges them into a single main track. As show here below in in the basic example image.
-
![](./Images/TechnicalDesign/RailSplitExample.png)
-
- The train makes use of a rail split when the
DistanceAlongSpline
value exceeds1f
, which means the train is at the end of its current spline. An internal counter called_currentRailSplitID
gets incremented and keeps track of the ID of the current rail split. Which we can use to determine what type the current rail split is. Then we snap the train to the new rail split.
It's important to note that when the train switches from splines (rail tracks) the length of the new spline is likely wildly different from the old spline, this means the train will move either much slower or much faster. Also, the spacing between the wagons will be different. That's why we have to refresh the new spline length to maintain the same proportional train speed and wagon spacing. This will fix these issues because we take in the spline length in all train-related calculations like speed/velocity and wagon spacing.
The placement layout of a rail split has a big impact on how smooth the transition between rails tracks will be. The rails need to be carefully laid over each other at the exact same position to avoid visual snapping/jitter when transitioning between rail tracks as show in the image below.
- The train makes use of a rail split when the
![](./Images/TechnicalDesign/RailSplit.png)
The Level Tracker keeps track of the points and is the final destination for the points check before the DeliveryBelt. It also generates the packages when the level starts based on the LevelData
.
- Generation
-
Packages and labels
The LevelTracker holds a ScriptableObject that holds a List of LevelData structs that holds data for each TrainStation. This data is to decide how many packages get generated. The LevelTracker generates aStationLabel
for each TrainStation and then proceeds to generate thhe amount of packages for each TrainStation that is defined in LevelData struct. -
Delivery Belt
When the player uses a Package on the DeliveryBelt and the Package'sPackageLabel
get's compared to the TrainStation'sStationLabel
. If these are equal the player's will receive the_succesfullDeliveryBonus
plus the BoxDamageable'sHealth
as points, else they will receive the_incorrectDeliveryPenalty
as points.
-
Packages and labels
The timer starts as soon as the game has entered the GameState
. The timer listens to TrainStationController
's event OnParkStateChanged
. When the state Parked
is true the the list _arrivedStations
gets checked for this station's _stationID
. If this _stationID
is not in _arrivedStations
the timer get's the _stationArrivalTimeBonus
added to the timer and the _stationID
get's added to the _arrivedStations
.
The MapManager is a class that manages all the MapTracks. The MapTracks are classes that hold a list of RectTransforms ( variable named MapPath
)that the MapManager Vector3.Lerp()
s the _mapIndicator
between to represent the players train. When the TrainController of the train invokes OnTrackSwitch
the MapManager finds a MapTrack that matches the track's TrackID, this MapTrack will be chosen as the current track and it's MapPath
will be used to Vector3.Lerp()
the _mapIndicator
between.
The AudioSystem class is responsible for playing various sounds effects at any time. It makes use of our AbstractSingleton
class to make it available from any other script by referencing AudioSystem.Instance
.
To get a more dynamic sound effect we have the ability to either:
- Randomize the pitch level of the audio clip
- Randomly choose a clip from a list binded to a particular
AudioCollectionType
.
The audio system is capable to play and randomize sound effects and to play song tracks. Every sound effect is binded to a AudioCollectionType
enum.
Whe choose an enum instead of a string for the sound binding because an enum is strongly typed, which means we can easily see all the available sound types from any other class when trying to play a sound effect. Also it is way less error prone using enums instead of typing every sound effect as a string when you want to play it.
To play a song in the background we can call the method AudioSystem.Instance.PlaySound()
The audio system will keep track of the currently played song, it will tween the currently played song volume to 0 if we try to play another song. The new song volume will also be tweened from 0 to 1. These two tweens will create a cross fade effect to make the transition from the old song to the new song smoother.
The camera shaker is responsible for adding a randomized shake to the camera controller. This camera shaker is attached to the main CinemachineVirtualCamera
which is used for the gameplay.
The camera shaker listens and subscribes a method called HandleSpeedChanged
to the action event TrainEngine.Instance.OnSpeedChanged
which gets invoked when the train speed is changed. We can then calculaye the new shake noise amplitude when HandleSpeedChanged
is called with the new speed value as a float.
When called, the HandleSpeedChanged method will dynamically adjusts the camera's shake intensity in response to changes in the train's speed. By lerping between no shake and maximum shake based on the train's speed relative to its maximum speed, the method ensures that the camera shake effect is proportionate to how fast the train is moving.
float amplitudeGain = Mathf.Lerp(0f, _startCameraNoiseAmplitude, Mathf.Abs(newSpeed) / TrainEngine.Instance.MaxSpeed);
_multiChannelPerlin.m_AmplitudeGain = amplitudeGain;
When adding new states you can create a new script and inherit from 2 classes:
- State
- Has basic functionality for registering/unregistering to the StateMachine and handling OnStateEnter, OnStateExit, OnStateEnteredFromChild and OnStateExitedToChild
- MenuState
- Has additional functionality for fading in/out UI corresponding UI.
Useful methods all states have and can use are:
- OnStateEnter
- This is called when the state is entered.
- This is not called when the state is entered and was previously in a child state.
- OnStateExit
- This is called when the state is exited.
- This is not called when the state exits to a child state.
- OnStateEnteredFromChild
- This is called when the state is entered and was previously in a child state.
- OnStateExitedToChild
- This is called when the state is exited but is entering a child state.
To go to a new state, we have several overloads of the method GoToState()
. The most used version is this:
StateMachine.Instance.GoToState<HostState>();
To define what state is the default state and which states are children of other states we use attributes. These attributes can be defined on class level of each state.
-
[DefaultState]
- Enables this state on start.
-
[ParentState(typeof(OtherState))]
- This makes the state a child of the given state.
For example, we use the [DefaultState]
attribute in MainMenuState.cs and the [ParentState]
attribute in HostState.cs.
When spawning a new player, a prefab named PlayerSpawnRequester
will be spawned on the client which will send a request to the server and hold the input device data which we will need later. When the server receives the request, it will spawn the actual player character model (only if the player limit is not reached yet) and send a request back to the client. After the client gets the request, the client will copy the input device address over to the newly spawned player using the SwitchCurrentControlScheme()
method. Lastly, the PlayerSpawnRequester
will be destroyed since we have already transfered the data from the local to the networked object and it is not needed anymore.
The object parenting system looks for the script ObjectParent
on all colliding objects and their parents using GetComponentsInParent<ObjectParent>();
. If it finds a ObjectParent
script, it will parent to that object using Fishnet's SetParent();
method. When no ObjectParent
scripts are found, the object will be un parented using Fishnet's UnsetParent();
method. We use Fishnet's methods so that everything will be visible for other clients.
For input parsing we use the input system package in Unity. Using this system, we can easily add support for various input devices.
All input parsers have a reference to a PlayerInput
script which holds the key mapping and the input device details.
The PlayerInputParser.cs
and PlayerLobbyInputParser.cs
are mediators between the input system and our classes. They should have simple events to subscribe to, such as OnInteract
, OnUse
and OnMove
.
When the oven gets enabled it will call TrainEngine.Instance.SetEngineState(TrainEngineState.Active);
. The coal burn is visually updated every second. But the burn rate is applied every frame only on the server. The TrainEngine.Instance.CurrentGearIndex
is used to multiply the burn rate, so when the train goes faster the burn rate is also faster.
The TakeDamage()
method is virtual
and can be overridden by classes to implement custom damage handling.
The train damageable checks if the train is moving with the TrainEngine.Instance.OnSpeedChanged
event. The damage happens every interval. The interval is configurable in the editor.
The box damageable uses the same checks if it is inside the train as the Hammer. Except for the teleporting behaviour.
To make an interactable repairable, the class should inherit from the interface IRepairable
. This requires you to implement two methods:
-
CanBeRepaired()
- This returns a bool with whether the item can be repaired.
-
Repair()
- This repairs the object.
In these methods custom behaviour can be written for specific Interactables. The default behaviour is routing it to a damageable script.
The hammer uses the UseableGrabbable class. When used it will find all IRepairable
interfaces in colliding range and call Repair()
when CanBeRepaired()
returns true
.
The hammer uses the TrainGrabbableTeleporter
. This prevents objects from leaving the train. It checks when the parent of the object changes using the OnTransformParentChanged()
method. When the parent changes, it searches for a ObjectParent
script in one of its parents. When the script ObjectParent
is found, it will check if the layer of that object is the Train
layer. When this is all true, nothing will happen. When this is false, the object will be dropped from the player’s hands and the object will be teleported to its original position.
The basic button class is an abstract class which handles the reference and the OnClick
event of the button. It has an abstract method OnButtonPressed()
which all inheriting scripts must implement.
The text updater has a dictionary with the key as the tag to replace and the value the string to replace it with. This way we can save the last given strings and reuse them when some part of the string needs to be replaced.
The trigger area is a generic class. The given type will be the target component to search for. It uses standard OnTriggerEnter()
and OnTriggerExit()
Unity methods and check if the colliding objects have the given type. Then it will trigger events such as OnColliderChange
, OnColliderEnter
and OnColliderExit
.
In addition to the standard Unity methods, we have a fallback method for when objects are not registered in these events. Every 25 frames it gets the all colliding colliders using the abstract method GetCollidingColliders()
. Inheriting scripts must implement their own Physics.Overlap
in that method and return the colliders. This fallback method will run the same events as described above.
Hosting and Joining over LAN uses the Fish Network Discovery package. Hosting will advertise the server on the network using AdvertiseServer()
. Joining will use SearchForServers()
and connect to the first server it finds. When the game is started, the host will stop advertising the server and joining will not be possible by disabling the joining on the PlayerInputManager.
Popups have 3 configurable values:
- Fade duration
- Hover height
- Hover duration
Fading and hovering is done by the package DOTween. For fading it uses a Canvas Group. Hovering uses the Yoyo
setting of DOTween.
The rotation is updated in LateUpdate()
and has a simple Quaternion.LookRotation()
calculation.