Functions and Parameters - Digital Prototyping - Introduction to Game Design, Prototyping, and Development (2015)

Introduction to Game Design, Prototyping, and Development (2015)

Part II: Digital Prototyping

Chapter 23. Functions and Parameters

In this chapter, you learn to take advantage of the immense power of functions. You write your own custom functions which can take any kind of variables as input arguments and can return a single variable as the function’s result. We also explore some special cases of parameters for function input like function overloading, optional parameters, and the params keyword modifier, all of which will help you to write more effective, modular, reusable, and flexible code.

Set Up the Function Examples Project

In Appendix A, “Standard Project Setup Procedure,” detailed instructions show you how to set up Unity projects for the chapters in this book. At the start of each project, you will also see a sidebar like the one here. Please follow the directions in the sidebar to create the project for this chapter.


Set Up the Project for this Chapter

Following the standard project setup procedure, create a new project in Unity. For information on the standard project setup procedure, see Appendix A.

Image Project name: Function Examples

Image Scene name: _Scene_Functions

Image C# Script names: CodeExample

Attach the script CodeExample to the Main Camera in the scene.


Definition of a Function

You’ve actually been writing functions since your first Hello World program, but up until now, you’ve been adding content to built-in Unity MonoBehaviour functions like Awake(), Start(), and Update(). From now on, you’ll also be writing custom functions.

The best way to think about a function is as a chunk of code that does something. For instance, to count the number of times that Update() has been called, you can create a new C# script with the following code (you will need to add the bold lines):

1 using UnityEngine;
2 using System.Collections;
3
4 public class CodeExample : MonoBehaviour {
5
6 public int numTimesCalled = 0; // 1
7
8 void Update() {
9 numTimesCalled++; // 2
10 CountUpdates(); // 3
11 }
12
13 void CountUpdates() { // 4
14 string outputMessage = "Updates: "+numTimesCalled; // 5
15 print( outputMessage ); // Output example: "Updates: 1" // 6
16 }
17
18 }

1. Declares the public variable numTimesCalled and defines it to initially have the value 0. Because numTimesCalled is declared as a public variable inside the class CodeExample but outside of any function, it is scoped to the CodeExample class and is available to be accessed by any of the functions within the CodeExample class.

2. numTimesCalled is incremented (1 is added to it).

3. Line 10 calls the function CountUpdates(). When your code calls a function, it causes the function to be executed. This will be described in more detail soon.

4. Lines 13 declares the function CountUpdates(). Declaring a function is similar to declaring a variable. void is the return type of the function (as will be covered later in the chapter). Lines 13-16 define the function. All lines of code between the opening brace { on line 13 and the closing brace } on line 16 are part of the definition of CountUpdates().

Note that it the order in which your functions are declared in the class doesn’t matter. Whether CountUpdates() or Update() is declared first is irrelevant as long as they are both within the braces of the class CodeExample. C# will look through all the declarations in a class before running any code. It’s perfectly fine for CountUpdates() to be called on line 10 and declared on line 13 because both CountUpdates() and Update() are functions declared in the class CodeExample.

5. Line 14 defines a local string variable named outputMessage. Because outputMessage is defined within the function CountUpdate() its scope is limited to CountUpdate(), meaning that outputMessage has no value outside of the function CountUpdate(). For more information about variable scope, see the “Variable Scope” section of Appendix B, “Useful Concepts.”

Line 14 also defines outputMessage to be the concatenation of "Updates: " and numTimesCalled.

6. The Unity function print() is called with the single argument outputMessage. This prints the value of outputMessage to the Unity Console. Function arguments are covered later in this chapter.

In practice, CountUpdate() would not be a terribly useful function, but it does showcase two of the important concepts covered in this chapter.

Image Functions encapsulate actions: A function can be thought of as a named collection of several lines of code. Every time the function is called, those lines of code are executed. This was demonstrated by both CountUpdate() and the BuySomeMilk() example from Chapter 17, “Introducing Our Language: C#.”

