Skip to content

Technical Design

PiterGroot edited this page May 23, 2024 · 51 revisions

This is the technical design for the game mechanics of Derailed Deliveries.

Player Movement

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.

Player Interactions

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.

Interactable class

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.

CoalOvenInteractable class

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.

CoalPileInteractable class

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.

DeliveryBeltInteractable class

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.

ExitInteractable class

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.

ShelfInteractable class

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 InteractingTargets new parent will be the ShelfInteractable. Else the method will call a Interact() on the _heldGrabbable of the ShelfInteractable.

TrainDirectionLeverInteractable class

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.

Grabbable class

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.

UseableGrabbable class

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().

BoxGrabbable class

The BoxGrabbable is a UseableGrabbable class that only holds the implementation of CheckCollidingType(). It's target is that of the ShelfInteractable class.

CoalGrabbable class

The CoalGrabbable is a UseableGrabbable class that only holds the implementation for CheckCollidingType(). It's target is that of the CoalOvenInteractable.

HammerGrabbable class

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().

Train

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.

train_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 use UnityEngine.Time.DeltaTime since we're not using the default Update loop anymore. That's why we have to use its equivalent TimeManager.TickDelta to smooth out the train movement over the game frames.

    • Movement
      To actually move the train along the spline track (aka the rails), the train keeps track of a float value called DistanceAlongSpline. 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 using Spline.EvaluatePosition on the spline track to get an interpolated position value based on the time of the spline data (which in this case is DistanceAlongSpline)

      Same goes for the rotation of each wagon, which we can evaluate using Spline.EvaluateTangent which returns an interpolated direction of ratio t (DistanceAlongSpline).
  • 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 component SplineInstantiate 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 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.
    • The train makes use of a rail split when the DistanceAlongSpline value exceeds 1f, 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.

Level Tracker

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 a StationLabel 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's PackageLabel get's compared to the TrainStation's StationLabel. If these are equal the player's will receive the _succesfullDeliveryBonus plus the BoxDamageable's Health as points, else they will receive the _incorrectDeliveryPenalty as points.

Timer

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.

Map

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.

AudioSystem

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.

Camera shaker

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;

StateMachine

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.

Player Manager

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.

Object Parenting System

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.

Input Parsers

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.

Coal Oven

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.

Damage and Repair System

Damageables

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.

Repairables

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.

Hammer

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.

Basic Button

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.

Text Updater

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.

Trigger Area

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.

Lobby

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.

Popup System

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.