Implementing Gameplay
The primary objective of the game is to drive through a race track comprising several checkpoints within a specified time. To implement such a game, we also need to consider several other essential components:
- Minimap to mark the player and checkpoints
- Checkpoint entity and race track entity consisting of several checkpoints
- Lap timer
- Simple interface displaying statistics
- End game screen
- Game managing component
Implementing a Minimap for the Location#
The minimap should be located in the corner of the screen and display the player's position and orientation and the next checkpoint location. Let's create the Map component for this purpose. We'll use the WidgetSprite widget that allows displaying multiple image layers (to draw the checkpoints and the player's position without changing the map itself).
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
#region Math Variables
#if UNIGINE_DOUBLE
using Vec3 = Unigine.dvec3;
#else
using Vec3 = Unigine.vec3;
#endif
#endregion
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- identifier is generated automatically for a new component
public class Map : Component
{
// the component is represented by a singleton for global access
private static Map instance = null;
public class MapMarker { }
// the Marker abstraction represents a marker on the map with the information about the layer it refers to
private class Marker : MapMarker
{
private int layer = -1;
protected Map owner = null;
public Marker(Map owner, int layer)
{
this.owner = owner;
this.layer = layer;
}
public virtual void Update() { }
public void Clear()
{
owner.mapSprite.RemoveLayer(layer);
owner = null;
layer = -1;
}
public void DecLayer(int removed_layer)
{
if (layer <= removed_layer)
return;
layer--;
}
public int Layer { get { return layer; } }
}
// common point markers will be used to represent checkpoints
private class PointMarker : Marker
{
public PointMarker(Map owner, int layer, Vec3 point) : base(owner, layer)
{
ivec2 image_position = base.owner.GetImagePosition(point);
base.owner.mapSprite.SetLayerTransform(Layer, MathLib.Translate(image_position));
}
}
// this marker will represent the player and its direction on the minimap
private class CarMarker : PointMarker
{
private Car car;
private int imageSize = 0;
public CarMarker(Map owner, int layer, Car car,int image_size) : base(owner, layer, car.node.WorldPosition)
{
imageSize = image_size;
this.car = car;
}
// rotating the marker sprite depending on the rotation of the node with the Car component assigned
public override void Update()
{
Vec3 position = car.node.WorldPosition;
ivec2 image_position = owner.GetImagePosition(position);
vec3 direction = car.node.GetWorldDirection(MathLib.AXIS.Y);
direction.z = 0.0f;
float a = MathLib.AngleSigned(direction, vec3.FORWARD, vec3.UP);
int hsize = imageSize / 2;
mat4 rot = MathLib.Translate(hsize,hsize,0.0f) * MathLib.RotateZ(a) * MathLib.Translate(-hsize,-hsize,0.0f);
owner.mapSprite.SetLayerTransform(Layer, MathLib.Translate(image_position) * rot);
}
}
// setting the paths to images for background, checkpoint, and car
[ShowInEditor, ParameterFile] private string heigthMapImage = "";
[ShowInEditor, ParameterFile] private string pointMarkerImage = "";
[ShowInEditor, ParameterFile] private string carMarkerImage = "";
// get reference to the main terrain layer to find out its boundaries
[ShowInEditor] private LandscapeLayerMap layerMap = null;
// use WidgetSprite for the minimap -- this widget allows having several image layers
private WidgetSprite mapSprite = null;
// images to be used
private Image mapImage = null;
private Image pointImage = null;
private Image carImage = null;
// list of markers on the map
private List<Marker> markers = new List<Marker>();
// method to convert world coordinates to the minimap coordinates
private ivec2 GetImagePosition(Vec3 world_coord)
{
int w = MathLib.CeilToInt(world_coord.x * mapImage.Width / layerMap.Size.x);
int h = MathLib.CeilToInt(mapImage.Height - world_coord.y * mapImage.Height / layerMap.Size.y);
return new ivec2(w, h);
}
private void Init()
{
Gui gui = Gui.GetCurrent();
mapImage = new Image(heigthMapImage);
pointImage = new Image(pointMarkerImage);
carImage = new Image(carMarkerImage);
mapSprite = new WidgetSprite(gui);
mapSprite.SetImage(mapImage);
gui.AddChild(mapSprite, Gui.ALIGN_RIGHT);
int map_size = MathLib.CeilToInt(gui.Width * 0.15f);
mapSprite.Height = map_size;
mapSprite.Width = map_size;
instance = this;
}
private void Update()
{
foreach(Marker m in markers)
{
m.Update();
}
}
private void Shutdown()
{
for (int i = markers.Count - 1; i >= 0; i--)
{
RemoveMarker(markers[i]);
}
markers.Clear();
Gui gui = Gui.GetCurrent();
gui.RemoveChild(mapSprite);
mapSprite.DeleteLater();
}
private MapMarker AddPointMarker(Vec3 point)
{
int layer = mapSprite.AddLayer();
mapSprite.SetLayerImage(layer, pointImage);
PointMarker m = new PointMarker(this, layer, point);
markers.Add(m);
return m;
}
private MapMarker AddCarMarker(Car car)
{
int layer = mapSprite.AddLayer();
mapSprite.SetLayerImage(layer, carImage);
CarMarker m = new CarMarker(this, layer, car, carImage.Width);
markers.Add(m);
return m;
}
// to delete a marker, you need to remove a layer from the list and shift layer indices of the remaining markers correctly
private void RemoveMarker(MapMarker marker)
{
Marker m = marker as Marker;
if (m == null)
return;
foreach(Marker mark in markers)
{
mark.DecLayer(m.Layer);
}
m.Clear();
markers.Remove(marker as Marker);
}
// globally accessible static singleton methods
public static MapMarker CreatePointMarker(Vec3 point)
{
if (instance == null)
return null;
return instance.AddPointMarker(point);
}
public static MapMarker CreateCarMarker(Car car)
{
if (instance == null)
return null;
return instance.AddCarMarker(car);
}
public static void ClearMarker(MapMarker marker)
{
if (instance == null)
return;
instance.RemoveMarker(marker);
}
}
Let's create one more NodeDummy for the Map component and set the images for the background and markers and the LandscapeLayerMap node — the terrain layer.
Implementing the Race Track and Checkpoints#
The CheckPoint component will represent a checkpoint of the race track that the player needs to pass. To detect the moment of passing, we use Physical Trigger that triggers a callback when physical bodies enter its volume.
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
public class CheckPoint : Component
{
// reference to a physical trigger
[ShowInEditor] private PhysicalTrigger trigger = null;
// marker representing this point on the minimap
private Map.MapMarker marker = null;
// checkpoint trigger callback
private Action<Car> event_handler = null;
// callback is triggered when the car passes a checkpoint
private void OnEnter(Body body)
{
if (event_handler == null)
return;
Car car = body.Object.GetComponent<Car>();
if (car == null)
return;
event_handler(car);
}
// by default all checkpoints are disabled, we'll create a dedicated component to switch their state
[Method(Order = 0)]
private void Init()
{
SetEnabled(false);
trigger.EventEnter.Connect(OnEnter);
}
private void Shutdown()
{
event_handler = null;
marker = null;
SetEnabled(false);
}
// when a checkpoint is enabled, its marker is added to the minimap
// when disabled, the marker is deleted
public void SetEnabled(bool mode)
{
node.Enabled = mode;
if (mode)
{
if (marker == null)
marker = Map.CreatePointMarker(node.WorldPosition);
}
else
{
if (marker != null)
{
Map.ClearMarker(marker);
marker = null;
}
}
}
public void SetEnterEventHandler(Action<Car> event_handler)
{
this.event_handler = event_handler;
}
}
Find the race/props/checkpoint/CheckPoint.node asset and add it to the scene by dragging with the left mouse button in the viewport.
This is a Node Reference, click Edit to modify this instance. It has a cylinder and a decal inside to visualize the checkpoint. Our task is to create a new node via the menu Create → Logic → Physical Trigger, make it a child of the point node, and place within the checkpoint bounds by selecting the Cylinder type and setting the required size.
Then select the top point node and assign the CheckPoint component to it. Make sure to set the trigger in the component parameters by dragging the child PhysicalTrigger to the corresponding field.
After that, save the Node Reference by clicking Apply, then create several instances of the CheckPoint node, and place them on the landscape, mapping out a small track consisting of points.
The next step is to create a track from the placed points. First, group them by selecting all checkpoints and pressing Ctrl + G, and rename the added parent NodeDummy from group to Track. Now let's create a new Track component that will receive the list of child nodes with the CheckPoint component, shuffle their order and provide an interface for various actions with the track:
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
#region Math Variables
#if UNIGINE_DOUBLE
using Vec3 = Unigine.dvec3;
#else
using Vec3 = Unigine.vec3;
#endif
#endregion
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- identifier is generated automatically for a new component
public class Track : Component
{
// list of the track checkpoints
private CheckPoint[] points = null;
private int[] current_track = null;
// index of the current point
private int current_point = -1;
// callbacks for completing the track and passing a checkpoint
private Action track_end_handler = null;
private Action check_point_handler = null;
// method called from the checkpoint component called on the player's passing
private void OnCarEnter(Car car)
{
points[current_track[current_point]].SetEnabled(false);
current_point++;
if (current_point < NumPoints)
{
points[current_track[current_point]].SetEnabled(true);
check_point_handler?.Invoke();
}
else
{
current_point = -1;
track_end_handler?.Invoke();
}
}
// list of checkpoints is built from children with the corresponding component assigned
[Method(Order = 1)]
private void Init()
{
points = GetComponentsInChildren<CheckPoint>(node);
current_track = new int[points.Length];
for (int i = 0; i < points.Length; i++)
{
points[i].SetEnterEventHandler(OnCarEnter);
current_track[i] = i;
}
}
private void Shutdown()
{
points = null;
current_track = null;
current_point = -1;
track_end_handler = null;
check_point_handler = null;
}
// each new route is created by shuffling the checkpoint list
public void Shuffle()
{
if (current_point >= 0 && current_point < NumPoints)
points[current_track[current_point]].SetEnabled(false);
for (int i = current_track.Length - 1; i >= 1; i--)
{
int j = MathLib.ToInt(MathLib.RandInt(0, i + 1));
int tmp = current_track[j];
current_track[j] = current_track[i];
current_track[i] = tmp;
}
current_point = 0;
points[current_track[current_point]].SetEnabled(true);
}
public void Restart()
{
if (current_point >= 0 && current_point < NumPoints)
points[current_track[current_point]].SetEnabled(false);
current_point = 0;
points[current_track[current_point]].SetEnabled(true);
}
public void SetTrackEndEventHandler(Action handler)
{
track_end_handler = handler;
}
public void SetCheckPointEventHandler(Action handler)
{
check_point_handler = handler;
}
public int NumPoints { get { return points.Length; } }
public Vec3 GetPoint(int num)
{
return points[num].node.WorldPosition;
}
// the track length obtained by adding the distances between the checkpoints and the starting position
public double GetLength(Vec3 start_position)
{
double result = MathLib.Length(GetPoint(0) - start_position);
for(int i = 1; i < NumPoints; i++)
{
result += MathLib.Length(GetPoint(current_track[i - 1]) - GetPoint(current_track[i]));
}
return result;
}
}
Assign the Track component to the checkpoints group (the Track node).
Implementing the Timer#
Let's also add the Timer component that implements a simple timer each frame adding the time spent on rendering the previous frame to the current time value. Thus, we don't need a system timer.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using Unigine;
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
public class Timer : Component
{
private static StringBuilder builder = new StringBuilder();
private Action time_left_handler = null;
private double time = 0.0;
private double timer = 0.0;
private bool is_running = false;
private void Update()
{
if (!is_running)
return;
timer += Game.IFps;
if (timer > time)
{
is_running = false;
time_left_handler?.Invoke();
}
}
public void Start(double time)
{
is_running = true;
this.time = time;
timer = 0.0f;
}
public void Stop()
{
is_running = false;
}
public void Resume()
{
is_running = true;
}
public void SetTimeLeftEventHandler(Action handler)
{
time_left_handler = handler;
}
public double TimeLeft { get { return time - timer; } }
public double TimePassed { get { return timer; } }
public static string SecondsToString(double seconds)
{
builder.Clear();
double t = seconds;
int h = MathLib.FloorToInt((double)(t / 3600.0));
t -= h * 3600.0;
int m = MathLib.FloorToInt((double)(t / 60.0));
t -= m * 60.0;
int s = MathLib.FloorToInt((double)t);
if (h > 0)
{
builder.Append(h.ToString());
builder.Append("h:");
if(m < 10) builder.Append('0');
}
if (m > 0)
{
builder.Append(m.ToString());
builder.Append("m:");
if(s < 10) builder.Append("0");
}
builder.Append(s.ToString());
builder.Append('s');
return builder.ToString();
}
}
We'll also need a dedicated NodeDummy for the Timer component — let's create it and assign this component to it.
Implementing the Interface#
A simple interface displaying the current speed, timer, and the best race time would be a nice thing to have. Let's create a separate HUD component for that and add the methods to update the values from outside. Assign the HUD component to a separate NodeDummy node.
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
public class HUD : Component
{
[ShowInEditor] private int font_size = 50;
private WidgetLabel speed_label = null;
private WidgetLabel time_label = null;
private WidgetLabel best_time_label = null;
private WidgetVBox vbox = null;
private void Init()
{
Gui gui = Gui.GetCurrent();
vbox = new WidgetVBox(gui);
WidgetLabel label = null;
WidgetGridBox grid = new WidgetGridBox(gui, 2);
label = new WidgetLabel(gui, "Best Time:");
label.FontSize = font_size;
best_time_label = new WidgetLabel(gui, "");
best_time_label.FontSize = font_size;
grid.AddChild(label);
grid.AddChild(best_time_label);
label = new WidgetLabel(gui, "Time:");
label.FontSize = font_size;
time_label = new WidgetLabel(gui, "");
time_label.FontSize = font_size;
grid.AddChild(label);
grid.AddChild(time_label);
label = new WidgetLabel(gui, "Speed:");
label.FontSize = font_size;
speed_label = new WidgetLabel(gui, "");
speed_label.FontSize = font_size;
grid.AddChild(label);
grid.AddChild(speed_label);
vbox.AddChild(grid);
gui.AddChild(vbox, Gui.ALIGN_BOTTOM | Gui.ALIGN_RIGHT | Gui.ALIGN_OVERLAP);
}
private void Shutdown()
{
Gui gui = Gui.GetCurrent();
gui.RemoveChild(vbox);
vbox.DeleteLater();
speed_label = null;
time_label = null;
best_time_label = null;
vbox = null;
}
public void SetSpeed(float speed)
{
speed_label.Text = String.Format("{0} km/h",MathLib.RoundToInt(speed));
}
public void SetTime(double time)
{
time_label.Text = Timer.SecondsToString(time);
}
public void SetBestTime(double time)
{
best_time_label.Text = Timer.SecondsToString(time);
}
public void Show()
{
vbox.Hidden = false;
}
public void Hide()
{
vbox.Hidden = true;
}
}
Implementing the End Game Screen#
One more component (UI) will be responsible for the window with the choice of actions after the player finished driving the track or ran out of time. Its only function is to display a widget with the race result text and two buttons: replay the current track and generate a new one.
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
public class UI : Component
{
public enum Status
{
Win,
Lose,
}
WidgetVBox vbox = null;
WidgetLabel status_label = null;
private Action restart_click_handler = null;
private Action new_click_handler = null;
private void OnRestartClicked() { restart_click_handler?.Invoke(); }
private void OnNewClicked() { new_click_handler?.Invoke(); }
private void Init()
{
Gui gui = Gui.GetCurrent();
vbox = new WidgetVBox(gui);
int vbox_size = 100;
vbox.Width = vbox_size;
vbox.Height = vbox_size;
vbox.Background = 1;
status_label = new WidgetLabel(gui, "Status");
status_label.TextAlign = Gui.ALIGN_CENTER;
WidgetSpacer spacer = new WidgetSpacer(gui);
WidgetButton button_restart = new WidgetButton(gui, "Restart");
button_restart.Toggleable = false;
WidgetButton button_new = new WidgetButton(gui, "New Track");
button_new.Toggleable = false;
button_restart.EventClicked.Connect(OnRestartClicked);
button_new.EventClicked.Connect(OnNewClicked);
vbox.AddChild(status_label);
vbox.AddChild(spacer);
vbox.AddChild(button_restart);
vbox.AddChild(button_new);
vbox.Arrange();
gui.AddChild(vbox, Gui.ALIGN_OVERLAP);
vbox.SetPosition(gui.Width / 2 - vbox_size / 2, gui.Height / 5);
Hide();
}
private void Shutdown()
{
Gui gui = Gui.GetCurrent();
gui.RemoveChild(vbox);
vbox.DeleteLater();
vbox = null;
status_label = null;
restart_click_handler = null;
new_click_handler = null;
}
public void Show(Status status)
{
status_label.Text = status == Status.Win ? "You Win" : "You Lose";
vbox.Hidden = false;
ControlsApp.MouseHandle = Input.MOUSE_HANDLE.USER;
}
public void Hide()
{
vbox.Hidden = true;
ControlsApp.MouseHandle = Input.MOUSE_HANDLE.GRAB;
}
public void SetRestartClickEventHandler(Action handler)
{
restart_click_handler = handler;
}
public void SetNewClickEventHandler(Action handler)
{
new_click_handler = handler;
}
}
This component should also be assigned to a dedicated NodeDummy.
Implementing the Gameplay#
We finally got to the main GameManager component, which ties together all the game logic and sets the game rules. It controls the timer, the track, the interface, and the end game screen.
Let's add one more standard speed parameter to calculate the standard time for the generated tracks, this time will be considered the best until the result is updated by the player.
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;
#region Math Variables
#if UNIGINE_DOUBLE
using Vec3 = Unigine.dvec3;
using Mat4 = Unigine.dmat4;
#else
using Vec3 = Unigine.vec3;
using Mat4 = Unigine.mat4;
#endif
#endregion
[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- identifier is generated automatically for a new component
public class GameManager : Component
{
// links to the game logic components
[ShowInEditor] private CarPlayer player = null;
[ShowInEditor] private Timer timer = null;
[ShowInEditor] private Track track = null;
[ShowInEditor] private UI ui = null;
[ShowInEditor] private HUD hud = null;
// speed in km/h to calculate the standard track completion time
[ShowInEditor] private float default_speed = 1.0f;
// variable to store the best result
private double best_time = 0.0;
// position to reset the car location and track starting point
private Mat4 reset_transform = Mat4.IDENTITY;
private Mat4 start_track_transform = Mat4.IDENTITY;
// end game screen when time is over
private void OnTimeLeft()
{
timer.Stop();
ui.Show(UI.Status.Lose);
InputController.IsEnabled = false;
}
// end game screen on completing the track
private void OnTrackEnd()
{
timer.Stop();
best_time = timer.TimePassed;
ui.Show(UI.Status.Win);
InputController.IsEnabled = false;
}
// update the car reset position in every checkpoint
private void OnCheckPoint()
{
reset_transform = player.node.WorldTransform;
}
[Method(Order = 2)]
private void Init()
{
reset_transform = player.node.WorldTransform;
Map.CreateCarMarker(player);
ui.SetNewClickEventHandler(StartTrack);
ui.SetRestartClickEventHandler(RestartTrack);
timer.SetTimeLeftEventHandler(OnTimeLeft);
best_time = 0.0;
track.SetTrackEndEventHandler(OnTrackEnd);
track.SetCheckPointEventHandler(OnCheckPoint);
StartTrack();
}
private void Update()
{
if (InputController.GetAction(InputController.InputActionType.Reset) > 0.0f)
player.Reset(reset_transform);
// update the speed and time display
hud.SetSpeed(player.Speed);
hud.SetTime(timer.TimePassed);
}
private void Start()
{
reset_transform = start_track_transform;
timer.Start(best_time);
hud.SetBestTime(best_time);
ui.Hide();
InputController.IsEnabled = true;
}
// when starting the track, calculate the standard track finish time
private void StartTrack()
{
start_track_transform = player.node.WorldTransform;
track.Shuffle();
double length = track.GetLength(player.node.WorldPosition);
double default_time = length / default_speed * 3.6f;
best_time = default_time;
Start();
}
private void RestartTrack()
{
player.Reset(start_track_transform);
track.Restart();
Start();
}
}
Now assign the component to a dedicated node and set the links to the corresponding components by dragging the nodes to which they are assigned into the corresponding fields in the Parameters window.
Set moderate standard speed for the timer, taking into account that one needs to get used to driving on mountainous terrain.
To simplify customization of rendering settings for beginners, the template_simplified.node preset with optimal settings has been added to the race folder. To apply it, double-click on the asset, or go to the Settings window, select any subsection of the Render section and select the desired preset in the Render Preset parameter.
Done, now you can build the project and enjoy the game!