Image Functions contain their own scope: As you can read in the “Variable Scope” section of Appendix B, variables declared within a function have their scope limited to that function. Therefore, the variable outputMessage (declared on line 14) has a scope limited to just the functionCountUpdates(). This can either be stated as “outputMessage is scoped to CountUpdates()" or "outputMessage is local to CountUpdates()."

Contrast the scope of outputMessage with that of the public variable numTimesCalled, which has a scope of the entire CodeExample class and can be used by any function in CodeExample.

If you run this code in Unity, you will see that numTimesCalled is incremented every frame, and CountUpdate() is called every frame (which outputs the value of numTimesCalled to the Console pane). Calling a function causes it to execute, and when the function is done, execution then returns to the point from where it was called. So, in the class CodeExample, the following happens every frame:

1. Every frame, the Unity engine calls the Update() function (line 8).

2. Then, line 9 increments numTimesCalled.

3. Line 10 calls CountUpdate().

4. Execution then jumps to the beginning of the CountUpdate() function on line 13.

5. Lines 14 and 15 are executed.

6. When Unity reaches the closing brace of CountUpdate() on line 16, execution returns to line 10 (the line from which it was called).

7. Execution continues to line 11.

The remainder of this chapter covers both simple and complex uses of functions, and it’s an introduction to some pretty complex concepts. As you continue into the tutorials later in this book, you’ll get a much better understanding of how functions work and get more ideas for your own functions, so if there’s anything that doesn’t make sense the first time through this chapter, that’s okay. You can return to it once you’ve read a bit more of the book.


Using Code From This Chapter in Unity

Though the first code listing in this chapter includes all of the lines of the CodeExample class, later code examples do not. If you want to actually run the rest of the code from this chapter in Unity, you will need to wrap it inside of a class. Classes are covered in detail inChapter 25, “Classes,” but for now, you can accomplish this by adding the bolded lines that follow around any of the code listed in this chapter:

1 using UnityEngine;
2 using System.Collections;
3
4 public class CodeExample : MonoBehaviour {
5
// The code listing would replace this comment
16
17 }

For example, without the bold lines here, the first code listing in this chapter would have looked like this:

6 public int numTimesCalled = 0;
7
8 void Update() {
9 CountUpdates();
10 }
11
12 void CountUpdates() {
13 numTimesCalled++;
14 print( "Updates: "+numTimesCalled ); // e.g., "Updates: 5"
15 }

If you wanted to try this listing of lines 6-15, you would need to add the bold lines from the previous listing around them. The final version of code in MonoDevelop would be identical to the first code listing in this chapter.

The remainder of the code listings in this chapter will arbitrarily start line numbers at 6 to indicate that other lines would need to precede and follow them in a full C# script.


Function Parameters and Arguments

Some functions are called with empty parentheses following them (for example, CountUpdates() in the first code listing). Other functions can be passed information in between the parentheses (for example, Say("Hello") in the following listing). When a function is designed to receive outside information via the parentheses like this, the type of information is specified by one or more parameters that create a local function variable (with a specific type) to hold the information. In line 10 of the following code listing, void Say( string sayThis ) declares a parameter named sayThis that is of the string type. sayThis can then be used as a local variable within the Say() function.

When information is sent to a function via its parameters, it is referred to as passing information to the function. The information passed is called an argument. In line 7 of the following listing, the function Say() is called with the argument "Hello". Another way to say this is that"Hello" is passed to the function Say(). The argument passed to a function must match the parameters of the function, or it will cause an error.

6 void Awake() {
7 Say("Hello"); // 2
8 }
9
10 void Say( string sayThis ) { // 1
11 print(sayThis);
12 }

1. The string sayThis is declared as a parameter variable of the function Say().

2. When Say() is called by line 7, the string literal "Hello" is passed into the function Say() as an argument, and line 10 then sets the value of sayThis to "Hello".

