For milestone 0 I will be adding to the Unity Lerpz tutorial found
here.
The updated unity project can be downloaded
here
Edit: added a link to the exe:
here
Game Hud
First we are going to add our name to the Game Hud. Why? because we are very proud of our work, and want everyone to know who did it. And also because that's the assignment.
To do this we will edit the existing GameHud.js script.
First lets add a variable to the top of the script so we can easily set our name from within the unity GUI.
// player name to write on the bottom of the screen
var nameText : String;
Next in keeping with the style of the class lets create a new method DrawLabelBottomCenterAligned. Unlike the other two draw label methods that take a vector and a string, our method will take just a string, and we will calculate the location to draw the string based on its size.
function DrawLabelBottomCenterAligned (text : String)
{
The first thing we want to do is calculate the size that the passed string will be. We can do this by getting the style of the Label, and using the CalcSize method
//get size of content
var textSize = GUI.skin.GetStyle("Label").CalcSize(GUIContent(text))
This gives us a vector2 where x is the width, and y is the height of the text. Now we need to use these to calculate the position that the text should be drawn. The top position is easy, we want the text to be aligned with the bottom of the window, so we will draw the text at the height of the window minus the height of the text.
The left position is a bit more difficult. But we can determine where to draw it with some simple geometry. First we find the center of the window by taking the width and diving by 2, then we subtract half the width of the text so that the center of the text aligns with the center of the window.
var top = nativeVerticalResolution - textSize.y;
var scaledResolutionWidth = nativeVerticalResolution / Screen.height * Screen.width;
var left = (scaledResolutionWidth / 2) - (textSize.x / 2);
Finally we draw the text
GUI.Label(Rect (left, top, textSize.x, textSize.y), text);
}
Now we need to actually call our function. At the end of the OnGui method add the following call:
DrawLabelBottomCenterAligned(nameText);
Finally in the Unity editor we can set our name
Now pressing play we can see our name at the bottom center of the screen!
Enemy Stun
Next were going to add the ability to stun the bad guys. The bad guys will become stunned when we jump on their head. Unlike humans when robots become stunned their circuits wig out, and they start rotating really fast. Also like all action heroes when Lerpz jumps on the enemies head he will bounce of the top of its head.
So we are going to need three things:
1. A way to tell when Lerpz collides with an enemies head
2. Ability to put the enemy into a stunned state, and make sure it does not try and attack while in that state.
3. Have Lerpz bounce up off the enemy's head.
First let focus on determining when Lerpz collides with the enemies head. To do this we will add a BoxCollider to the Copper prefab. But wait! For some reason it appears the Coppers already have a BoxCollider on their head. If you look at a Copper in the world view Hierarchy and dig down into Copper->root->Base->Waist1->Waist2->Body->Torso->Head->Helmet you will see there is already a BoxCollider there.
Thats not a very good place for our BoxCollider though and here is why. For reasons disscussed
here Unity developers have chosen to only allow access to two levels of object Hierarchy in the Project view. The BoxCollider is much more than two levels deep, so if we ever wanted to edit it, it would not be possible in the Project view. So first lets delete that BoxCollider, and make a new one on the Copper prefab. The setting shown below should put the BoxCollider on the Coppers helmet:
Now lets edit the EnemyPoliceGuy script.
First we will add some variables. The first is private, and is to tell us if the enemy is currently stunned. The next two allow you to easily set the length of time the enemy will be stunned for, and the height at which Lerpz will bounce off the enemy.
private var stunned : boolean = false;
var stunLenght = 10.0;
var stunJumpHeight = 5.0;
Next lets write the routine that the enemies will run when they are stunned
function StunnedRotate()
{
while (stunned)
{
// Rotate
var rotation = rotateSpeed * 3.0 * Time.deltaTime;
transform.Rotate(0, rotation, 0);
yield;
}
}
There are a few things to notice about this routine. You can see first the the routine will not return until we are no longer stunned. Instead the routine uses the
yield keyword. The yield keyword tell unity that our script is done for this frame, and allows other scripts to do the processing they need to do. When its time for our object to do work again, instead of calling Update() our script will resume right where it was when we yielded.
The function itself is rotating the enemy at a rotation speed three times as fast as normal. For the rotation instead of rotating a certain amount per frame, we want to rotate a certain amount per second. So we multiply the rotation speed by Time.deltaTime, which is the time that has passed between this frame and the last one.
Now we need to decide when our function is going to be called. If you look at the end of the Start function you will see how the script currently has the Copper Idling for some time, and then Attacking for some time. This is the place we want to add a call to our StunnedRotate function.
while (true) {
yield StunnedRotate();
// Don't do anything when idle. And wait for player to be in range!
// This is the perfect time for the player to attack us
yield Idle();
// Prepare, turn to player and attack him
yield Attack();
}
At this point after each attack the Copper will check if it is stunned and will rotate until it is no longer stunned. But what if the Copper is stunned while it is doing its attack routine. To handle this we will add some break outs to the Attack method. There are two while loops in this method. One where the Copper rotates until the angel between it and the player is near 0, and another where the copper runs toward the player. In both loops we want to add a check right before where we would normally yield, and if we are stunned break out of the loop (search for text "break out of the loop if stunned").
while (angle > 5 || time < attackTurnTime)
{
time += Time.deltaTime;
angle = Mathf.Abs(RotateTowardsPosition(target.position, rotateSpeed));
move = Mathf.Clamp01((90 - angle) / 90);
// depending on the angle, start moving
animation["attackrun"].weight = animation["attackrun"].speed = move;
direction = transform.TransformDirection(Vector3.forward * attackSpeed * move);
characterController.SimpleMove(direction);
//break out of the loop if stunned
if (stunned ) { break; }
yield;
}
// Run towards player
var timer = 0.0;
var lostSight = false;
while (timer < extraRunTime)
{
angle = RotateTowardsPosition(target.position, attackRotateSpeed);
// The angle of our forward direction and the player position is larger than 50 degrees
// That means he is out of sight
if (Mathf.Abs(angle) > 40)
lostSight = true;
// If we lost sight then we keep running for some more time (extraRunTime).
// then stop attacking
if (lostSight)
timer += Time.deltaTime;
// Just move forward at constant speed
direction = transform.TransformDirection(Vector3.forward * attackSpeed);
characterController.SimpleMove(direction);
// Keep looking if we are hitting our target
// If we are, knock them out of the way dealing damage
var pos = transform.TransformPoint(punchPosition);
if(Time.time > lastPunchTime + 0.3 && (pos - target.position).magnitude < punchRadius)
{
// deal damage
target.SendMessage("ApplyDamage", damage);
// knock the player back and to the side
var slamDirection = transform.InverseTransformDirection(target.position - transform.position);
slamDirection.y = 0;
slamDirection.z = 1;
if (slamDirection.x >= 0)
slamDirection.x = 1;
else
slamDirection.x = -1;
target.SendMessage("Slam", transform.TransformDirection(slamDirection));
lastPunchTime = Time.time;
}
// We are not actually moving forward.
// This probably means we ran into a wall or something. Stop attacking the player.
if (characterController.velocity.magnitude < attackSpeed * 0.3)
break;
//break out of the loop if stunned
if (stunned ) { break; }
yield;
}
Now we need a function that actually puts us into stunned mode. The function below will put us into stunned mode, wait for a certain number of seconds and then take us back out. Once again we are using yield to break execution in the middle of a function. This time we call yield with a WaitForSeconds argument so that we dont return from the yield until that many second has passed.
function StunFor(seconds : int)
{
stunned = true;
yield WaitForSeconds(seconds);
stunned = false;
}
Finally lets add the function that will get called when we collide with the BoxCollider. First we want to check that the object that collided was the player. Next we want to make Lerpz bounce off the enemy (conveniently he already has a SuperJump function allowing him to do just that), and finally we want to Stun the enemy for a number of seconds.
function OnTriggerEnter (other : Collider)
{
var controller : ThirdPersonController = other.GetComponent(ThirdPersonController);
if (controller != null)
{
controller.SuperJump(stunJumpHeight);
StunFor(stunLenght);
}
}
Now enemies will be no match for Lerpz
Patrol Ship
Last of all we will add a ship that patrols the area. The player will not be able to interact with the ship it will just be to add effect to the level and make the area look more alive.
First we need to add a game object for the ship, to do that create a new game object, and add a mesh filter, mesh render, and mesh collider. We are going to use the same texture for the patrol ship as we used for the Lerpz ship (hopefully our hero wont get confused). Set the properties of the patrole ship as shown:
Now we have our ship we need to make it move. Luckily there are people in the Unity community that have created useful game components, so we wont actually have to write any code for this. We are going to be using the Spline Controller from
here. This allows us to set a bunch of points in the Unity editor, attach a script to our game object, and it will travel between the points. After downloading the ".unitypackage" file from the link above, double click it and a Splines folder will be added to your projects resource.
To start placing the path drag out a Spline System object. This creates a spline system with a few nodes. Now we just need to add some more nodes to define the path we want our space ship to travel on. This can be done by selecting the last Node object, and clicking the Add Next button. Now we need to position all the nodes this is easily done from the top down view.
I choose my path such that the ship was coming right at the player from the start. Also I made sure that all Nodes on my path had a Y value of 0. The Nodes are sub objects of the Spline System object, so instead of adjusting the Y position each individual Node it is easier to just adjust the Y value of the Spline System object. This Y value will need to be tinkered with some so that the ship is low enough to appears in the players view, but is not so low that it collides with scenery. To complete the laying out of the Spline System I set the Speed of all Nodes to be 8, and the Pause time to be 0.
Now we need to add the spline controller script to the object that we want to follow the path. Drag the script onto our patrol ship object. This script will automatically add a rigid-body component as well. Set the properties of these components as shown below:
To set the Current Spline drag out the the top level Spline System object (Not one of the Nodes in the Spline System). Now the space ship should follow the points when you press Play.
Appendix
The full text of the two edited script files is below:
GameHUD.js
// GameHUD: Platformer Tutorial Master GUI script.
// This script handles the in-game HUD, showing the lives, number of fuel cells remaining, etc.
var guiSkin: GUISkin;
var nativeVerticalResolution = 1200.0;
// main decoration textures:
var healthImage: Texture2D;
var healthImageOffset = Vector2(0, 0);
// the health 'pie chart' assets consist of six textures with alpha channels. Only one is ever shown:
var healthPieImages : Texture2D[];
var healthPieImageOffset = Vector2(10, 147);
// the lives count is displayed in the health image as a text counter
var livesCountOffset = Vector2(425, 160);
// The fuel cell decoration image on the right side
var fuelCellsImage: Texture2D;
var fuelCellOffset = Vector2(0, 0);
// The counter text inside the fuel cell image
var fuelCellCountOffset = Vector2(391, 161);
// player name to write on the bottom of the screen
var nameText : String;
private var playerInfo : ThirdPersonStatus;
// Cache link to player's state management script for later use.
function Awake()
{
playerInfo = FindObjectOfType(ThirdPersonStatus);
if (!playerInfo)
Debug.Log("No link to player's state manager.");
}
function OnGUI ()
{
var itemsLeft = playerInfo.GetRemainingItems(); // fetch items remaining -- the fuel cans. This can be a negative number!
// Similarly, health needs to be clamped to the number of pie segments we can show.
// We also need to check it's not negative, so we'll use the Mathf Clamp() function:
var healthPieIndex = Mathf.Clamp(playerInfo.health, 0, healthPieImages.length);
// Displays fuel cans remaining as a number.
// As we don't want to display negative numbers, we clamp the value to zero if it drops below this:
if (itemsLeft < 0)
itemsLeft = 0;
// Set up gui skin
GUI.skin = guiSkin;
// Our GUI is laid out for a 1920 x 1200 pixel display (16:10 aspect). The next line makes sure it rescales nicely to other resolutions.
GUI.matrix = Matrix4x4.TRS (Vector3(0, 0, 0), Quaternion.identity, Vector3 (Screen.height / nativeVerticalResolution, Screen.height / nativeVerticalResolution, 1));
// Health & lives info.
DrawImageBottomAligned( healthImageOffset, healthImage); // main image.
// now for the pie chart. This is where a decent graphics package comes in handy to check relative sizes and offsets.
var pieImage = healthPieImages[healthPieIndex-1];
DrawImageBottomAligned( healthPieImageOffset, pieImage );
// Displays lives left as a number.
DrawLabelBottomAligned( livesCountOffset, playerInfo.lives.ToString() );
// Now it's the fuel cans' turn. We want this aligned to the lower-right corner of the screen:
DrawImageBottomRightAligned( fuelCellOffset, fuelCellsImage);
DrawLabelBottomRightAligned( fuelCellCountOffset, itemsLeft.ToString() );
DrawLabelBottomCenterAligned(nameText);
}
function DrawImageBottomAligned (pos : Vector2, image : Texture2D)
{
GUI.Label(Rect (pos.x, nativeVerticalResolution - image.height - pos.y, image.width, image.height), image);
}
function DrawLabelBottomAligned (pos : Vector2, text : String)
{
GUI.Label(Rect (pos.x, nativeVerticalResolution - pos.y, 100, 100), text);
}
function DrawImageBottomRightAligned (pos : Vector2, image : Texture2D)
{
var scaledResolutionWidth = nativeVerticalResolution / Screen.height * Screen.width;
GUI.Label(Rect (scaledResolutionWidth - pos.x - image.width, nativeVerticalResolution - image.height - pos.y, image.width, image.height), image);
}
function DrawLabelBottomRightAligned (pos : Vector2, text : String)
{
var scaledResolutionWidth = nativeVerticalResolution / Screen.height * Screen.width;
GUI.Label(Rect (scaledResolutionWidth - pos.x, nativeVerticalResolution - pos.y, 100, 100), text);
}
function DrawLabelBottomCenterAligned (text : String)
{
var textSize = GUI.skin.GetStyle("Label").CalcSize(GUIContent(text));
var top = nativeVerticalResolution - textSize.y;
var scaledResolutionWidth = nativeVerticalResolution / Screen.height * Screen.width;
var left = (scaledResolutionWidth / 2) - (textSize.x / 2);
GUI.Label(Rect (left, top, textSize.x, textSize.y), text);
}
EnemyPoliceGuy.js
/*
animations played are:
idle, threaten, turnjump, attackrun
*/
var attackTurnTime = 0.7;
var rotateSpeed = 120.0;
var attackDistance = 17.0;
var extraRunTime = 2.0;
var damage = 1;
var attackSpeed = 5.0;
var attackRotateSpeed = 20.0;
var idleTime = 1.6;
var punchPosition = new Vector3 (0.4, 0, 0.7);
var punchRadius = 1.1;
// sounds
var idleSound : AudioClip; // played during "idle" state.
var attackSound : AudioClip; // played during the seek and attack modes.
private var attackAngle = 10.0;
private var isAttacking = false;
private var lastPunchTime = 0.0;
var target : Transform;
private var stunned : boolean = false;
var stunLenght = 10.0;
var stunJumpHeight = 5.0;
// Cache a reference to the controller
private var characterController : CharacterController;
characterController = GetComponent(CharacterController);
// Cache a link to LevelStatus state machine script:
var levelStateMachine : LevelStatus;
function Start ()
{
levelStateMachine = GameObject.Find("/Level").GetComponent(LevelStatus);
if (!levelStateMachine)
{
Debug.Log("EnemyPoliceGuy: ERROR! NO LEVEL STATUS SCRIPT FOUND.");
}
if (!target)
target = GameObject.FindWithTag("Player").transform;
animation.wrapMode = WrapMode.Loop;
// Setup animations
animation.Play("idle");
animation["threaten"].wrapMode = WrapMode.Once;
animation["turnjump"].wrapMode = WrapMode.Once;
animation["gothit"].wrapMode = WrapMode.Once;
animation["gothit"].layer = 1;
// initialize audio clip. Make sure it's set to the "idle" sound.
audio.clip = idleSound;
yield WaitForSeconds(Random.value);
// Just attack for now
while (true)
{
yield StunnedRotate();
// Don't do anything when idle. And wait for player to be in range!
// This is the perfect time for the player to attack us
yield Idle();
// Prepare, turn to player and attack him
yield Attack();
}
}
function Idle ()
{
// if idling sound isn't already set up, set it and start it playing.
if (idleSound)
{
if (audio.clip != idleSound)
{
audio.Stop();
audio.clip = idleSound;
audio.loop = true;
audio.Play(); // play the idle sound.
}
}
// Don't do anything when idle
// The perfect time for the player to attack us
yield WaitForSeconds(idleTime);
// And if the player is really far away.
// We just idle around until he comes back
// unless we're dying, in which case we just keep idling.
while (true)
{
characterController.SimpleMove(Vector3.zero);
yield WaitForSeconds(0.2);
var offset = transform.position - target.position;
// if player is in range again, stop lazyness
// Good Hunting!
if (offset.magnitude < attackDistance)
return;
if (stunned ) { return; }
}
}
function StunnedRotate()
{
while (stunned)
{
// Rotate
var rotation = rotateSpeed * 3.0 * Time.deltaTime;
transform.Rotate(0, rotation, 0);
yield;
}
}
function RotateTowardsPosition (targetPos : Vector3, rotateSpeed : float) : float
{
// Compute relative point and get the angle towards it
var relative = transform.InverseTransformPoint(targetPos);
var angle = Mathf.Atan2 (relative.x, relative.z) * Mathf.Rad2Deg;
// Clamp it with the max rotation speed
var maxRotation = rotateSpeed * Time.deltaTime;
var clampedAngle = Mathf.Clamp(angle, -maxRotation, maxRotation);
// Rotate
transform.Rotate(0, clampedAngle, 0);
// Return the current angle
return angle;
}
function Attack ()
{
isAttacking = true;
if (attackSound)
{
if (audio.clip != attackSound)
{
audio.Stop(); // stop the idling audio so we can switch out the audio clip.
audio.clip = attackSound;
audio.loop = true; // change the clip, then play
audio.Play();
}
}
// Already queue up the attack run animation but set it's blend wieght to 0
// it gets blended in later
// it is looping so it will keep playing until we stop it.
animation.Play("attackrun");
// First we wait for a bit so the player can prepare while we turn around
// As we near an angle of 0, we will begin to move
var angle : float;
angle = 180.0;
var time : float;
time = 0.0;
var direction : Vector3;
while (angle > 5 || time < attackTurnTime)
{
time += Time.deltaTime;
angle = Mathf.Abs(RotateTowardsPosition(target.position, rotateSpeed));
move = Mathf.Clamp01((90 - angle) / 90);
// depending on the angle, start moving
animation["attackrun"].weight = animation["attackrun"].speed = move;
direction = transform.TransformDirection(Vector3.forward * attackSpeed * move);
characterController.SimpleMove(direction);
//break out of the loop if stunned
if (stunned ) { break; }
yield;
}
// Run towards player
var timer = 0.0;
var lostSight = false;
while (timer < extraRunTime)
{
angle = RotateTowardsPosition(target.position, attackRotateSpeed);
// The angle of our forward direction and the player position is larger than 50 degrees
// That means he is out of sight
if (Mathf.Abs(angle) > 40)
lostSight = true;
// If we lost sight then we keep running for some more time (extraRunTime).
// then stop attacking
if (lostSight)
timer += Time.deltaTime;
// Just move forward at constant speed
direction = transform.TransformDirection(Vector3.forward * attackSpeed);
characterController.SimpleMove(direction);
// Keep looking if we are hitting our target
// If we are, knock them out of the way dealing damage
var pos = transform.TransformPoint(punchPosition);
if(Time.time > lastPunchTime + 0.3 && (pos - target.position).magnitude < punchRadius)
{
// deal damage
target.SendMessage("ApplyDamage", damage);
// knock the player back and to the side
var slamDirection = transform.InverseTransformDirection(target.position - transform.position);
slamDirection.y = 0;
slamDirection.z = 1;
if (slamDirection.x >= 0)
slamDirection.x = 1;
else
slamDirection.x = -1;
target.SendMessage("Slam", transform.TransformDirection(slamDirection));
lastPunchTime = Time.time;
}
// We are not actually moving forward.
// This probably means we ran into a wall or something. Stop attacking the player.
if (characterController.velocity.magnitude < attackSpeed * 0.3)
break;
//break out of the loop if stunned
if (stunned ) { break; }
yield;
}
isAttacking = false;
// Now we can go back to playing the idle animation
animation.CrossFade("idle");
}
function ApplyDamage ()
{
animation.CrossFade("gothit");
}
function StunFor(seconds : int)
{
stunned = true;
yield WaitForSeconds(seconds);
stunned = false;
}
function OnDrawGizmosSelected ()
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere (transform.TransformPoint(punchPosition), punchRadius);
Gizmos.color = Color.red;
Gizmos.DrawWireSphere (transform.position, attackDistance);
}
function OnTriggerEnter (other : Collider)
{
var controller : ThirdPersonController = other.GetComponent(ThirdPersonController);
if (controller != null)
{
controller.SuperJump(stunJumpHeight);
StunFor(stunLenght);
}
}
// Auto setup
function Reset ()
{
if (collider == null)
gameObject.AddComponent(BoxCollider);
collider.isTrigger = true;
}
@script RequireComponent(AudioSource)