Systems
The systems in the Quantum Golf Sample are split into two categories:
- systems driving gameplay; and,
- systems dedicated to managing player turns.
Gameplay
Gameplay systems handle all game logic specific to the Quantum Golf Sample.
Maximum Amount of Players
The maximum amount of players is 2 and fixed in the DSL using the following snippet: #pragma max_players 2
. The value is also defined as a constant #define MAX_PLAYERS 2
in order to reused it for array initialization.
SetupSystem
The SetupSystems
handles the initial game setup - this covers:
- receive the
RuntimePlayer
via theISignalOnPlayerDataSet
signal; - initialize the players based on their respective
RuntimePlayer
- resetting player’s
TurnData
instances; and, - triggering the ball spawning via the
SpawnSystem
(see related system).
SpawnSystem
The SpawnSystem
's sole responsibility is to spawn the golf ball upon request from the SetupSystem
. To achieve this it first looks up the GameConfig
asset and uses a filter to find all active SpawnPoint
. The SpawnSystem
will spawn the ball according to the SpawnPointType
provided in the GameConfig
, after which the SpawnPoint
will be disabled.
SpawnPoint
A SpawnPoint
is a component used on scene prototype to define spawn point locations.
It holds data on its SpawnPointType
and whether an entity has been spawn on it.
NearHole
: definesSpawnPoint
s to be used when theNearHole
option is selected for spawning balls in theGameConfig
asset;Regular
: for spawning balls on regular games.
C#
asset ConfigAssets;
asset GameConfig;
asset UserMap;
enum SpawnPointType { Regular, NearHole }
component SpawnPoint
{
SpawnPointType Type;
Boolean IsAvailable;
entity_ref Entity;
}
synced event GameplayEnded { }
PlaySystem
The PlaySystem
handles the ball's physics and determine the turn state based on its velocity. The information on a player's strike is received via the ISignalOnSkipCommandReceived
signal.
Ball
The ball model uses two components, Actor
and BallFields
; both of which are defined in the Ball.qtn
file.
Actor
The Actor
component holds a player_ref
to the player controlling / owning the entity it is placed on. The Active
boolean is used to check if a player is currently active in this game and it is used when loading a game from a saved file.
C#
component Actor
{
Boolean Active;
player_ref Player;
}
BallFields
The BallFields
component is used to hold the state of a player's ball.
C#
component BallFields
{
asset_ref<BallSpec> Spec;
TurnData TurnStats;
FPVector3 LastPosition;
Boolean HasCompletedCourse;
Int32 EndOfMovementTimer;
Int32 Score;
}
TurnStats
: An instance ofTurnData
to accumulate stats from the globalCurrentTurn
when it is that particular player's turn. This particular field is maintained by theTurnSystem
.LastPosition
: last position before being shot. It is used to reset the ball’s position when it lands on a Rough Field.HasCompletedCourse
: keeps track of whether the player has completed the current course. It is checked and updated by thePlaySystem
.EnOfMovementTimer
: A timer that is increased by thePlaySystem
until the ball has come to a stop.Score
: Updated by theScoreSystem
when a player’s turn has ended.Score
: Updated by theScoreSystem
when a player’s turn has ended.
OnBallShot
The ISignalOnBallShot
signal triggered by the PlaySystem
when a ball is shot.
Input
In addition to the PlayCommand
sent by a player when striking a ball, the regular input struct is used to share the current aiming direction and force bar mark position with other players. This allows to replicate the active player’s aiming on the waiting player's side.
input
{
FPVector3 Direction;
FP ForceBarMarkPos;
}
These values are meant exclusively for visual feedback purposes and are not used for any gameplay related logic despite holding the same information as PlayCommand
s.
Forces
The forces applied to the ball are defined by the player strike sent as input via the PlayCommand
. The PlaySystem
implements the ISignalOnPlayCommandReceived
which allows it to receive PlayCommand
. The systems processes the command to ensure it is valid; this takes place in 3 steps:
- clamping the strike data (direction and force) according to the values defined on the
GameConfig
asset; - striking the ball by applying a force to its
PhysicsBody3D
; and finally, - triggering the
ISignalOnBallShot
signal.
Collisions
In addition to applying the forces on the ball, the PlaySystem
also evaluates the collisions triggered by the course's hole and the outer Rough Field.
If a ball stops on a Rough Field static collider, the outer dark green field, its position will be reset to position it had before the strike.
When a ball triggers a static collider on the Hole
layer AND its velocity is below the HitHoleVelocityThreshold
defined in the GameConfig
asset, the OnHitHole()
method is called and the course is considered completed.
PlayerTurn
If the global variable CurrentTurn.Status
is set to TurnStatus.Resolving
, it means the ball is still moving. During that time, the PlaySystem
will check if the ball has stopped moving at each Update()
. The resting threshold is determined by the value set in the BallSpec
asset.
If the ball's velocity is below the threshold value, it is considering at rest. At this point, the EndOfMovementTimer
field in the BallFields
component will start increasing. Once it has reached the EndOfMovementWaitingInTicks
value, the EndOfPlay()
method will be called and the ISignalOnTurnEnded
signal will be triggered thus ending the current player's turn.
When a play ends, the ball's PhysicsBody3D
component is disabled to prevent unwanted interactions while that player's TurnStatus
is Inactive
.
TurnSystem
The TurnSystem
handles the turn related logic used by the turn based features.
Each player has their own TurnData
instance to aggregate the of their turn from the global CurrentTurn
data instance. The active player’s TurnStatus
is also kept updated by the system.
When the ball is struck in the PlaySystem
, the TurnSystem
set the global CurrentTurn.Status
to TurnStatus.Resolving
and calls SetStatus()
on the active player's BallFields
component TurnStats
field.
If a valid SkipCommand
is received from the active player, the TurnSystem
simply triggers a the ISignalOnTurnEnded
signal.
When a ISignalOnTurnEnded
signal is received, the TurnSystem
checks the TurnType
.
- If it is of type
Countdown
and there is a eligible ball for the next turn, it activates next ball's turn. - If it is of type
Play
, it callsAccumulateStats()
andSetStatus()
on the current player's ball to render it inactive. Afterwards it callsReset()
on the globalCurrentTurn
.
TurnData
The Turn-Based SDK is an addon for the default Quantum SDK. It aims to help you store and operate logic over your turn-related data.
Most of this happens on your Turn Data struct instances, which have configurable parameters through a data asset and can be used on each of your players/entities and/or globally to manage your game flow.
Variables
The TurnData
struct holds references to a Player
and an Entity
for convenience; e.g. to easily access the player or entity the data refers. It also keeps a reference to a TurnConfig
asset that carries the information about a turn's configurable parameters.
Every turn has a current TurnType
that suggests but does not mandate what kind of interactions are allowed/forbidden during the current turn. The Quantum Golf Sample includes two pre-defined types:
Play
: interactions are allowed.Countdown
: interactions are forbidden and the gameplay is on hold.
Every turn also has a keeps track of its TurnStatus
; it is used to define which logic path will be used to manipulate data while that status is active. The Quantum Golf Sample uses three pre-defined status:
Active
: the turn timer can be increased and player commands are accepted.Inactive
: the timer is paused and player commands are not accepted.Resolving
: signalizes a temporary status after aPlayCommand
has been received by the current player and game-specific logic is being executed - e.g. simulating ball physics.
The Number
keeps track of the number of turns played by a player and is increased by 1 when calling AccumulateStats()
. This way the active player's TurnData
can be updated when the global CurrentTurn
ends. In contrast, the Ticks
variables is increased every frame while the global CurrentTurn
is marked as Active
. This allows to know exactly how many ticks each player was active during all turns they has played thus far.
C#
enum TurnType { Play, Countdown }
enum TurnStatus { Inactive, Active, Resolving }
struct TurnData
{
player_ref Player;
entity_ref Entity;
asset_ref<TurnConfig> ConfigRef;
TurnType Type;
TurnStatus Status;
Int32 Number;
Int32 Ticks;
}
The CurrentTurn
is a global TurnData
instance which is reset at the beginning of every new turn to track of the current player's turn. When a turn ends, the data is aggregated / accumulated to the existing player's turn data instance.
A turn may end for many different reasons; the Quantum Golf Sample enumerates some of them via the TurnEndReasons
enum. These can be changed and added to signalizing a turn has ended and trigger different game-specific logic routines.
C#
enum TurnEndReason { Time, Skip, Play, Resolved }
global
{
TurnData CurrentTurn;
}
Methods
If the current turn uses a timer when a TurnData
instance is updated and the status is Active
, the Ticks
are incremented by 1.
If the Ticks
value reach the maximum allowed amount for this turn's as defined in the TurnConfig
asset, the ISignalOnTurnEnded
signal is triggered with the TurnEndReason.Time
enum passed in as the turn end reason.
C#
public void Update(Frame f)
{
var config = f.FindAsset<TurnConfig>(ConfigRef.Id);
if (config == null || !config.UsesTimer || Status != TurnStatus.Active)
{
return;
}
Ticks++;
if (Ticks >= config.TurnDurationInTicks)
{
f.Signals.OnTurnEnded(this, TurnEndReason.Time);
}
}
A TurnData
instance can also AccumulateStats()
from another instance; the implementation in the Quantum Golf Sample translates this to increasing the Number
of turns by 1 and accumulating the Ticks
from the instances. Usually, a player's TurnData
instance accumulates its stats when the global CurrentTurn
ends.
C#
public void AccumulateStats(TurnData from)
{
Ticks += from.Ticks;
Number++;
}
A TurnData
instance offers two different setter methods:
SetType()
SetStatus()
Although they do not required a Frame
to be provided, doing so allows to trigger events if so desired.
C#
public void SetType(TurnType newType, Frame f = null){
if (Type == newType){
return;
}
var previousType = Type;
Type = newType;
f?.Events.TurnTypeChanged(this, previousType);
}
public void SetStatus(TurnStatus newStatus, Frame f = null){
if (Status == newStatus){
return;
}
var previousStatus = Status;
Status = newStatus;
f?.Events.TurnStatusChanged(this, previousStatus);
if (Status == TurnStatus.Active){
f?.Events.TurnActivated(this);
}
}
A player's TurnData
instance can be Reset()
at the beginning of the game; all overloads will reset the Ticks
value while also offering the ability to set other fields depending on which overload is chosen.
It can also be used to reset global current turn when a turn ends to prepare it to track another player's turn.
If a Frame is provided (not mandatory), a Turn Timer Reset event is raised on that frame.
C#
public void ResetTicks(Frame f = null) {
ResetData(Type, Status, Entity, Player, ConfigRef, f);
}
public void Reset(TurnConfig config, TurnType type, TurnStatus status, Frame f = null){
ResetData(type, status, Entity, Player, config, f);
}
public void Reset(EntityRef entity, PlayerRef owner, Frame f = null){
ResetData(Type, Status, entity, owner, ConfigRef, f);
}
public void Reset(TurnConfig config, TurnType type, TurnStatus status, EntityRef entity, PlayerRef owner, Frame f = null){
ResetData(type, status, entity, owner, config, f);
}
ScoreSystem
The ScoreSystem
handles end game scoring. If the game ends as part of a play (TurnType.Play
) - i.e. the a player managed to put their ball inside the hole -, the score for the winning player is tabulated as follows: the maximum number of allowed strokes + 1 (defined in the MaxStrokes
field of the GameConfig
) minus the amount of strokes it took the player to hit the ball into the hole.
If the course has not been completed yet, the score is 0.
GameEnd
The GameEnd
systems checks at the end of every turn whether the game's end conditions have been met. The Quantum Golf Sample implements 2 end conditions:
- all players have taken the maximum amount of allowed strikes.
- the course has been completed (i.e. a ball was put in the hole)
Once either of these conditions is reached, the global CurrentTurn
data is reset and a the GameplayEnded
event is raised. The GameplayEnded
event is used to communicate with Unity and display a visual feedback.
Turn Based Systems
The turn based systems are game agnostic and can be used for any sort of turn based gameplay.
CommandSystem
The CommandSystem
is responsible for controlling the type of command sent by players and trigger the corresponding signals / logic if these are valid. The two commands included in the Quantum Golf Sample are PlayCommand
and SkipCommand
.
Only commands sent by the currently active player are accepted; the active player is referenced in the global CurrentTurn
data field.
When a PlayCommand
is received, the global CurrentTurn
status is set to Resolving
to stop the timer during that time while game-specific logic might need to be executed.
C#
public override void Update(Frame f)
{
var currentTurn = f.Global->CurrentTurn;
if (currentTurn.Status != TurnStatus.Active)
{
return;
}
var currentPlayer = f.Global->CurrentTurn.Player;
switch (f.GetPlayerCommand(currentPlayer))
{
case PlayCommand playCommand:
if (currentTurn.Type != TurnType.Play)
{
return;
}
f.Signals.OnPlayCommandReceived(currentPlayer, playCommand.Data);
f.Events.PlayCommandReceived(currentPlayer, playCommand.Data);
break;
case SkipCommand skipCommand:
var config = f.FindAsset<TurnConfig>(currentTurn.ConfigRef.Id);
if (!config.IsSkippable)
{
return;
}
f.Signals.OnSkipCommandReceived(currentPlayer, skipCommand.Data);
f.Events.SkipCommandReceived(currentPlayer, skipCommand.Data);
break;
}
}
}
TurnTimerSystem
The TurnTimerSystem
is responsible for calling Update()
on the global CurrentTurn
every tick.
C#
public unsafe class TurnTimerSystem : SystemMainThread
{
public override void Update(Frame f)
{
f.Global->CurrentTurn.Update(f);
}
}
Signals
Quantum Signals are meant for inter-system communication.
A Turn Ended signal can be triggered to signalize that a turn has ended by a given turn end reason and should be used to trigger game-specific turn-control logic, like passing the turn to the next player on the Quantum Golf Sample.
A Play/Skip Command Received signal is raised when a respective valid command is received (more about player commands validity on Systems) and can be used to trigger the desired game-specific logic, like striking a ball or ending a turn on Quantum Golf Sample.
C#
signal OnTurnEnded (TurnData data, TurnEndReason reason);
signal OnPlayCommandReceived (PlayerRef player, PlayCommandData data);
signal OnSkipCommandReceived (PlayerRef player, SkipCommandData data);
Events
Quantum Events are meant to communicate things that happen inside the simulation to the rendering engine, so it can respond accordingly with a desired audio-visual feedback.
More on regular/synced/abstract Quantum Events here.
- When a turn type/status changes via a Turn Data Set method (more on Methods), an event is raised carrying that Turn Data instance value and the previous Type/Status.
- When a turn timer is reset by calling its Reset method passing a non-null frame, an event is raised carrying that Turn Data instance value.
- When a turn is activated by changing its Status to Active in a Set Status method, or a turn ends for a given reason, an event is raised to signalize so, passing the reason in the second case.
- When a valid Play/Skip command is received by the respective command System, an event is raised to signalize so.
C#
abstract event TurnEvent { TurnData Turn; }
synced event TurnTypeChanged : TurnEvent { TurnType PreviousType; }
synced event TurnStatusChanged : TurnEvent { TurnStatus PreviousStatus; }
synced event TurnEnded : TurnEvent { TurnEndReason Reason; }
synced event TurnTimerReset : TurnEvent { }
synced event TurnActivated : TurnEvent { }
abstract event CommandEvent { player_ref Player; }
event PlayCommandReceived : CommandEvent { PlayCommandData Data; }
event SkipCommandReceived : CommandEvent { SkipCommandData Data; }