In the function Say() in the previous listing, we’ve added a single parameter named sayThis. Just as with any other variable declaration, the first word is the variable type (string) and the second is the name of the variable (sayThis).

Just like other local function variables, the parameter variables of a function disappear from memory as soon as the function is complete; if the parameter sayThis were used anywhere in the Awake() function, it would cause a compiler error due to sayThis being undefined outside of the scope of the function Say().

In line 7 of the previous code listing, the argument passed into the function is the string literal "Hello", but any kind of variable or literal can be passed into a function as an argument as long as it matches the parameter(s) of the function (for example, line 7 of the following code listing, passes this.gameObject as an argument to the function PrintGameObjectName()). If a function has multiple parameters, arguments passed to it should be separated by commas (see line 8 in the following code listing).

6 void Awake() {
7 PrintGameObjectName( this.gameObject );
8 SetColor( Color.red, this.gameObject );
9 }
10
11 void PrintGameObjectName( GameObject go ) {
12 print( go.name );
13 }
14
15 void SetColor( Color c, GameObject go ) {
16 Renderer r = go.renderer;
17 r.material.color = c;
18 }

Returning Values

In addition to receiving values as parameters, functions can also return back a single value, known as the result of the function as shown on line 13 of the following code listing.

6 void Awake() {
7 int num = Add( 2, 5 );
8 print( num ); // Prints the number 7 to the Console
9 }
10
11 int Add( int numA, int numB ) {
12 int sum = numA + numB;
13 return( sum );
14 }

In this example, the function Add() has two parameters, the integers numA and numB. When called, it will sum the two integer arguments that were passed in and then return the result. The int at the beginning of the function definition declares that Add() will be returning an integer as its result. Just as you must declare the type of any variable for it to be useful, you must also declare the return type of a function for it to be used elsewhere in code.

Returning void

Most of the functions that we’ve written so far have had a return type of void, which means no value can be returned. Though these functions don’t return a specific value, there are still times that you might want to call return within them.

Any time return is used within a function, it stops execution of the function and returns execution back to the line from which the function was called. (For example, the return on line 16 of the following code listing returns execution back to line 9.)

It is sometimes useful to return from a function to avoid the remainder of the function. For example, if you had a list of over 100,000 GameObjects (e.g., reallyLongList in the following code listing), and you wanted to move the GameObject named “Phil” to the origin (Vector3.zero), but didn’t care about doing anything else, you could write this function:

6 public List<GameObject> reallyLongList; // Defined in the Unity Editor // 1
7
8 void Awake() {
9 MoveToOrigin("Phil"); // 2
10 }
11
12 void MoveToOrigin(string theName) { // 3
13 foreach (GameObject go in reallyLongList) {
14 if (go.name == theName) { // 4
15 go.transform.position = Vector3.zero; // 5
16 return; // 6
17 }
18 }
19 }

1. List<GameObject> reallyLongList is a very long list of GameObjects that we are imagining has been predefined in the Unity Inspector. Because we must imagine this predefined List for this example, entering this code into Unity would not work unless you definedreallyLongList yourself.

2. The function MoveToOrigin() is called with the string literal "Phil" as its argument.

3. The foreach statement iterates over reallyLongList.

4. If a GameObject with the name “Phil” is found...

5. ...then it is moved to the origin, which is the position [0,0,0].

6. Line 16 returns execution to line 9. This avoids iterating over the rest of the List.

In MoveToOrigin(), you really don’t care about checking the other GameObjects after you’ve found the one named Phil, so it is better to short circuit the function and return before wasting computing power on doing so. If Phil is the last GameObject in the list, you haven’t saved any time, however, if Phil is the first GameObject, you have saved a lot.

Note that when return is used in a function with the void return type, it does not require parentheses.

Proper Function Names

As you’ll recall, variable names should be sufficiently descriptive, start with a lowercase letter, and use camel caps (uppercase letters at each word break). For example:

int numEnemies;
float radiusOfPlanet;
Color colorAlert;
string playerName;

