This page has been translated automatically.
Video Tutorials
Interface
Essentials
Advanced
How To
Professional (SIM)
UnigineEditor
Interface Overview
Assets Workflow
Settings and Preferences
Working With Projects
Adjusting Node Parameters
Setting Up Materials
Setting Up Properties
Lighting
Landscape Tool
Sandworm
Using Editor Tools for Specific Tasks
Extending Editor Functionality
Built-in Node Types
Nodes
Objects
Effects
Decals
Light Sources
Geodetics
World Nodes
Sound Objects
Pathfinding Objects
Players
Programming
Fundamentals
Setting Up Development Environment
C++
C#
UnigineScript
UUSL (Unified UNIGINE Shader Language)
Plugins
File Formats
Materials and Shaders
Rebuilding the Engine Tools
GUI
Double Precision Coordinates
API
Containers
Common Functionality
Controls-Related Classes
Engine-Related Classes
Filesystem Functionality
GUI-Related Classes
Math Functionality
Node-Related Classes
Objects-Related Classes
Networking Functionality
Pathfinding-Related Classes
Physics-Related Classes
Plugins-Related Classes
IG Plugin
CIGIConnector Plugin
Rendering-Related Classes
Content Creation
Content Optimization
Materials
Material Nodes Library
Miscellaneous
Input
Math
Matrix
Textures
Art Samples
Tutorials