Function names are similar; however, function names should all start with a capital letter so that they are easy to differentiate from the variables in your code. Here are some good function names:

void ColorAGameObject( GameObject go, Color c ) {...}
void AlignX( GameObject go0, GameObject go1, GameObject go2 ) {...}
void AlignListX( List<GameObject> goList ) {...}
void SetX( GameObject go, float eX ) {...}

When Should You Use Functions?

Functions are a perfect method for encapsulating code and functionality in a reusable form. Generally, any time that you would write the same lines of code more than a couple of times, it’s good style to define a function to do so instead. Let’s start with a code listing that has some repeated code in it.

The function AlignX() in the following code listing takes three GameObjects as parameters, averages their position in the X direction, and sets them all to that average X position:

6 void AlignX( GameObject go0, GameObject go1, GameObject go2 ) {
7 float avgX = go0.transform.position.x;
8 avgX += go1.transform.position.x;
9 avgX += go2.transform.position.x;
10 avgX = avgX/3.0f;
11 Vector3 tempPos;
12 tempPos = go0.transform.position; // 1
13 tempPos.x = avgX; // 1
14 go0.transform.position = tempPos; // 1
15 tempPos = go1.transform.position;
16 tempPos.x = avgX;
17 go1.transform.position = tempPos;
18 tempPos = go2.transform.position;
19 tempPos.x = avgX;
20 go2.transform.position = tempPos;
21 }

1. In lines 12-14, you can see how we handle Unity’s restriction that does not allow you to directly set the position.x of a transform. Instead, you must first assign the current position to another variable (for example, Vector3 tempPos), then change the x value of that variable, and finally copy the whole Vector3 back onto transform.position. This is very tedious to write repeatedly (as demonstrated on lines 12-20), which is why it should be replaced by the SetX() function shown in the next code listing. The SetX() function in that listing enables you to set the x position of a transform in a single step (e.g., SetX( this.gameObject, 25.0f ) ).

Because of the limitations on directly setting an x, y, or z value of the transform.position, there is a lot of repeated code on lines 12 through 20 of the AlignX() function. Typing that can be very tedious, and if anything needs to be changed later, it would necessitate repeating the same change three times. This is one of the main reasons for writing functions. In the following code listing, the repetitive lines have been replaced by calls to a new function, SetX(). The bold lines have been altered from the previous code listing.

6 void AlignX( GameObject go0, GameObject go1, GameObject go2 ) {
7 float avgX = go0.transform.position.x;
8 avgX += go1.transform.position.x;
9 avgX += go2.transform.position.x;
10 avgX = avgX/3.0f;
11 SetX ( go0, avgX );
12 SetX ( go1, avgX );
13 SetX ( go2, avgX );
14 }
15
16 void SetX( GameObject go, float eX ) {
17 Vector3 tempPos = go.transform.position;
18 tempPos.x = eX;
19 go.transform.position = tempPos;
20 }

In this improved code listing, the ten lines from 11 to 20 in the previous code have been replaced by the definition of a new function SetX() (lines 16-20) and three calls to it (lines 11-13). If anything needed to change about how we were setting the x value, it would only require making a change once to SetX() rather than making the change three times in the prior code listing. Though this is a simple example, I hope it serves to demonstrate the power that functions allow us as programmers.

The remainder of this chapter covers some more complex and interesting ways to write functions in C#.

Function Overloading

Function overloading is a fancy term for the capability of functions in C# to act differently based upon the type and number of parameters that are passed into them. The bold sections of the following code demonstrate function overloading.

6 void Awake() {
7 print( Add( 1.0f, 2.5f ) );
8 // ^ Prints: "3.5"
9 print( Add( new Vector3(1, 0, 0), new Vector3(0, 1, 0) ) );
10 // ^ Prints "(1.0, 1.0, 0.0)"
11 Color colorA = new Color( 0.5f, 1, 0, 1);
12 Color colorB = new Color( 0.25f, 0.33f, 0, 1);
13 print( Add( colorA, colorB ) );
14 // ^ Prints "RGBA(0.750, 1.000, 0.000, 1.000)"
15 }
16
17 float Add( float f0, float f1 ) { // 1
18 return( f0 + f1 );
19 }
20
21 Vector3 Add( Vector3 v0, Vector3 v1 ) { // 1
22 return( v0 + v1 );
23 }
24
25 Color Add( Color c0, Color c1 ) { // 1
26 float r, g, b, a;
27 r = Mathf.Min( c0.r + c1.r, 1.0f ); // 2
28 g = Mathf.Min( c0.g + c1.g, 1.0f ); // 2
29 b = Mathf.Min( c0.b + c1.b, 1.0f ); // 2
30 a = Mathf.Min( c0.a + c1.a, 1.0f ); // 2
31 return( new Color( r, g, b, a ) );
32 }

1. There are three different Add() functions in this listing, and each is called based on the parameters passed in by various lines of the Awake() function. When two floating-point numbers are passed in, the float version of Add() is used; when two Vector3s are passed in, the Vector3 version is used; and when two Colors are passed in, the Color version is used.

2. In the Color version of Add(), care is taken to not allow r, g, b, or a to exceed 1 because the red, green, blue, and alpha channels of a color are limited to values between 0 and 1. This is done through the use of the Mathf.Min() function. Mathf.Min() takes any number of arguments as parameters and returns the one with the minimum value. In the previous listing, if the summed reds are equal to 0.75f, then 0.75f will be returned in the red channel; however, if the greens were to sum to any number greater than 1.0f, a green value of 1.0f will be returned instead.

Optional Parameters

There are times when you want a function to have optional parameters that may either be passed in or omitted:

6 void Awake() {
7 SetX( this.gameObject, 25 ); // 2
8 print( this.gameObject.transform.position.x ); // Outputs: "25"
9 SetX( this.gameObject ); // 3
10 print( this.gameObject.transform.position.x ); // Outputs: "0"
11 }
12
13 void SetX( GameObject go, float eX=0.0f ) { // 1
14 Vector3 tempPos = go.transform.position;
15 tempPos.x = eX;
16 go.transform.position = tempPos;
17 }

1. The float eX is defined as an optional parameter with a default value of 0.0f.

2. Because a float can hold any integer value,1 it is perfectly fine to pass an int into a float. (For example, the integer literal 25 on line 7 is passed into the float eX on line 13.)

1 To be more precise, a float can hold most int values. As was described in Chapter 19, “Variables and Components,” floats get somewhat inaccurate for very big and very small numbers, so a very large int might be rounded to the nearest number that a float can represent. Based on an experiment I ran in Unity, a float seems to be able to represent every whole number up to 16,777,217 after which it will lose accuracy.

3. Because the float eX parameter is optional, it is not required, as is shown on line 9.

In this version of the SetX() function, float eX is an optional parameter. If you give a parameter a default value in the definition of the function, the compiler will interpret that parameter as optional (for example, line 13 in the code listing where the float eX is given a default value of 0.0f).

The first time it’s called from Awake(), the eX parameter is set to 25.0f, which overrides the default of 0.0f. However, the second time it’s called, the eX parameter is omitted, leaving eX to default to a value of 0.0f.

Optional parameters must come after any required parameters in the function definition.

The params Keyword

The params keyword can be used to make a function accept any number of parameters of the same type. These parameters are converted into an array of that type.

6 void Awake() {
7 print( Add( 1 ) ); // Outputs: "1"
8 print( Add( 1, 2 ) ); // Outputs: "3"
9 print( Add( 1, 2, 3 ) ); // Outputs: "6"
10 print( Add( 1, 2, 3, 4 ) ); // Outputs: "10"
11 }
12
13 int Add( params int[] ints ) {
14 int sum = 0;
15 foreach (int i in ints) {
16 sum += i;
17 }
18 return( sum );
19 }