Making a First-Person Shooter (C#)

This tutorial shows how to create a First-Person Shooter.

Using the C# component system, we will create a controllable player who can fire a gun with a crosshair in the center of the screen, and enemies pursuing the player and trying to shoot him.

Topics covered in this article include:

Creating a New Project and Downloading Assets#

We are going to use the previously made assets which are available for downloading via the SDK Browser.

  1. Create a new empty C# project. Open the SDK Browser, go to the My Projects tab and click the Create New button.
  2. In the window that opens, make sure to select C# (.NET 5) in the API + IDE list and click Create New Project.

  3. After the new project is created, it will appear in the My Projects tab. Click Open Editor under the created project to open it in the UnigineEditor.

  4. Install the Docs Sample Content add-on via the Add-Ons tab of the SDK Browser to get the required assets. Click Open Folder under the Docs Sample Content add-on and copy the data/fps folder to the data folder of your project via the File Explorer.

Creating a Player#

To make a main character, we will need a controller node implementing basic player functionality (handling keyboard and mouse input, movement settings, etc.). Attached to this node, we will have a first-person view camera, hands with a gun, and a body imitation to check for collisions with other characters, bullets, and environment. Later we will assign logic components to the nodes to implement shooting, visual effects, etc.

Character Controller#

For the character controller, we will use the template first-person controller. It is included in the default scene of a new C# project as the first_person_controller node reference that stores a dummy object with the FirstPersonController component assigned.

Let's rename it player: enable editing of the node reference and rename both nodes. It will represent our character.

Arranging a First-Person Setup with Hands and Pistol#

For a first-person setup you will need hands and weapon models and animations previously created in a 3D modeling software. Our ready-to-use assets are available in the data/fps folder.

Adding Hands#

We start with adding hands, and then we will attach a pistol to them.

In the Asset Browser, find the data/fps/hands/hands.fbx asset and add it to the scene.

Player Body#

To simulate a player's body that takes damage when hit by enemy bullets, you can use an additional node:

  1. In the Menu bar, choose Create -> Primitive -> Box to create a box primitive of the (1,1,2) size, add it to the scene and rename player_hit_box.

  2. Add it as a child to the hands dummy node and reset its position to the parent one.

  3. Adjust the position of the player_hit_box so that it is placed below the hands.
  4. Make it invisible by clearing its Viewport mask in the Node tab of the Parameters window. Also clear the Shadow mask to disable shadows rendering.

Later, we will assign a Health component to it.

Adding a Camera#

To be able to see through the eyes of the character in the UnigineEditor, you can create a new camera (PlayerDummy). It will make it easier to test the first-person setup.

  1. Right-click the player dummy object and choose Create -> Camera -> Dummy. Place the created camera somewhere in the world. If necessary, enable editing of the player node reference first.

  2. In the Node tab of the Parameters window, reset the position of the camera to the player position. Then adjust the rotation so that the camera is directed forward: set the rotation around the X axis to 90.

  3. Add the hands dummy node as a child to the PlayerDummy node.

  4. Adjust the position of the hands so that you can see them through the PlayerDummy camera. Transformation of the player's body should also be changed.
  5. In the Parameters window, change Near Clipping and FOV Degrees values of the PlayerDummy node: it will help you to get the required camera view.
  6. Check the Main Player box in the parameters of the PlayerDummy to make it the main camera.

  7. To avoid hands falling under gravity, adjust the position of the player dummy object's ShapeCapsule in the Physics tab of the Parameters window. It should almost coincide with the position of the player_hit_box node.
  8. Select the player dummy object and go to the Node tab of the Parameters window.
  9. In the Node Components and Properties section, choose Camera -> Camera mode -> USE EXTERNAL.
  10. Drag and drop the PlayerDummy node to the Camera field.

Now you can switch to the PlayerDummy camera in the Editor Viewport.

Attaching a Weapon to the Hands#

Our FBX model of hands contains several bones. We can attach the pistol to a particular bone of the hands to make it follow the transformations of this bone. For this purpose, you should create a WorldTransformBone node.

  1. In the Asset Browser, find the data/fps/pistol/pistol.fbx asset and add it to the scene.
  2. In the Menu bar, choose Create -> Mesh -> SkinnedBone: a WorldTransformBone node will be created. Add it as a child to the hands skinned mesh (the one that is inherited from the hands dummy node).

  3. In the Bone drop-down list, select joint_hold. This will be the bone to which the pistol will be attached.

  4. Make the pistol a child of the WorldTransformBone node. Reset its relative position and rotation to zero if needed.

Testing Animations#

There are also a number of animations to be used for the hands (idle, walking, shooting). You can check how a certain animation looks like, for example:

  1. In the Asset Browser, find the fps/hands/hands_animations/handspistolidle.fbx/handspistolidle.anim file and drag it to the Preview Animation section of the hands skinned mesh parameters.
  2. Check the Loop option and click Play.

Playing Animations via Code#

When the character changes its states (shooting, walking forward/backward/left/right), the corresponding animations should change smoothly. We will implement a component for mixing our animations.

Blending Animations#

To ensure a seamless transition, we need to play two animations simultaneously and blend them. To do so, we will use multiple layers; then we will be able to assign different weights to these layers and achieve smooth blending.

The following scheme shows the blend tree we are going to use:

  1. Create a new C# component HandAnimationController.cs: in the Asset Browser, right-click and choose Create C# Component in the drop-down list. Copy and paste the following code to the created component:

    HandAnimationController.cs

    Source code (C#)
    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 HandAnimationController : Component
    {
    	// first person controller
    	public FirstPersonController fpsController = null;
    
    	public float moveAnimationSpeed = 30.0f;
    	public float shootAnimationSpeed = 30.0f;
    	public float idleWalkMixDamping = 5.0f;
    	public float walkDamping = 5.0f;
    	public float shootDamping = 1.0f;
    
    	// animation parameters
    	[ParameterFile(Filter = ".anim")]
    	public string idleAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveForwardAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveBackwardAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveRightAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveLeftAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string shootAnimation = null;
    
    	public vec2 LocalMovementVector
    	{
    		get
    		{
    			return new vec2(
    				MathLib.Dot(fpsController.SlopeAxisY, fpsController.HorizontalVelocity),
    				MathLib.Dot(fpsController.SlopeAxisX, fpsController.HorizontalVelocity)
    			);
    		}
    		set {}
    	}
    
    	private ObjectMeshSkinned meshSkinned = null;
    	private float currentIdleWalkMix = 0.0f; // 0 means idle animation, 1 means walk animation
    	private float currentShootMix = 0.0f; // 0 means idle/walk mix, 1 means shoot animation
    	private float currentWalkForward = 0.0f;
    	private float currentWalkBackward = 0.0f;
    	private float currentWalkRight = 0.0f;
    	private float currentWalkLeft = 0.0f;
    
    	private float currentWalkIdleMixFrame = 0.0f;
    	private float currentShootFrame = 0.0f;
    	private int numShootAnimationFrames = 0;
    
    	// animation layers number
    	private const int numLayers = 6;
    
    	private void Init()
    	{
    		// grab the node with the current component assigned
    		// and cast it to the ObjectMeshSkinned type
    		meshSkinned = node as ObjectMeshSkinned;
    
    		// set the number of animation layers for the node
    		meshSkinned.NumLayers = numLayers;
    
    		// set animation for each animation layer
    		meshSkinned.SetAnimation(0, idleAnimation);
    		meshSkinned.SetAnimation(1, moveForwardAnimation);
    		meshSkinned.SetAnimation(2, moveBackwardAnimation);
    		meshSkinned.SetAnimation(3, moveRightAnimation);
    		meshSkinned.SetAnimation(4, moveLeftAnimation);
    		meshSkinned.SetAnimation(5, shootAnimation);
    
    		int animation = meshSkinned.GetAnimation(5);
    		numShootAnimationFrames = meshSkinned.GetNumAnimationFrames(animation);
    
    		// enable all animation layers
    		for (int i = 0; i < numLayers; ++i)
    			meshSkinned.SetLayerEnabled(i, true);
    	}
    
    	public void Shoot()
    	{
    		// enable the shooting animation
    		currentShootMix = 1.0f;
    		// set the animation layer frame to 0
    		currentShootFrame = 0.0f;
    	}
    
    	private void Update()
    	{
    		vec2 movementVector = LocalMovementVector;
    	
    		// check if the character is moving
    		bool isMoving = movementVector.Length2 > MathLib.EPSILON;
    
    		// handle input: check if the fire button is pressed
    
    		bool isShooting = Input.IsMouseButtonDown(Input.MOUSE_BUTTON.LEFT);
    		if (isShooting)
    			Shoot();
    
    		// calculate the target values for the layer weights
    		float targetIdleWalkMix = (isMoving) ? 1.0f : 0.0f;
    		float targetWalkForward = (float) MathLib.Max(0.0f, movementVector.x);
    		float targetWalkBackward = (float) MathLib.Max(0.0f, -movementVector.x);
    		float targetWalkRight = (float) MathLib.Max(0.0f, movementVector.y);
    		float targetWalkLeft = (float) MathLib.Max(0.0f, -movementVector.y);
    
    		// apply the current layer weights
    		float idleWeight = 1.0f - currentIdleWalkMix;
    		float walkMixWeight = currentIdleWalkMix;
    		float shootWalkIdleMix = 1.0f - currentShootMix;
    
    		meshSkinned.SetLayerWeight(0, shootWalkIdleMix * idleWeight);
    		meshSkinned.SetLayerWeight(1, shootWalkIdleMix * walkMixWeight * currentWalkForward);
    		meshSkinned.SetLayerWeight(2, shootWalkIdleMix * walkMixWeight * currentWalkBackward);
    		meshSkinned.SetLayerWeight(3, shootWalkIdleMix * walkMixWeight * currentWalkRight);
    		meshSkinned.SetLayerWeight(4, shootWalkIdleMix * walkMixWeight * currentWalkLeft);
    		meshSkinned.SetLayerWeight(5, currentShootMix);
    
    		// update the animation frames: set the same frame for the animation layers to keep them in sync
    		meshSkinned.SetFrame(0, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(1, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(2, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(3, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(4, currentWalkIdleMixFrame);
    		// set the shooting animation layer frame to 0 to start animation from the beginning
    		meshSkinned.SetFrame(5, currentShootFrame);
    
    		currentWalkIdleMixFrame += moveAnimationSpeed * Game.IFps;
    		currentShootFrame = MathLib.Min(currentShootFrame + shootAnimationSpeed * Game.IFps, numShootAnimationFrames);
    
    		// smoothly update the current weight values
    		currentIdleWalkMix = MathLib.Lerp(currentIdleWalkMix, targetIdleWalkMix, idleWalkMixDamping * Game.IFps);
    
    		currentWalkForward = MathLib.Lerp(currentWalkForward, targetWalkForward, walkDamping * Game.IFps);
    		currentWalkBackward = MathLib.Lerp(currentWalkBackward, targetWalkBackward, walkDamping * Game.IFps);
    		currentWalkRight = MathLib.Lerp(currentWalkRight, targetWalkRight, walkDamping * Game.IFps);
    		currentWalkLeft = MathLib.Lerp(currentWalkLeft, targetWalkLeft, walkDamping * Game.IFps);
    
    		currentShootMix = MathLib.Lerp(currentShootMix, 0.0f, shootDamping * Game.IFps);
    	}
    }
  2. In the UnigineEditor, assign this component to the hands skinned mesh.
  3. Remove the fps/hands/hands_animations/handspistolidle.fbx/handspistolidle.anim from the Preview Animation field of the Mesh Skinned section.
  4. Add animations stored in the fps/hands/hands_animations folder to the corresponding parameters.

  5. Assign (drag and drop) the player dummy node to the Fps Controller field of the HandAnimationController component so that it could get required data from the player's first person controller to perform blending.

  6. Save all changes and run the application logic via the UnigineEditor to check the result.

Implementing Player Shooting#

Shooting Controls#

Let's implement a new component for checking if the fire button is pressed. This is the preferred way as we are going to use this logic in the other components:

  • In the HandAnimationController component to start the shooting animation.
  • In the WeaponController component to start the shooting logic.

In this component, you can also define a button that acts as a fire button.

To handle user input, use one of the Input class functions to check if the given button is pressed.

  1. Create a ShootInput.cs component and copy the following code to it.

    ShootInput.cs

    Source code (C#)
    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 ShootInput : Component
    {	
    	public bool IsShooting()
    	{
    		return Input.IsMouseButtonDown(Input.MOUSE_BUTTON.LEFT);
    	}
    }
  2. Add the ShootInput.cs component to the player dummy node.

  3. Modify the HandAnimationController.cs component in order to use logic of the ShootInput.cs. Replace your current code with the following one:

    HandAnimationController.cs

    Source code (C#)
    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 HandAnimationController : Component
    {
    	// first person controller
    	public FirstPersonController fpsController = null;
    
    	public ShootInput shootInput = null;
    
    	public float moveAnimationSpeed = 30.0f;
    	public float shootAnimationSpeed = 30.0f;
    	public float idleWalkMixDamping = 5.0f;
    	public float walkDamping = 5.0f;
    	public float shootDamping = 1.0f;
    
    	// animation parameters
    	[ParameterFile(Filter = ".anim")]
    	public string idleAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveForwardAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveBackwardAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveRightAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string moveLeftAnimation = null;
    
    	[ParameterFile(Filter = ".anim")]
    	public string shootAnimation = null;
    
    	public vec2 LocalMovementVector
    	{
    		get
    		{
    			return new vec2(
    				MathLib.Dot(fpsController.SlopeAxisY, fpsController.HorizontalVelocity),
    				MathLib.Dot(fpsController.SlopeAxisX, fpsController.HorizontalVelocity)
    			);
    		}
    		set {}
    	}
    
    	private ObjectMeshSkinned meshSkinned = null;
    	private float currentIdleWalkMix = 0.0f; // 0 means idle animation, 1 means walk animation
    	private float currentShootMix = 0.0f; // 0 means idle/walk mix, 1 means shoot animation
    	private float currentWalkForward = 0.0f;
    	private float currentWalkBackward = 0.0f;
    	private float currentWalkRight = 0.0f;
    	private float currentWalkLeft = 0.0f;
    
    	private float currentWalkIdleMixFrame = 0.0f;
    	private float currentShootFrame = 0.0f;
    	private int numShootAnimationFrames = 0;
    
    	// animation layers number
    	private const int numLayers = 6;
    
    	private void Init()
    	{
    		// grab the node with the current component assigned
    		// and cast it to the ObjectMeshSkinned type
    		meshSkinned = node as ObjectMeshSkinned;
    
    		// set the number of animation layers for the node
    		meshSkinned.NumLayers = numLayers;
    
    		// set animation for each animation layer
    		meshSkinned.SetAnimation(0, idleAnimation);
    		meshSkinned.SetAnimation(1, moveForwardAnimation);
    		meshSkinned.SetAnimation(2, moveBackwardAnimation);
    		meshSkinned.SetAnimation(3, moveRightAnimation);
    		meshSkinned.SetAnimation(4, moveLeftAnimation);
    		meshSkinned.SetAnimation(5, shootAnimation);
    
    		int animation = meshSkinned.GetAnimation(5);
    		numShootAnimationFrames = meshSkinned.GetNumAnimationFrames(animation);
    
    		// enable all animation layers
    		for (int i = 0; i < numLayers; ++i)
    			meshSkinned.SetLayerEnabled(i, true);
    	}
    
    	public void Shoot()
    	{
    		// enable the shooting animation
    		currentShootMix = 1.0f;
    		// set the animation layer frame to 0
    		currentShootFrame = 0.0f;
    	}
    
    	private void Update()
    	{
    		vec2 movementVector = LocalMovementVector;
    	
    		// check if the character is moving
    		bool isMoving = movementVector.Length2 > MathLib.EPSILON;
    
    		// handle input: check if the fire button is pressed
    
    		if (shootInput.IsShooting())
    			Shoot();
    
    		// calculate the target values for the layer weights
    		float targetIdleWalkMix = (isMoving) ? 1.0f : 0.0f;
    		float targetWalkForward = (float) MathLib.Max(0.0f, movementVector.x);
    		float targetWalkBackward = (float) MathLib.Max(0.0f, -movementVector.x);
    		float targetWalkRight = (float) MathLib.Max(0.0f, movementVector.y);
    		float targetWalkLeft = (float) MathLib.Max(0.0f, -movementVector.y);
    
    		// apply the current layer weights
    		float idleWeight = 1.0f - currentIdleWalkMix;
    		float walkMixWeight = currentIdleWalkMix;
    		float shootWalkIdleMix = 1.0f - currentShootMix;
    
    		meshSkinned.SetLayerWeight(0, shootWalkIdleMix * idleWeight);
    		meshSkinned.SetLayerWeight(1, shootWalkIdleMix * walkMixWeight * currentWalkForward);
    		meshSkinned.SetLayerWeight(2, shootWalkIdleMix * walkMixWeight * currentWalkBackward);
    		meshSkinned.SetLayerWeight(3, shootWalkIdleMix * walkMixWeight * currentWalkRight);
    		meshSkinned.SetLayerWeight(4, shootWalkIdleMix * walkMixWeight * currentWalkLeft);
    		meshSkinned.SetLayerWeight(5, currentShootMix);
    
    		// update the animation frames: set the same frame for the animation layers to keep them in sync
    		meshSkinned.SetFrame(0, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(1, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(2, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(3, currentWalkIdleMixFrame);
    		meshSkinned.SetFrame(4, currentWalkIdleMixFrame);
    		// set the shooting animation layer frame to 0 to start animation from the beginning
    		meshSkinned.SetFrame(5, currentShootFrame);
    
    		currentWalkIdleMixFrame += moveAnimationSpeed * Game.IFps;
    		currentShootFrame = MathLib.Min(currentShootFrame + shootAnimationSpeed * Game.IFps, numShootAnimationFrames);
    
    		// smoothly update the current weight values
    		currentIdleWalkMix = MathLib.Lerp(currentIdleWalkMix, targetIdleWalkMix, idleWalkMixDamping * Game.IFps);
    
    		currentWalkForward = MathLib.Lerp(currentWalkForward, targetWalkForward, walkDamping * Game.IFps);
    		currentWalkBackward = MathLib.Lerp(currentWalkBackward, targetWalkBackward, walkDamping * Game.IFps);
    		currentWalkRight = MathLib.Lerp(currentWalkRight, targetWalkRight, walkDamping * Game.IFps);
    		currentWalkLeft = MathLib.Lerp(currentWalkLeft, targetWalkLeft, walkDamping * Game.IFps);
    
    		currentShootMix = MathLib.Lerp(currentShootMix, 0.0f, shootDamping * Game.IFps);
    	}
    }
  4. Select the hands node, drag and drop the player dummy node to the Shoot Input field in the HandAnimationController section.

Using Intersections#

To implement shooting, you can use the properties of the PlayerDummy camera. This camera has its -Z axis pointing at the middle of the screen. So, you can do a ray cast from the camera to the middle of the screen, get the intersection, and check if you hit something.

In the component code below, we will store two points (p0, p1): the camera point and the point of the mouse pointer. GetIntersection() method will cast a ray from p0 to p1 and check the intersection with an object's surface that has the matching intersection mask. If we get the intersection, the method returns hitObject and hitInfo values (the intersection point and normal).

  1. Create a WeaponController.cs component and copy the following code:

    WeaponController.cs

    Source code (C#)
    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")] // <-- this line is generated automatically for a new component
    public class WeaponController : Component
    {
    	public PlayerDummy shootingCamera = null;
    	public ShootInput shootInput = null;
    	public NodeDummy weaponMuzzle = null;
    
    	public int damage = 1;
    
    	// intersection mask
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int mask = ~0;
    
    	public void Shoot()
    	{
    
    		// initialize the camera point (p0) and the point of the mouse pointer (p1)
    		Vec3 p0, p1;
    		shootingCamera.GetDirectionFromScreen(out p0, out p1);
    
    		// create an intersection normal
    		WorldIntersectionNormal hitInfo = new WorldIntersectionNormal();
    		// get the first object intersected by the (p0,p1) line
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, mask, hitInfo);
    
    		// if the intersection is found
    		if (hitObject)
    		{
    			// render the intersection normal
    			Visualizer.RenderVector(hitInfo.Point, hitInfo.Point + hitInfo.Normal, vec4.RED, 0.25f, false, 2.0f);
    
    		}
    	}
    
    	private void Update()
    	{
    		// handle input: check if the fire button is pressed
    		if (shootInput.IsShooting())
    			Shoot();
    	}
    }
  2. Add the component to the player dummy node.
  3. Assign PlayerDummy to the Shooting Camera field so that the component could get information from the camera.
  4. Assign the player dummy node to the Shoot Input field.

To view the shooting intersection points and normals, you can enable Visualizer when the application is running:

  1. Open the console by pressing ~
  2. Type show_visualizer 1.

Hit Effect and Muzzle Flash#

Visual effects for shooting can be implemented in a separate component. You can get information about the hit point and spawn a hit prefab at this point oriented along the hit normal. For the muzzle flash, you can attach a NodeDummy to the muzzle of the pistol, and spawn a muzzle flash prefab at this position.

In the component code below, the OnHit() and OnShoot() methods implement this logic.

  1. Create a VFXController.cs component and copy the code below. You can also use the existing data/fps/components/VFXController.cs component.

    VFXController.cs

    Source code (C#)
    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")] // <-- this line is generated automatically for a new component
    public class VFXController : Component
    {
    	// prefabs for hit and muzzle flash visualization
    	[ParameterFile(Filter = ".node")]
    	public string hitPrefab = null;
    
    	[ParameterFile(Filter = ".node")]
    	public string muzzleFlashPrefab = null;
    
    	public void OnShoot(Mat4 transform)
    	{
    		// if no hit prefab is specified, do nothing
    		if (string.IsNullOrEmpty(hitPrefab))
    			return;
    
    		// load the prefab for muzzle flash visualization
    		Node muzzleFlashVFX = World.LoadNode(muzzleFlashPrefab);
    		// set the muzzle flash node transformation
    		muzzleFlashVFX.WorldTransform = transform;
    	}
    
    	public void OnHit(Vec3 hitPoint, vec3 hitNormal, Unigine.Object hitObject)
    	{
    		// if no hit prefab is specified, do nothing
    		if (string.IsNullOrEmpty(hitPrefab))
    			return;
    
    		// load the prefab for hit visualization
    		Node hitVFX = World.LoadNode(hitPrefab);
    		// place the prefab in the hit point and set its direction according to the hit normal
    		hitVFX.WorldPosition = hitPoint;
    		hitVFX.SetWorldDirection(hitNormal, vec3.UP, MathLib.AXIS.Y);
    	}
    }
  2. Modify the WeaponController.cs component in order to use logic of the VFXController.cs.

    WeaponController.cs

    Source code (C#)
    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")] // <-- this line is generated automatically for a new component
    public class WeaponController : Component
    {
    	public PlayerDummy shootingCamera = null;
    	public ShootInput shootInput = null;
    	public NodeDummy weaponMuzzle = null;
    
    	public VFXController vfx = null;
    
    	public int damage = 1;
    
    	// intersection mask
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int mask = ~0;
    
    	public void Shoot()
    	{
    
    		// spawn a muzzle flash
    		if (weaponMuzzle)
    			vfx.OnShoot(weaponMuzzle.WorldTransform);
    
    		// initialize the camera point (p0) and the point of the mouse pointer (p1)
    		Vec3 p0, p1;
    		shootingCamera.GetDirectionFromScreen(out p0, out p1);
    
    		// create an intersection normal
    		WorldIntersectionNormal hitInfo = new WorldIntersectionNormal();
    		// get the first object intersected by the (p0,p1) line
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, mask, hitInfo);
    
    		// if the intersection is found
    		if (hitObject)
    		{
    			// render the intersection normal
    			Visualizer.RenderVector(hitInfo.Point, hitInfo.Point + hitInfo.Normal, vec4.RED, 0.25f, false, 2.0f);
    
    			// spawn a hit prefab at the intersection point
    			vfx.OnHit(hitInfo.Point, hitInfo.Normal, hitObject);
    
    		}
    	}
    
    	private void Update()
    	{
    		// handle input: check if the fire button is pressed
    		if (shootInput.IsShooting())
    			Shoot();
    	}
    }
  3. Add the VFXController.cs component to the player dummy node.
  4. Create a NodeDummy, call it muzzle, make it a child of the pistol skinned mesh, and place it near the end of the weapon muzzle.

  5. Select the player dummy node, assign the muzzle node to the Weapon Muzzle field in the WeaponController section.
  6. Assign the player dummy node to the Vfx field in the WeaponController section.

  7. Add the data/fps/bullet/bullet_hit.node to the Hit Prefab field of the VFXController section.
  8. Add the data/fps/bullet/bullet_spawn.node to the Muzzle Flash Prefab field.

Now you can press Start and test the shooting visual effects.

VFX Lifetime#

To control the duration of visual effects, you can add a component that will allow you to define a time interval for the node during which it will live and after which it will be deleted. The ready-to-use data/fps/components/LifeTime.cs component implements this logic.

LifeTime.cs

Source code (C#)
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 Lifetime : Component
{
	[ShowInEditor][Parameter(Tooltip = "Object's lifetime")]
	private float lifeTime = 1.0f;

	private float startTime = 0.0f;

	void Init()
	{
		// remember initialization time of an object
		startTime = Game.Time;
	}

	void Update()
	{
		// wait until the lifetime ends and delete the object
		if (Game.Time - startTime > lifeTime)
			node.DeleteLater();
	}
}

This component is already added to the bullet_hit.node and bullet_spawn.node prefabs:

  • For the bullet_hit.node, the Life Time parameter is set to 1 second.

  • For the bullet_spawn.node, the Life Time parameter is set to 5 seconds.

Adding a HUD for the Crosshair and Player Stats#

To make a HUD displaying some game info or other graphic elements, you can get an instance of the screen GUI and then add widgets as its children. We will use a WidgetSprite to make a crosshair at the center of the screen.

  1. Create a HUD.cs component (or use the existing one in data/fps/components).

    HUD.cs

    Source code (C#)
    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
    {
    	// crosshair parameters
    	[ParameterFile]
    	public string crosshairImage = null;
    	public int crosshairSize = 16;
    
    	private WidgetSprite sprite = null;
    
    	private void Init()
    	{
    
    		// get an instance of the screen Gui
    		Gui screenGui = Gui.Get();
    
    		// add a sprite widget
    		sprite = new WidgetSprite(screenGui, crosshairImage);
    		// set the sprite size
    		sprite.Width = crosshairSize;
    		sprite.Height = crosshairSize;
    
    		// make the sprite stay in the screen center and overlap the other widgets
    		screenGui.AddChild(sprite, Gui.ALIGN_CENTER | Gui.ALIGN_OVERLAP);
    	}
    	
    	private void Update()
    	{
    		// write here code to be called before updating each render frame
    		
    	}
    }
  2. Create a NodeDummy, place it somewhere in the scene, name it HUD and add the HUD.cs component to it.

  3. Add the data/fps/hud/textures/crosshair.png file to the Crosshair Image field.

Creating an Enemy#

The important part of any shooter is an enemy. We are going to create an enemy which moves around the scene chasing the player, starts firing at a certain distance from the player, and gets killed (deleted) if hit by the player's bullets.

Adding Enemy Model#

Before adding an enemy model, you should create it in a 3D modeling software.

Find our ready-to-use robot_enemy.node enemy prefab in the data/fps/robot folder and place it in the scene.

Applying a Finite-State Machine for AI#

To be a strong opponent, your enemy must have a certain level of intelligence. A simple AI can be implemented using a Finite-State Machine - a concept allowing you to describe the logic in terms of states and transitions between them.

For simplicity, consider three states: Idle, Chase, and Attack/Fire.

The following diagram describes what the enemy should do in each state, and how it will switch different states. The typical transitions would be from Idle to Chase, from Chase to Attack, and vice versa.

  1. Create an EnemyLogic.cs component and copy the code below:

    EnemyLogic.cs

    Source code (C#)
    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
    
    // declare the enemy states
    public enum EnemyLogicState
    {
    	Idle,
    	Chase,
    	Attack,
    }
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class EnemyLogic : Component
    {
    
    	public NavigationMesh navigationMesh = null;
    	public Node player = null;
    	public Node intersectionSocket = null;
    	public float reachRadius = 0.5f;
    	public float attackInnerRadius = 5.0f;
    	public float attackOuterRadius = 7.0f;
    	public float speed = 1.0f;
    	public float rotationStiffness = 8.0f;
    	public float routeRecalculationInterval = 3.0f;
    
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int playerIntersectionMask = ~0;
    
    	// initialize the enemy state
    	private EnemyLogicState currentState = EnemyLogicState.Idle;
    
    	private bool targetIsVisible;
    	private Vec3 lastSeenPosition;
    	private vec3 lastSeenDirection;
    	private float lastSeenDistanceSqr;
    
    	private BodyRigid bodyRigid = null;
    	private WorldIntersection hitInfo = new WorldIntersection();
    	private Node[] hitExcludes = new Node[2];
    
    	private bool IsTargetVisible()
    	{
    		Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
    		Vec3 p0 = intersectionSocket.WorldPosition;
    		Vec3 p1 = p0 + direction * 2.0f;
    
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
    		if (hitObject == null)
    			return false;
    
    		return player.ID == hitObject.ID;
    	}
    
    	private void Init()
    	{
    		// initialize parameters of the point moving along the route inside the navigation mesh
    		route.Radius = 0.0f;
    		route.Height = 1.0f;
    		route.MaxAngle = 0.5f;
    
    		bodyRigid = node.ObjectBodyRigid;
    		hitExcludes[0] = node;
    		hitExcludes[1] = node.GetChild(0);
    
    		targetIsVisible = false;
    
    	}
    
    	private void Update()
    	{
    
    		UpdateTargetState();
    		UpdateOrientation();
    
    		// switch between the enemy states
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: ProcessIdleState(); break;
    			case EnemyLogicState.Chase: ProcessChaseState(); break;
    			case EnemyLogicState.Attack: ProcessAttackState(); break;
    		}
    
    		// switch the colors indicating the enemy states
    		vec4 color = vec4.BLACK;
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: color = vec4.BLUE; break;
    			case EnemyLogicState.Chase: color = vec4.YELLOW; break;
    			case EnemyLogicState.Attack: color = vec4.RED; break;
    		}
    
    		// visualize the enemy states
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
    		Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);
    
    		// visualize the attack radus
    		Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
    		Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);
    
    	}
    
    	private void UpdateTargetState()
    	{
    		targetIsVisible = IsTargetVisible();
    		if (targetIsVisible)
    			lastSeenPosition = player.WorldPosition;
    
    		lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition);
    		lastSeenDistanceSqr = lastSeenDirection.Length2;
    		lastSeenDirection.Normalize();
    	}
    
    	private void UpdateOrientation()
    	{
    		vec3 direction = lastSeenDirection;
    		direction.z = 0.0f;
    
    		quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
    		quat currentRotation = node.GetWorldRotation();
    
    		currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
    		node.SetWorldRotation(currentRotation);
    	}
    
    	private void ProcessIdleState()
    	{
    		// check Idle -> Chase transition
    		if (targetIsVisible)
    		{
    			// change the current state to Chase
    			currentState = EnemyLogicState.Chase;
    			// remember the player last seen position
    			lastSeenPosition = player.WorldPosition;
    		}
    	}
    
    	private void ProcessChaseState()
    	{
    
    		vec3 currentVelocity = bodyRigid.LinearVelocity;
    		currentVelocity.x = 0.0f;
    		currentVelocity.y = 0.0f;
    
    		bool targetReached = (lastSeenDistanceSqr < reachRadius * reachRadius);
    		if (!targetReached)
    		{
    			currentVelocity.x = lastSeenDirection.x * speed;
    			currentVelocity.y = lastSeenDirection.y * speed;
    		}
    
    		// check Chase->Idle transition
    		if (!targetIsVisible)
    		{
    			currentState = EnemyLogicState.Idle;
    		}
    
    		// check Chase -> Attack transition
    		if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius && targetIsVisible)
    		{
    			currentState = EnemyLogicState.Attack;
    			currentVelocity.x = 0.0f;
    			currentVelocity.y = 0.0f;
    
    		}
    
    		bodyRigid.LinearVelocity = currentVelocity;
    	}
    
    	private void ProcessAttackState()
    	{
    		// check Attack -> Chase transition
    		if (lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius || !targetIsVisible)
    		{
    			currentState = EnemyLogicState.Chase;
    
    		}
    	}
    }
  2. Enable editing of the robot_enemy node and assign the component to the robot_root dummy node in the Parameters window.
  3. Right-click the player node reference in the World Nodes window and choose Unpack to Node Content. The node reference will be removed and its content will be displayed in the World Nodes hierarchy.

  4. Drag and drop the player_hit_box node to the Player field. The player_hit_box imitates the body of the player and is used in calculations.
  5. Drag and drop the robot_intersection_socket node of the robot_enemy to Intersection Socket field. It is the node from which the robot will do intersection checks.

For debugging, you can enable Visualizer which will display the inner and outer attack radius, as well as colored squares above the robot indicating:

  • The state of the robot: Idle - BLUE, Chase - YELLOW, Attack - RED.
  • If the target is visible: Yes - GREEN, No - RED.

Implementing Enemy Shooting#

Creating Guns#

To implement robot shooting, you need a bullet prefab that will be spawned at certain points when the robot is in the Attack state.

In the EnemyFireController component we will add some shooting logic to make the robot shoot alternately from the left and right muzzle. Positions of these muzzles, where bullet nodes will be spawned, are defined by the positions of two nodes that we will assign to the Left Muzzle and Right Muzzle fields of the component.

  1. Create an EnemyFireController.cs component (or use the existing one in data/fps/components).

    EnemyFireController.cs

    Source code (C#)
    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 EnemyFireController : Component
    {
    	public Node leftMuzzle = null;
    	public Node rightMuzzle = null;
    
    	[ParameterFile]
    	public string bulletPrefab = null;
    
    	public float shootInterval = 1.0f;
    
    	private float currentTime = 0.0f;
    	private bool isLeft = false;
    	private bool isFiring = false;
    
    	public void StartFiring()
    	{
    		isFiring = true;
    	}
    
    	public void StopFiring()
    	{
    		isFiring = false;
    	}
    
    	private void Init()
    	{
    		// reset the current time
    		currentTime = 0.0f;
    		// start shooting from the right muzzle
    		isLeft = false;
    	}
    
    	private void Update()
    	{
    		// if the enemy is in the Chase state, do nothing
    		if (!isFiring)
    			return;
    
    		// update the current time
    		currentTime += Game.IFps;
    
    		// check if the next shot should be fired
    		if (currentTime > shootInterval)
    		{
    			// reset the current time
    			currentTime = 0.0f;
    			// spawn a bullet
    			Node bullet = World.LoadNode(bulletPrefab);
    
    			// set the bullet transformation
    			bullet.WorldTransform = (isLeft) ? leftMuzzle.WorldTransform : rightMuzzle.WorldTransform;
    			// switch the muzzle for the next shot
    			isLeft = !isLeft;
    		}
    	}
    }
  2. Modify the EnemyLogic.cs component in order to use the implemented logic.

    EnemyLogic.cs

    Source code (C#)
    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
    
    // declare the enemy states
    public enum EnemyLogicState
    {
    	Idle,
    	Chase,
    	Attack,
    }
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class EnemyLogic : Component
    {
    
    	public NavigationMesh navigationMesh = null;
    	public Node player = null;
    	public Node intersectionSocket = null;
    	public float reachRadius = 0.5f;
    	public float attackInnerRadius = 5.0f;
    	public float attackOuterRadius = 7.0f;
    	public float speed = 1.0f;
    	public float rotationStiffness = 8.0f;
    	public float routeRecalculationInterval = 3.0f;
    
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int playerIntersectionMask = ~0;
    
    	// initialize the enemy state
    	private EnemyLogicState currentState = EnemyLogicState.Idle;
    
    	private bool targetIsVisible;
    	private Vec3 lastSeenPosition;
    	private vec3 lastSeenDirection;
    	private float lastSeenDistanceSqr;
    
    	private BodyRigid bodyRigid = null;
    	private WorldIntersection hitInfo = new WorldIntersection();
    	private Node[] hitExcludes = new Node[2];
    
    	private EnemyFireController fireController = null;
    
    	private bool IsTargetVisible()
    	{
    		Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
    		Vec3 p0 = intersectionSocket.WorldPosition;
    		Vec3 p1 = p0 + direction * 2.0f;
    
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
    		if (hitObject == null)
    			return false;
    
    		return player.ID == hitObject.ID;
    	}
    
    	private void Init()
    	{
    		// initialize parameters of the point moving along the route inside the navigation mesh
    		route.Radius = 0.0f;
    		route.Height = 1.0f;
    		route.MaxAngle = 0.5f;
    
    		bodyRigid = node.ObjectBodyRigid;
    		hitExcludes[0] = node;
    		hitExcludes[1] = node.GetChild(0);
    
    		targetIsVisible = false;
    
    		// grab the EnemyFireController component
    		fireController = node.GetComponent<EnemyFireController>();
    
    	}
    
    	private void Update()
    	{
    
    		UpdateTargetState();
    		UpdateOrientation();
    
    		// switch between the enemy states
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: ProcessIdleState(); break;
    			case EnemyLogicState.Chase: ProcessChaseState(); break;
    			case EnemyLogicState.Attack: ProcessAttackState(); break;
    		}
    
    		// switch the colors indicating the enemy states
    		vec4 color = vec4.BLACK;
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: color = vec4.BLUE; break;
    			case EnemyLogicState.Chase: color = vec4.YELLOW; break;
    			case EnemyLogicState.Attack: color = vec4.RED; break;
    		}
    
    		// visualize the enemy states
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
    		Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);
    
    		// visualize the attack radus
    		Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
    		Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);
    
    	}
    
    	private void UpdateTargetState()
    	{
    		targetIsVisible = IsTargetVisible();
    		if (targetIsVisible)
    			lastSeenPosition = player.WorldPosition;
    
    		lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition);
    		lastSeenDistanceSqr = lastSeenDirection.Length2;
    		lastSeenDirection.Normalize();
    	}
    
    	private void UpdateOrientation()
    	{
    		vec3 direction = lastSeenDirection;
    		direction.z = 0.0f;
    
    		quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
    		quat currentRotation = node.GetWorldRotation();
    
    		currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
    		node.SetWorldRotation(currentRotation);
    	}
    
    	private void ProcessIdleState()
    	{
    		// check Idle -> Chase transition
    		if (targetIsVisible)
    		{
    			// change the current state to Chase
    			currentState = EnemyLogicState.Chase;
    			// remember the player last seen position
    			lastSeenPosition = player.WorldPosition;
    		}
    	}
    
    	private void ProcessChaseState()
    	{
    
    		vec3 currentVelocity = bodyRigid.LinearVelocity;
    		currentVelocity.x = 0.0f;
    		currentVelocity.y = 0.0f;
    
    		bool targetReached = (lastSeenDistanceSqr < reachRadius * reachRadius);
    		if (!targetReached)
    		{
    			currentVelocity.x = lastSeenDirection.x * speed;
    			currentVelocity.y = lastSeenDirection.y * speed;
    		}
    
    		// check Chase->Idle transition
    		if (!targetIsVisible)
    		{
    			currentState = EnemyLogicState.Idle;
    		}
    
    		// check Chase -> Attack transition
    		if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius && targetIsVisible)
    		{
    			currentState = EnemyLogicState.Attack;
    			currentVelocity.x = 0.0f;
    			currentVelocity.y = 0.0f;
    
    			// start firing
    			if (fireController)
    				fireController.StartFiring();
    
    		}
    
    		bodyRigid.LinearVelocity = currentVelocity;
    	}
    
    	private void ProcessAttackState()
    	{
    		// check Attack -> Chase transition
    		if (lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius || !targetIsVisible)
    		{
    			currentState = EnemyLogicState.Chase;
    
    			// stop firing
    			if (fireController)
    				fireController.StopFiring();
    
    		}
    	}
    }
  3. Enable editing of the robot_enemy node (if required) and assign the EnemyFireController.cs component to the robot_root dummy object.
  4. Drag and drop the LeftGunMuzzle and RightGunMuzzle dummy nodes to the corresponding fields.

  5. Drag and drop the data/fps/bullet/bullet.node to the Bullet Prefab field.

Right Gun Muzzle Selected

Making a Moving and Destroyable Bullet#

After spawning, a bullet should move in the right direction changing its position in the world. If the bullet intersects with an object, a hit effect should be spawned at the point of impact. And if this object can take damage (i.e., it has a Health component, see below), its health should be decreased by a certain value. Also, you can make the bullet apply an impulse to physical objects.

  1. Add the data/fps/bullet/bullet.node to the scene.
  2. Create a Bullet.cs component and copy the following code:

    Bullet.cs

    Source code (C#)
    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")] // <-- this line is generated automatically for a new component
    public class Bullet : Component
    {
    	public float speed = 10.0f;
    	public int damage = 1;
    
    	[ParameterFile]
    	public string hitPrefab = null;
    
    	[ParameterMask]
    	public int intersectionMask = ~0;
    
    	private WorldIntersectionNormal hitInfo = new WorldIntersectionNormal();
    
    	private void Init()
    	{
    	}
    	
    	private void Update()
    	{
    		// set the current position of the bullet
    		Vec3 currentPosition = node.WorldPosition;
    		// set the current world direction vector of the bullet pointing along the Y axis
    		vec3 currentDirection = node.GetWorldDirection(MathLib.AXIS.Y);
    
    		// change the bullet position
    		node.WorldPosition += currentDirection * speed * Game.IFps;
    
    		// get the first intersected object
    		Unigine.Object hitObject = World.GetIntersection(currentPosition, node.WorldPosition, intersectionMask, hitInfo);
    
    		// if no intersections are found, do nothing
    		if (hitObject == null)
    			return;
    
    		// load a prefab for hit visualization
    		Node hitEffect = World.LoadNode(hitPrefab);
    		// place the prefab in the hit point and set its direction according to the hit normal
    		hitEffect.WorldPosition = hitInfo.Point;
    		hitEffect.SetWorldDirection(hitInfo.Normal, vec3.UP, MathLib.AXIS.Y);
    
    		// delete the bullet
    		node.DeleteLater();
    	}
    }

  3. Enable editing of the bullet node and assign the component to the bullet static mesh.
  4. Drag and drop data/fps/bullet/bullet_hit.node to the Hit Prefab field.
  5. Assign the LifeTime.cs component to the bullet static mesh and set its Life Time to 5 seconds.

Using Pathfinding#

The enemy should be able to chase the player correctly and not get stuck. To give the enemy additional knowledge about how it can navigate through the level, you can use pathfinding. This requires creating a navigation mesh, which specifies areas available for navigation and can be generated based on the FBX model of the scene using special tools, for example, RecastBlenderAddon.

The previously added EnemyLogic component includes pathfinding logic which calculates the route for the robot. When the robot is in the Chase state, instead of going directly to the last seen target position, it will follow a path using a navigation mesh added to the scene. The path consists of a queue of route points calculated by using the PathRoute class functionality.

In the data/fps/navmesh_import folder you can find a navigation mesh created for this project.

  1. To place the mesh in the scene, click Create->Navigation->NavigationMesh in the Menu Bar and specify the navmesh_import/navmesh.fbx/navmesh.002.mesh file.
  2. Align the mesh with the area.

  3. In the Parameters window, set the Height of the navigation mesh to 3 for proper route calculation.

  4. Implement pathfinding logic in the EnemyLogic.cs component. Replace the existing code with the following one:

    EnemyLogic.cs

    Source code (C#)
    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
    
    // declare the enemy states
    public enum EnemyLogicState
    {
    	Idle,
    	Chase,
    	Attack,
    }
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class EnemyLogic : Component
    {
    
    	public NavigationMesh navigationMesh = null;
    	public Node player = null;
    	public Node intersectionSocket = null;
    	public float reachRadius = 0.5f;
    	public float attackInnerRadius = 5.0f;
    	public float attackOuterRadius = 7.0f;
    	public float speed = 1.0f;
    	public float rotationStiffness = 8.0f;
    	public float routeRecalculationInterval = 3.0f;
    
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int playerIntersectionMask = ~0;
    
    	// initialize the enemy state
    	private EnemyLogicState currentState = EnemyLogicState.Idle;
    
    	private bool targetIsVisible;
    	private Vec3 lastSeenPosition;
    	private vec3 lastSeenDirection;
    	private float lastSeenDistanceSqr;
    
    	private BodyRigid bodyRigid = null;
    	private WorldIntersection hitInfo = new WorldIntersection();
    	private Node[] hitExcludes = new Node[2];
    
    	private EnemyFireController fireController = null;
    
    	// create a queue of the route points
    	private Queue<Vec3> calculatedRoute = new Queue<Vec3>();
    
    	private PathRoute route = new PathRoute();
    	private bool shouldUpdateRoute = true;
    	private float lastCalculationTime = 0.0f;
    
    	private bool IsTargetVisible()
    	{
    		Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
    		Vec3 p0 = intersectionSocket.WorldPosition;
    		Vec3 p1 = p0 + direction * 2.0f;
    
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
    		if (hitObject == null)
    			return false;
    
    		return player.ID == hitObject.ID;
    	}
    
    	private void Init()
    	{
    		// initialize parameters of the point moving along the route inside the navigation mesh
    		route.Radius = 0.0f;
    		route.Height = 1.0f;
    		route.MaxAngle = 0.5f;
    
    		bodyRigid = node.ObjectBodyRigid;
    		hitExcludes[0] = node;
    		hitExcludes[1] = node.GetChild(0);
    
    		targetIsVisible = false;
    
    		// grab the EnemyFireController component
    		fireController = node.GetComponent<EnemyFireController>();
    
    		shouldUpdateRoute = true;
    		lastCalculationTime = Game.Time;
    
    	}
    
    	private void Update()
    	{
    
    		UpdateTargetState();
    		UpdateOrientation();
    
    		UpdateRoute();
    
    		// switch between the enemy states
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: ProcessIdleState(); break;
    			case EnemyLogicState.Chase: ProcessChaseState(); break;
    			case EnemyLogicState.Attack: ProcessAttackState(); break;
    		}
    
    		// switch the colors indicating the enemy states
    		vec4 color = vec4.BLACK;
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: color = vec4.BLUE; break;
    			case EnemyLogicState.Chase: color = vec4.YELLOW; break;
    			case EnemyLogicState.Attack: color = vec4.RED; break;
    		}
    
    		// visualize the enemy states
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
    		Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);
    
    		// visualize the attack radus
    		Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
    		Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);
    
    		// visualize the route points
    		foreach (vec3 route_point in calculatedRoute)
    			Visualizer.RenderPoint3D(route_point + vec3.UP, 0.25f, vec4.BLACK);
    
    	}
    
    	private void UpdateRoute()
    	{
    		if (Game.Time - lastCalculationTime < routeRecalculationInterval)
    			return;
    
    		if (shouldUpdateRoute)
    		{
    			// calculate the route to the player
    			route.Create2D(node.WorldPosition, lastSeenPosition, 1);
    			shouldUpdateRoute = false;
    		}
    
    		// if the route is calculated
    		if (route.IsReady)
    		{
    			// if the target point of the route is reached
    			if (route.IsReached)
    			{
    				// clear the queue
    				calculatedRoute.Clear();
    
    				// add all root points to the queue
    				for(int i = 1; i < route.NumPoints; ++i)
    					calculatedRoute.Enqueue(route.GetPoint(i));
    
    				shouldUpdateRoute = true;
    				lastCalculationTime = Game.Time;
    			}
    			else
    				// recalculate the route if the target point isn't reached
    				route.Create2D(node.WorldPosition, lastSeenPosition, 1);
    		}
    	}
    
    	private void UpdateTargetState()
    	{
    		targetIsVisible = IsTargetVisible();
    		if (targetIsVisible)
    			lastSeenPosition = player.WorldPosition;
    
    		lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition);
    		lastSeenDistanceSqr = lastSeenDirection.Length2;
    		lastSeenDirection.Normalize();
    	}
    
    	private void UpdateOrientation()
    	{
    		vec3 direction = lastSeenDirection;
    		direction.z = 0.0f;
    
    		quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
    		quat currentRotation = node.GetWorldRotation();
    
    		currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
    		node.SetWorldRotation(currentRotation);
    	}
    
    	private void ProcessIdleState()
    	{
    		// check Idle -> Chase transition
    		if (targetIsVisible)
    		{
    			// change the current state to Chase
    			currentState = EnemyLogicState.Chase;
    			// remember the player last seen position
    			lastSeenPosition = player.WorldPosition;
    		}
    	}
    
    	private void ProcessChaseState()
    	{
    
    		vec3 currentVelocity = bodyRigid.LinearVelocity;
    		currentVelocity.x = 0.0f;
    		currentVelocity.y = 0.0f;
    
    		if (calculatedRoute.Count > 0)
    		{
    			float distanceToTargetSqr = (float)(calculatedRoute.Peek() - node.WorldPosition).Length2;
    
    			bool targetReached = (distanceToTargetSqr < reachRadius * reachRadius);
    			if (targetReached)
    				calculatedRoute.Dequeue();
    
    			vec3 direction = (vec3)(calculatedRoute.Peek() - node.WorldPosition);
    			direction.z = 0.0f;
    			direction.Normalize();
    
    			currentVelocity.x = direction.x * speed;
    			currentVelocity.y = direction.y * speed;
    
    		}
    
    		// check Chase->Idle transition
    		if (!targetIsVisible)
    		{
    			currentState = EnemyLogicState.Idle;
    		}
    
    		// check Chase -> Attack transition
    		if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius && targetIsVisible)
    		{
    			currentState = EnemyLogicState.Attack;
    			currentVelocity.x = 0.0f;
    			currentVelocity.y = 0.0f;
    
    			// start firing
    			if (fireController)
    				fireController.StartFiring();
    
    		}
    
    		bodyRigid.LinearVelocity = currentVelocity;
    	}
    
    	private void ProcessAttackState()
    	{
    		// check Attack -> Chase transition
    		if (lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius || !targetIsVisible)
    		{
    			currentState = EnemyLogicState.Chase;
    
    			// stop firing
    			if (fireController)
    				fireController.StopFiring();
    
    		}
    	}
    }
  5. Drag and drop the NavigationMesh node to the NavigationMesh field of the EnemyLogic component assigned to the robot.

You can visualize the route points as black squares for debugging by enabling Visualizer.

Controlling Health#

The player and enemies should have a health level that will decrease each time they are hit by a bullet.

  1. Create a Health.cs component and copy the following code (or use the existing one in data/fps/components):

    Health.cs

    Source code (C#)
    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 Health : Component
    {
    	public int health = 5;
    
    	// flag indicating that the health value is less or equal to 0
    	public bool IsDead => health <= 0;
    
    	public void TakeDamage(int damage)
    	{
    		// calculate damage to health
    		health = MathLib.Max(health - damage, 0);
    	}
    }
  2. Add it to the visuals node of the robot_enemy.

  3. Add it to the player_hit_box node of the player.
  4. Modify the WeaponController.cs, EnemyLogic.cs and Bullet.cs components in order to use logic of the Health.cs.

    WeaponController.cs

    Source code (C#)
    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")] // <-- this line is generated automatically for a new component
    public class WeaponController : Component
    {
    	public PlayerDummy shootingCamera = null;
    	public ShootInput shootInput = null;
    	public NodeDummy weaponMuzzle = null;
    
    	public VFXController vfx = null;
    
    	public int damage = 1;
    
    	// intersection mask
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int mask = ~0;
    
    	public void Shoot()
    	{
    
    		// spawn a muzzle flash
    		if (weaponMuzzle)
    			vfx.OnShoot(weaponMuzzle.WorldTransform);
    
    		// initialize the camera point (p0) and the point of the mouse pointer (p1)
    		Vec3 p0, p1;
    		shootingCamera.GetDirectionFromScreen(out p0, out p1);
    
    		// create an intersection normal
    		WorldIntersectionNormal hitInfo = new WorldIntersectionNormal();
    		// get the first object intersected by the (p0,p1) line
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, mask, hitInfo);
    
    		// if the intersection is found
    		if (hitObject)
    		{
    			// render the intersection normal
    			Visualizer.RenderVector(hitInfo.Point, hitInfo.Point + hitInfo.Normal, vec4.RED, 0.25f, false, 2.0f);
    
    			// spawn a hit prefab at the intersection point
    			vfx.OnHit(hitInfo.Point, hitInfo.Normal, hitObject);
    
    			// apply damage
    			Health health = hitObject.GetComponent<Health>();
    			if (health)
    				health.TakeDamage(damage);
    
    		}
    	}
    
    	private void Update()
    	{
    		// handle input: check if the fire button is pressed
    		if (shootInput.IsShooting())
    			Shoot();
    	}
    }

    EnemyLogic.cs

    Source code (C#)
    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
    
    // declare the enemy states
    public enum EnemyLogicState
    {
    	Idle,
    	Chase,
    	Attack,
    }
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class EnemyLogic : Component
    {
    
    	public NavigationMesh navigationMesh = null;
    	public Node player = null;
    	public Node intersectionSocket = null;
    	public float reachRadius = 0.5f;
    	public float attackInnerRadius = 5.0f;
    	public float attackOuterRadius = 7.0f;
    	public float speed = 1.0f;
    	public float rotationStiffness = 8.0f;
    	public float routeRecalculationInterval = 3.0f;
    
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int playerIntersectionMask = ~0;
    
    	// initialize the enemy state
    	private EnemyLogicState currentState = EnemyLogicState.Idle;
    
    	private bool targetIsVisible;
    	private Vec3 lastSeenPosition;
    	private vec3 lastSeenDirection;
    	private float lastSeenDistanceSqr;
    
    	private BodyRigid bodyRigid = null;
    	private WorldIntersection hitInfo = new WorldIntersection();
    	private Node[] hitExcludes = new Node[2];
    
    	private EnemyFireController fireController = null;
    
    	private Health health = null;
    
    	// create a queue of the route points
    	private Queue<Vec3> calculatedRoute = new Queue<Vec3>();
    
    	private PathRoute route = new PathRoute();
    	private bool shouldUpdateRoute = true;
    	private float lastCalculationTime = 0.0f;
    
    	private bool IsTargetVisible()
    	{
    		Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
    		Vec3 p0 = intersectionSocket.WorldPosition;
    		Vec3 p1 = p0 + direction * 2.0f;
    
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
    		if (hitObject == null)
    			return false;
    
    		return player.ID == hitObject.ID;
    	}
    
    	private void Init()
    	{
    		// initialize parameters of the point moving along the route inside the navigation mesh
    		route.Radius = 0.0f;
    		route.Height = 1.0f;
    		route.MaxAngle = 0.5f;
    
    		bodyRigid = node.ObjectBodyRigid;
    		hitExcludes[0] = node;
    		hitExcludes[1] = node.GetChild(0);
    
    		targetIsVisible = false;
    
    		// grab the EnemyFireController component
    		fireController = node.GetComponent<EnemyFireController>();
    
    		// grab the Health component
    		health = node.GetComponentInChildren<Health>();
    
    		shouldUpdateRoute = true;
    		lastCalculationTime = Game.Time;
    
    	}
    
    	private void Update()
    	{
    
    		// check the enemy health
    		if (health != null && health.IsDead)
    			// delete the enemy if it's dead
    			node.DeleteLater();
    
    		UpdateTargetState();
    		UpdateOrientation();
    
    		UpdateRoute();
    
    		// switch between the enemy states
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: ProcessIdleState(); break;
    			case EnemyLogicState.Chase: ProcessChaseState(); break;
    			case EnemyLogicState.Attack: ProcessAttackState(); break;
    		}
    
    		// switch the colors indicating the enemy states
    		vec4 color = vec4.BLACK;
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: color = vec4.BLUE; break;
    			case EnemyLogicState.Chase: color = vec4.YELLOW; break;
    			case EnemyLogicState.Attack: color = vec4.RED; break;
    		}
    
    		// visualize the enemy states
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
    		Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);
    
    		// visualize the attack radus
    		Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
    		Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);
    
    		// visualize the route points
    		foreach (vec3 route_point in calculatedRoute)
    			Visualizer.RenderPoint3D(route_point + vec3.UP, 0.25f, vec4.BLACK);
    
    	}
    
    	private void UpdateRoute()
    	{
    		if (Game.Time - lastCalculationTime < routeRecalculationInterval)
    			return;
    
    		if (shouldUpdateRoute)
    		{
    			// calculate the route to the player
    			route.Create2D(node.WorldPosition, lastSeenPosition, 1);
    			shouldUpdateRoute = false;
    		}
    
    		// if the route is calculated
    		if (route.IsReady)
    		{
    			// if the target point of the route is reached
    			if (route.IsReached)
    			{
    				// clear the queue
    				calculatedRoute.Clear();
    
    				// add all root points to the queue
    				for(int i = 1; i < route.NumPoints; ++i)
    					calculatedRoute.Enqueue(route.GetPoint(i));
    
    				shouldUpdateRoute = true;
    				lastCalculationTime = Game.Time;
    			}
    			else
    				// recalculate the route if the target point isn't reached
    				route.Create2D(node.WorldPosition, lastSeenPosition, 1);
    		}
    	}
    
    	private void UpdateTargetState()
    	{
    		targetIsVisible = IsTargetVisible();
    		if (targetIsVisible)
    			lastSeenPosition = player.WorldPosition;
    
    		lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition);
    		lastSeenDistanceSqr = lastSeenDirection.Length2;
    		lastSeenDirection.Normalize();
    	}
    
    	private void UpdateOrientation()
    	{
    		vec3 direction = lastSeenDirection;
    		direction.z = 0.0f;
    
    		quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
    		quat currentRotation = node.GetWorldRotation();
    
    		currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
    		node.SetWorldRotation(currentRotation);
    	}
    
    	private void ProcessIdleState()
    	{
    		// check Idle -> Chase transition
    		if (targetIsVisible)
    		{
    			// change the current state to Chase
    			currentState = EnemyLogicState.Chase;
    			// remember the player last seen position
    			lastSeenPosition = player.WorldPosition;
    		}
    	}
    
    	private void ProcessChaseState()
    	{
    
    		vec3 currentVelocity = bodyRigid.LinearVelocity;
    		currentVelocity.x = 0.0f;
    		currentVelocity.y = 0.0f;
    
    		if (calculatedRoute.Count > 0)
    		{
    			float distanceToTargetSqr = (float)(calculatedRoute.Peek() - node.WorldPosition).Length2;
    
    			bool targetReached = (distanceToTargetSqr < reachRadius * reachRadius);
    			if (targetReached)
    				calculatedRoute.Dequeue();
    
    			vec3 direction = (vec3)(calculatedRoute.Peek() - node.WorldPosition);
    			direction.z = 0.0f;
    			direction.Normalize();
    
    			currentVelocity.x = direction.x * speed;
    			currentVelocity.y = direction.y * speed;
    
    		}
    
    		// check Chase->Idle transition
    		if (!targetIsVisible)
    		{
    			currentState = EnemyLogicState.Idle;
    		}
    
    		// check Chase -> Attack transition
    		if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius && targetIsVisible)
    		{
    			currentState = EnemyLogicState.Attack;
    			currentVelocity.x = 0.0f;
    			currentVelocity.y = 0.0f;
    
    			// start firing
    			if (fireController)
    				fireController.StartFiring();
    
    		}
    
    		bodyRigid.LinearVelocity = currentVelocity;
    	}
    
    	private void ProcessAttackState()
    	{
    		// check Attack -> Chase transition
    		if (lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius || !targetIsVisible)
    		{
    			currentState = EnemyLogicState.Chase;
    
    			// stop firing
    			if (fireController)
    				fireController.StopFiring();
    
    		}
    	}
    }

    Bullet.cs

    Source code (C#)
    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")] // <-- this line is generated automatically for a new component
    public class Bullet : Component
    {
    	public float speed = 10.0f;
    	public int damage = 1;
    
    	[ParameterFile]
    	public string hitPrefab = null;
    
    	[ParameterMask]
    	public int intersectionMask = ~0;
    
    	private WorldIntersectionNormal hitInfo = new WorldIntersectionNormal();
    
    	private void Init()
    	{
    	}
    	
    	private void Update()
    	{
    		// set the current position of the bullet
    		Vec3 currentPosition = node.WorldPosition;
    		// set the current world direction vector of the bullet pointing along the Y axis
    		vec3 currentDirection = node.GetWorldDirection(MathLib.AXIS.Y);
    
    		// change the bullet position
    		node.WorldPosition += currentDirection * speed * Game.IFps;
    
    		// get the first intersected object
    		Unigine.Object hitObject = World.GetIntersection(currentPosition, node.WorldPosition, intersectionMask, hitInfo);
    
    		// if no intersections are found, do nothing
    		if (hitObject == null)
    			return;
    
    		// load a prefab for hit visualization
    		Node hitEffect = World.LoadNode(hitPrefab);
    		// place the prefab in the hit point and set its direction according to the hit normal
    		hitEffect.WorldPosition = hitInfo.Point;
    		hitEffect.SetWorldDirection(hitInfo.Normal, vec3.UP, MathLib.AXIS.Y);
    
    		// apply damage from the bullet
    		Health health = hitObject.GetComponent<Health>();
    		if (health != null)
    			health.TakeDamage(damage);
    
    		// delete the bullet
    		node.DeleteLater();
    	}
    }

Deleting the Killed#

The nodes that have zero health should be deleted from the scene.

The Health component has the IsDead flag which is checked by the EnemyLogic component of the robot. If the flag is true, the node will be deleted. We need to implement the same check for the player.

  1. Create a PlayerLogic.cs component (or use the existing one in data/fps/components)

    PlayerLogic.cs

    Source code (C#)
    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 PlayerLogic : Component
    {
    	private Health health = null;
    
    	private void Init()
    	{
    		// grab the Health component
    		health = node.GetComponentInChildren<Health>();
    	}
    	
    	private void Update()
    	{
    		// apply damage to the player's health
    		if (health != null && health.IsDead)
    		{
    			// delete the player
    			node.DeleteLater();
    			Game.Player = null;
    
    		}
    	}
    }
  2. Add the PlayerLogic component to the player node.

Switching Game States#

The game should have different states depending on the occurrence of certain events. For example, you can obtain a list of enemies, and if the list becomes empty, you win. You lose if the player gets killed or time runs out.

To switch between Play and Win/Lose states, we have a GameController component.

  1. Create a GameController.cs component and copy the following code (or use the existing one in data/fps/components):

    GameController.cs

    Source code (C#)
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using Unigine;
    
    public enum GameState
    {
    	Gameplay,
    	Win,
    	Lose,
    }
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class GameController : Component
    {
    	public GameState state = GameState.Gameplay;
    }
  2. Modify the EnemyLogic.cs and PlayerLogic.cs components in order to use logic of the GameCotroller.cs.

    EnemyLogic.cs

    Source code (C#)
    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
    
    // declare the enemy states
    public enum EnemyLogicState
    {
    	Idle,
    	Chase,
    	Attack,
    }
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class EnemyLogic : Component
    {
    
    	public GameController gameController = null;
    
    	public NavigationMesh navigationMesh = null;
    	public Node player = null;
    	public Node intersectionSocket = null;
    	public float reachRadius = 0.5f;
    	public float attackInnerRadius = 5.0f;
    	public float attackOuterRadius = 7.0f;
    	public float speed = 1.0f;
    	public float rotationStiffness = 8.0f;
    	public float routeRecalculationInterval = 3.0f;
    
    	[ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    	public int playerIntersectionMask = ~0;
    
    	// initialize the enemy state
    	private EnemyLogicState currentState = EnemyLogicState.Idle;
    
    	private bool targetIsVisible;
    	private Vec3 lastSeenPosition;
    	private vec3 lastSeenDirection;
    	private float lastSeenDistanceSqr;
    
    	private BodyRigid bodyRigid = null;
    	private WorldIntersection hitInfo = new WorldIntersection();
    	private Node[] hitExcludes = new Node[2];
    
    	private EnemyFireController fireController = null;
    
    	private Health health = null;
    
    	// create a queue of the route points
    	private Queue<Vec3> calculatedRoute = new Queue<Vec3>();
    
    	private PathRoute route = new PathRoute();
    	private bool shouldUpdateRoute = true;
    	private float lastCalculationTime = 0.0f;
    
    	private bool IsTargetVisible()
    	{
    		Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
    		Vec3 p0 = intersectionSocket.WorldPosition;
    		Vec3 p1 = p0 + direction * 2.0f;
    
    		Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
    		if (hitObject == null)
    			return false;
    
    		return player.ID == hitObject.ID;
    	}
    
    	private void Init()
    	{
    		// initialize parameters of the point moving along the route inside the navigation mesh
    		route.Radius = 0.0f;
    		route.Height = 1.0f;
    		route.MaxAngle = 0.5f;
    
    		bodyRigid = node.ObjectBodyRigid;
    		hitExcludes[0] = node;
    		hitExcludes[1] = node.GetChild(0);
    
    		targetIsVisible = false;
    
    		// grab the EnemyFireController component
    		fireController = node.GetComponent<EnemyFireController>();
    
    		// grab the Health component
    		health = node.GetComponentInChildren<Health>();
    
    		shouldUpdateRoute = true;
    		lastCalculationTime = Game.Time;
    
    	}
    
    	private void Update()
    	{
    
    		// check the game state
    		if (gameController.state != GameState.Gameplay)
    			return;
    
    		// check the enemy health
    		if (health != null && health.IsDead)
    			// delete the enemy if it's dead
    			node.DeleteLater();
    
    		UpdateTargetState();
    		UpdateOrientation();
    
    		UpdateRoute();
    
    		// switch between the enemy states
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: ProcessIdleState(); break;
    			case EnemyLogicState.Chase: ProcessChaseState(); break;
    			case EnemyLogicState.Attack: ProcessAttackState(); break;
    		}
    
    		// switch the colors indicating the enemy states
    		vec4 color = vec4.BLACK;
    		switch (currentState)
    		{
    			case EnemyLogicState.Idle: color = vec4.BLUE; break;
    			case EnemyLogicState.Chase: color = vec4.YELLOW; break;
    			case EnemyLogicState.Attack: color = vec4.RED; break;
    		}
    
    		// visualize the enemy states
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
    		Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
    		Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);
    
    		// visualize the attack radus
    		Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
    		Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);
    
    		// visualize the route points
    		foreach (vec3 route_point in calculatedRoute)
    			Visualizer.RenderPoint3D(route_point + vec3.UP, 0.25f, vec4.BLACK);
    
    	}
    
    	private void UpdateRoute()
    	{
    		if (Game.Time - lastCalculationTime < routeRecalculationInterval)
    			return;
    
    		if (shouldUpdateRoute)
    		{
    			// calculate the route to the player
    			route.Create2D(node.WorldPosition, lastSeenPosition, 1);
    			shouldUpdateRoute = false;
    		}
    
    		// if the route is calculated
    		if (route.IsReady)
    		{
    			// if the target point of the route is reached
    			if (route.IsReached)
    			{
    				// clear the queue
    				calculatedRoute.Clear();
    
    				// add all root points to the queue
    				for(int i = 1; i < route.NumPoints; ++i)
    					calculatedRoute.Enqueue(route.GetPoint(i));
    
    				shouldUpdateRoute = true;
    				lastCalculationTime = Game.Time;
    			}
    			else
    				// recalculate the route if the target point isn't reached
    				route.Create2D(node.WorldPosition, lastSeenPosition, 1);
    		}
    	}
    
    	private void UpdateTargetState()
    	{
    		targetIsVisible = IsTargetVisible();
    		if (targetIsVisible)
    			lastSeenPosition = player.WorldPosition;
    
    		lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition);
    		lastSeenDistanceSqr = lastSeenDirection.Length2;
    		lastSeenDirection.Normalize();
    	}
    
    	private void UpdateOrientation()
    	{
    		vec3 direction = lastSeenDirection;
    		direction.z = 0.0f;
    
    		quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
    		quat currentRotation = node.GetWorldRotation();
    
    		currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
    		node.SetWorldRotation(currentRotation);
    	}
    
    	private void ProcessIdleState()
    	{
    		// check Idle -> Chase transition
    		if (targetIsVisible)
    		{
    			// change the current state to Chase
    			currentState = EnemyLogicState.Chase;
    			// remember the player last seen position
    			lastSeenPosition = player.WorldPosition;
    		}
    	}
    
    	private void ProcessChaseState()
    	{
    
    		vec3 currentVelocity = bodyRigid.LinearVelocity;
    		currentVelocity.x = 0.0f;
    		currentVelocity.y = 0.0f;
    
    		if (calculatedRoute.Count > 0)
    		{
    			float distanceToTargetSqr = (float)(calculatedRoute.Peek() - node.WorldPosition).Length2;
    
    			bool targetReached = (distanceToTargetSqr < reachRadius * reachRadius);
    			if (targetReached)
    				calculatedRoute.Dequeue();
    
    			vec3 direction = (vec3)(calculatedRoute.Peek() - node.WorldPosition);
    			direction.z = 0.0f;
    			direction.Normalize();
    
    			currentVelocity.x = direction.x * speed;
    			currentVelocity.y = direction.y * speed;
    
    		}
    
    		// check Chase->Idle transition
    		if (!targetIsVisible)
    		{
    			currentState = EnemyLogicState.Idle;
    		}
    
    		// check Chase -> Attack transition
    		if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius && targetIsVisible)
    		{
    			currentState = EnemyLogicState.Attack;
    			currentVelocity.x = 0.0f;
    			currentVelocity.y = 0.0f;
    
    			// start firing
    			if (fireController)
    				fireController.StartFiring();
    
    		}
    
    		bodyRigid.LinearVelocity = currentVelocity;
    	}
    
    	private void ProcessAttackState()
    	{
    		// check Attack -> Chase transition
    		if (lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius || !targetIsVisible)
    		{
    			currentState = EnemyLogicState.Chase;
    
    			// stop firing
    			if (fireController)
    				fireController.StopFiring();
    
    		}
    	}
    }

    PlayerLogic.cs

    Source code (C#)
    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 PlayerLogic : Component
    {
    	private Health health = null;
    
    	public GameController gameController = null;
    
    	private void Init()
    	{
    		// grab the Health component
    		health = node.GetComponentInChildren<Health>();
    	}
    	
    	private void Update()
    	{
    		// apply damage to the player's health
    		if (health != null && health.IsDead)
    		{
    			// delete the player
    			node.DeleteLater();
    			Game.Player = null;
    
    			// change the game state to Lose
    			gameController.state = GameState.Lose;
    
    		}
    	}
    }
  3. Create a NodeDummy, name it gameplay_systems, and assign the GameController component to it.

  4. Drag and drop gameplay_systems to the Game Controller field of the player and robot_enemy.

Trying Out#

Now you are ready to add more enemies and see if you can fight off an attack by a gang of angry robots!

Last update: 2022-04-07
Build: ()