Add() can now accept any number of integers and return their sum. As with optional parameters, the params list needs to come after any other parameters in your function definition (meaning that you can have other required parameters before the params list).

This also allows us to rewrite the AlignX() function from before to take any number of possible GameObjects as is demonstrated in the following code listing.

6 void AlignX( params GameObject[] goArray ) { // 1
7 float sumX = 0;
8 foreach (GameObject go in goArray) { // 2
9 sumX += go.transform.position.x; // 3
10 }
11 float avgX = sumX / goArray.Length; // 4
12
13 foreach (GameObject go in goArray) { // 5
14 SetX ( go, avgX );
15 }
16 }
17
18 void SetX( GameObject go, float eX ) {
19 Vector3 tempPos = go.transform.position;
20 tempPos.x = eX;
21 go.transform.position = tempPos;
22 }

1. The params keyword creates an array of GameObjects from any GameObjects passed in.

2. foreach can iterate over every GameObject in goArray. The GameObject go variable is scoped to the foreach loop, so it does not conflict with the GameObject go variable in the foreach loop on lines 13-15.

3. The X position of the current GameObject is added to sumX.

4. The average X position is found by dividing the sum of all X positions by the number of GameObjects.

5. Another foreach loop iterates over all the GameObjects in goArray and calls SetX() with each GameObject as a parameter.

Recursive Functions

Sometimes a function is designed to call itself repeatedly, this is known as a recursive function. One simple example of this is calculating the factorial of a number.

In math, 5! (5 factorial) is the multiplication of that number and every other natural number below it. (Natural numbers are the integers greater than 0.)

5! = 5 * 4 * 3 * 2 * 1 = 120

It is a special case that 0!=1, and the factorial of a negative number will be 0 for our purposes:

0! = 1

We can write a recursive function to calculate the factorial of any integer:

6 void Awake() {
7 print( Fac(-1) ); // Outputs: "0"
8 print( Fac(0) ); // Outputs: "1"
9 print( Fac(5) ); // Outputs: "120"
10 }
11
12 int Fac( int n ) {
13 if (n < 0) { // This handles the case if n<0
14 return( 0 );
15 }
16 if (n == 0) { // This is the "terminal case"
17 return( 1 );
18 }
19 int result = n * Fac( n-1 );
20 return( result );
21 }

When Fac(5) is called (line 9), and the code reaches the 19th line, Fac() is called again with a parameter of n-1 (which is 4) in a process called recursion. This recursion continues with Fac() called four more times until it reaches the case where Fac(0) is called. Because n is equal to 0, this hits the terminal case on line 16, which returns 1. The 1 is passed back up to line 19 of the previous recursion and multiplied by n, the result of which is passed back up again until all the recursions of Fac() have finished, and it eventually returns the value 120 to line 9 (where it is printed). The chain of all these recursive Fac() calls works something like this:

Fac(5)
5 * Fac(4)
5 * 4 * Fac(3)
5 * 4 * 3 * Fac(2)
5 * 4 * 3 * 2 * Fac(1)
5 * 4 * 3 * 2 * 1 * Fac(0)
5 * 4 * 3 * 2 * 1 * 1
5 * 4 * 3 * 2 * 1
5 * 4 * 3 * 2
5 * 4 * 6
5 * 24
120

The best way to really understand what’s happening in this recursive function is to explore it using the debugger, a feature in MonoDevelop that enables you to watch each step of the execution of your programs and see how different variables are affected by your code. The process of debugging is the topic of the next chapter.

Summary

In this chapter, you have seen the power of functions and many different ways that you can use them. Functions are a cornerstone of any modern programming language, and the more programming you do, the more you will see how powerful and necessary they are.

Chapter 24, “Debugging,” shows you how to use the debugging tools in Unity. These tools are meant to help you find problems with your code, but they are also very useful for understanding how your code works. After you have learned about debugging from the next chapter, I recommend returning to this chapter and examining the Fac() function in more detail. And, of course, feel free to use the debugger to explore and better understand any of the functions in this chapter or others.