Research in the field of artificial characters has shown that they must be believable, offer interactions that don’t make humans uncomfortable (i.e., interactions must not be stilted, unnatural, or strange), and be capable of real-time interactions. In this section, we will begin to build the ability to offer comfortable interactions in our characters.
Since you are reading this book, Valued Reader, I’m fairly confident that you are at least familiar with Masahiro Mori’s concept of the Uncanny Valley. For those who are not, Mori proposed that if some artificial entity is “close to human” in terms of appearance and motion, the non–human-like characteristics of the construct will stand out and create a feeling of overwhelming strangeness. Not many, however, are familiar with the converse of this argument—that is, that the more non-human a construct appears to be, the more its human-like characteristics will stand-out, thus creating a feeling of familiarity (or comfort). Further, the gap widens when these constructs move. Think about that for a moment …. The more realism in your character’s representation, the less familiar the character will feel, and the more alien or cartoony your character’s representation, the more human he will feel! That sounds like some principle that would have a really long name….
Very few people are familiar with the Freudian basis for Mori’s work. Summarizing liberally, Freud’s version of uncanny surmises that what seems strange or taboo reminds us of our own id (or our repressed, animal instincts), which is viewed as a threat by our super-ego (no doubt fearing punishment for deviating from societal norms, according to Freud). Yeah.
But wait! There’s even more, Valued Reader. Much of Freud’s work in this area was fueled by the work of a German Romantic writer named E.T.A. Hoffman, who wrote fantasy and horror (and other genres) way back in the early 1800s. Hoffman’s horror work tended to focus on uncanny events, a practice that was first reviewed by a dude named Ernst Jentsch. Jentsch believed the uncanny revolved around doubts as to whether something seemingly animate was really alive, or whether some seemingly inanimate object might really be animate—in other words, fear of dead things or reanimated dead things. Ahem. Zombies.
Casting these three ideas about uncomfortable artificial characters into video-game terms, we can see that Mori’s theories deal more with the look and movements of the character (e.g., modeling fidelity and animation fidelity); that Freud’s theories would be more concerned with characters “acting” or “behaving right,” where “right” is defined by the cultural norm (e.g., animation fidelity and cognitive fidelity); and that Jentsch’s ideas are more concerned with avoiding the appearance of being dead or an inanimate object brought to some semblance of life, which, it can be argued, encompasses characters that look, move, act, and feel right (e.g., modeling fidelity, animation fidelity, cognitive fidelity, and emotional fidelity).
Whichever interpretation of uncanniness you buy into, it is something that we must actively keep in mind as we develop game characters. Any character that moves strangely, acts strangely, or unintentionally gives us the willies will cause one of those mental speed bumps that jars us out of immersion and engagement. One of the most common failings in this regard in the game industry is the practice of releasing a game chock-a-block with zombies. Now, before you go insane, Valued Reader, I’m not criticizing any zombie-killing games (although I do have a few words to say about jumping on the formulaic bandwagon that seems to pervade zombie, ninja, and pirate titles).
From this point on, let’s define a zombie as a video-game character that just plain moves wrong and, in so doing, gives us the heebie-jeebies or breaks engagement or immersion. To continue this discussion, I need to introduce two concepts:
Modeling fidelity: Modeling fidelity is the quality of shape evidenced by the character. This does not imply that the highest level of realism is required. On the contrary, the appropriate level of detail with respect to the game’s artistic look and feel and animation fidelity is required.
Animation fidelity: Animation fidelity is the quality of movement expressed by the character. Again, this does not imply that realistic, life-like movement is required. In fact, life-like movement might hinder your success, even if you get it right (which is a really big if). Further, the principle of amplification through simplification also means that players will feel more fidelity with iconic animations rather than realistic animations.
With these two definitions in mind, we can say that zombies are any video game characters whose animation fidelity is lower than their modeling fidelity. (I’d love to take credit for this piece of wisdom, but I owe my knowledge of this to Glenn Entis, who was the chief vision officer at Electronic Arts when I worked there.) Let’s face it: We are seeing more and more games that have hyper-realistic—even photo-realistic—models and textures but, relatively speaking, horrible animations. The fact that these characters look so good while standing still is made moot the first time they move, simply because the animations aren’t worthy of the modeling and texturing.
What in the world am I blathering on about now? Glad you asked, Valued Reader. So far, our 2D sprite-base game has characters that look okay, but basically animate by sliding around. In other words, our characters have an okay level of modeling fidelity and a horribly low level animation fidelity.
Let’s address this stepwise (as if I do things any other way). First, we need to increase the modeling fidelity a bit. I mean, c’mon—these guys don’t even have eyeballs! To that end, let’s take a peek at some high-quality art work. Figure 5.1 represents our first stab at adding eyes to these little creatures. Figures 5.2 through 5.7 represent a few basic expressions we might see plastered on the face of vaguely froglike creatures from space. The expressions shown are actually a composite of the eye images plus a mouth expression image.
Now, adding this stuff to our game is blisteringly easy. First, we need to create a new sprite class to handle the eyes. It’s true that we could just make a simple eye texture like what is shown in Figure 5.1, but as you will soon see, this isn’t a great idea for our purposes. This class (Character/Sprite/EyeSprite.cs) is pretty simple (but will get a little more complicated as we go). It will contain two of almost everything, as it will house both the eyeball texture (Content/Character/SumaEyeBall.png) and the pupil texture (Content/Character/SumaPupil.png). Here’s the code:
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework; namespace _2DSpriteGame.Character.Sprite { /// <summary> /// An implementation of a 2D sprite, specified for character eye objects /// </summary> public class EyeSprite { #region members private Texture2D eyeTexture; //the eyeball texture to display private Texture2D pupilTexture; //the pupil texture to display private Rectangle ballSrcRectangle; //a rectangle used in our Draw() call that represents where inside the texture to copy the pixels from private Rectangle pupSrcRectangle; //a rectangle used in our Draw() call that represents where inside the texture to copy the pixels from private Vector2 position; //a point that represents the upper-left corner of the eyeball sprite private Vector2 pupilPos; //a point that represents the upper-left corner of the pupil sprite private Vector2 ballOffset; //the relative position of the eyeball
private Vector2 pupilOffset; //the relative position of the pupil private Vector2 lookatPos; //a point that represents where the eye should be looking private float scale; //a scale percentage for the overall eye private float hscale; //a scale percentage for the height of the eye private float scaler; #endregion #region properties public Texture2D Texture { get { return eyeTexture; } } public Vector2 Position { get { return position; } set { position = new Vector2(value.X + ballOffset.X, value.Y + ballOffset.Y); pupilPos = new Vector2(value.X + pupilOffset.X, value.Y + pupilOffset.Y); } } public float Scale { get { return scale; } set { scale = value; } } public float HScale { get { return hscale; } set { hscale = value; } } public Vector2 LookTarget { get { return lookatPos; } set { lookatPos = value; } } #endregion #region constructors / init
public EyeSprite(Texture2D et, Texture2D pt, Vector2 pos, Vector2 epos, Vector2 ppos, float sc) { eyeTexture = et; ballOffset = epos; pupilTexture = pt; pupilOffset = ppos; Position = pos; scale = sc; hscale = sc; scaler = sc; lookatPos = Vector2.Zero; ballSrcRectangle = new Rectangle(0, 0, eyeTexture.Width, eyeTexture.Height); pupSrcRectangle = new Rectangle(0, 0, pupilTexture.Width, pupilTexture.Height); } #endregion #region utilities public void Update(GameTime gameTime) { if (lookatPos == Vector2.Zero) { return; } } public void Draw(SpriteBatch sb) { sb.Draw(eyeTexture, position, ballSrcRectangle, Color.White, of, Vector2.Zero, scaler, SpriteEffects.None, 0); sb.Draw(pupilTexture, pupilPos, pupSrcRectangle, Color.White, of, Vector2.Zero, scaler, SpriteEffects.None, 0); } #endregion } }
Yeah, I added an update method, but we’ll get back to it later. For now, the test at the beginning of the Update()
will always evaluate true, but later on we will add some code that makes that untrue.
Next, add the following members to the Character.cs class:
private Sprite.CharacterSprite mouth; private Sprite.CharacterSprite mouthHappy; private Sprite.CharacterSprite mouthSad; private Sprite.CharacterSprite mouthConfused; private Sprite.CharacterSprite mouthBored; private Sprite.CharacterSprite mouthAngry; private Sprite.CharacterSprite mouthDisgusted; private Sprite.EyeSprite leye; private Sprite.EyeSprite reye; //position constants private Vector2 lEyePos = new Vector2(33f, 14f); private Vector2 lPupCent = new Vector2(36f, 15f); private Vector2 rEyePos = new Vector2(57f, 14f); private Vector2 rPupCent = new Vector2(60f, 15f);
We need to change the constructor slightly to allow us to load the content for these once (in the CharacterManager
class):
public Character(Texture2D tex, Texture2D mask, Texture2D eball, Texture2D pupil, Texture2D[] mouths, Vector2 p, float s, float spd, Color bColor, Color bMaskColor, String n) { pos = p; scale = s; speed = spd; bodyColor = bColor; bodyMaskColor = bMaskColor; drawMask = false; name = n; idleBodyFixed = new Sprite.CharacterSprite(tex, pos, scale); idleBodyFixed.Tint = bodyColor; idleBodyFixedMask = new Sprite.CharacterSprite(mask, pos, scale); idleBodyFixedMask.Tint = bodyMaskColor; mouthHappy = new Sprite.CharacterSprite(mouths[0], pos, scale); mouthSad = new Sprite.CharacterSprite(mouths[1], pos, scale); mouthConfused = new Sprite.CharacterSprite(mouths[2], pos, scale); mouthBored = new Sprite.CharacterSprite(mouths[3], pos, scale);
mouthAngry = new Sprite.CharacterSprite(mouths[4], pos, scale); mouthDisgusted = new Sprite.CharacterSprite(mouths[5], pos, scale); leye = new Sprite.EyeSprite(eball, pupil, pos, lEyePos, lPupCent, 1.of); reye = new Sprite.EyeSprite(eball, pupil, pos, rEyePos, rPupCent, 1.of); //for testing!! mouth = mouthBored; path = new Logic.CharacterPath(); bBox = new Rectangle((int)pos.X + x, (int)pos.Y + y, w, h); nameSprite = new GUI.ScreenText(); isSleeping = false; fsm = new Logic.CharacterFSM(this); }
We will also need a specialized accessor method for setting the expression of the character:
/// <summary> /// Changes the expression sprites. 0 = happy, 1 = sad, 2 = confused, 3 = bored, /// 4 = angry, 5 = disgusted /// </summary> /// <param name="m"></param> public void SetExpression(int m) { switch (m) { case 0: mouth = mouthHappy; break; case 1: mouth = mouthSad; break; case 2: mouth = mouthConfused; break; case 3: mouth = mouthBored; break; case 4: mouth = mouthAngry; break;
case 5: mouth = mouthDisgusted; break; default: break; } mouth.Position = pos; }
Finally, we need to make changes in both the Update()
and Draw()
methods, like so:
/// <summary> /// Performs update processing on the character's position, logical components, /// and sprite assets. /// </summary> /// <param name="gameTime"></param> public void Update(GameTime gameTime) { if (isSleeping) { return; } path.Update(pos); nameSprite.RemoveNewest(); nameSprite.Print(name, new Vector2(pos.X + 32, pos.Y - 12), Color.Snow, (int)GUI.ScreenText.FontName.DEFAULT); if (path.Waypoint == Vector2.Zero) return; //compute the size of the next step Vector2 step = Vector2.Normalize(Vector2.Subtract(pos, path.Waypoint)) * speed * (float)gameTime.ElapsedGameTime.TotalSeconds; //if we are not within our "there" range, if ((Math.Max(2.0f, Math.Abs(pos.X - path.Waypoint.X)) > 2.0f) || (Math.Max(2.0f, Math.Abs(pos.Y - path.Waypoint.Y)) > 2.0f)) { pos -= step; }
idleBodyFixed.Position = pos; idleBodyFixedMask.Position = pos; mouth.Position = pos; leye.Position = pos; leye.Update(gameTime); reye.Position = pos; reye.Update(gameTime); bBox.X = (int)pos.X + x; bBox.Y = (int)pos.Y + y; } /// <summary> /// Draws the sprite assets of the character in the correct order when appropriate. /// </summary> /// <param name="sb"></param> public void Draw(SpriteBatch sb) { if (drawMask) { idleBodyFixedMask.Draw(sb); } idleBodyFixed.Draw(sb); mouth.Draw(sb); leye.Draw(sb); reye.Draw(sb); nameSprite.Draw(sb); }
It is very important in the EyeSprite
cases that the EyeSprite.Position
is set prior to calling the EyeSprite.Update()
method (not so much now, since that method just returns, but soon, Valued Reader, soon).
In the CharacterManager
, we just need to change the LoadContent()
method to accommodate the new Character
constructor. Here’s what I did:
public void LoadContent(ContentManager Content) { names[0] = "Xret"; names[1] = "Biix"; names[2] = "Laka"; names[3] = "Potr"; names[4] = "Mati"; names[5] = "Dibu"; names[6] = "Fhet";
names[7] = "Raas"; names[8] = "Tyeu"; names[9] = "Skao"; int y = rand.Next(248) + 410; //the walkable y-space in our environment int x = rand.Next(904) + 10; //the walkable x-space in our environment Color sumaColor = new Color(0,240, 75, 255); float spd = 150f; Texture2D[] m = new Texture2D[6]; m[0] = Content.Load<Texture2D>("Character/SumaHappyMouth"); m[1] = Content.Load<Texture2D>("Character/SumaSadMouth"); m[2] = Content.Load<Texture2D>("Character/SumaConfusedMouth"); m[3] = Content.Load<Texture2D>("Character/SumaBoredMouth"); m[4] = Content.Load<Texture2D>("Character/SumaAngryMouth"); m[5] = Content.Load<Texture2D>("Character/SumaDisgustedMouth"); Texture2D bodyFixed = Content.Load<Texture2D>("Character/SumaFixed"); Texture2D bodyGM = Content.Load<Texture2D>("Character/SumaFixedGlowMask"); Texture2D eyeBall = Content.Load<Texture2D>("Character/SumaEyeBall"); Texture2D pupil = Content.Load<Texture2D>("Character/SumaPupil"); for (int i = 0; i < NUM_TOONS; i++) { toons[i] = new Character( bodyFixed, bodyGM, eyeBall, pupil, m, new Vector2(x, y), 1.of, spd, sumaColor, Color.GhostWhite, names[i]); toons[i].LoadContent(Content); y = rand.Next(248) + 410; //the walkable y-space in our environment x = rand.Next(904) + 10; //the walkable x-space in our environment } }
I’ve also made some changes in the states for the character FSM. (We built FSMs into the game in Chapter 4, “Indirect Control in a 2D Sprite-Based Game,” if you are reading out of order.) Basically, I just called the appropriate (in my opinion) Character.SetExpression()
method for each state. For example, I wanted dancing to be a happy-time event (for now), so I changed the DanceState.Action()
method (recall that all of the state classes can be found in Character/Logic/CharacterFSMStates) like so:
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { //give a happy expression self.SetExpression(0);
The rest of the method is completely unchanged. This will work for now if each state’s action method also changes the expression appropriately (or even in a silly manner for now).
Run it and check it out. Remember that everyone will be disgusted with you until you give them something to do (because they are in the stop state). Our little dudes now have faces (kinda), but modeling and animation guru Jason Osipa (and author of Stop Staring: Facial Modeling and Animation Done Right) will still want to yell at us quite a bit, because these guys just stare and stare and stare at us. They don’t blink, they don’t look anywhere but straight at the player; in fact, they look pretty uncanny, despite the change in mouth expression.
Before we can move on, we need to fix that. Here’s how we will do it: First, we need to give each character something to look at. What can he look at? Moving characters can simply look at their waypoints. Characters that are dancing, chatting, or fighting can look at each other. We can also spice things up by making stopped characters stare at the mouse point or at the player (screen), look at other players, or some combination of these options.
The natural place to put these routines is in the state Action()
methods themselves. To make that work, however, we need to add the following property to the Character
class:
public Vector2 LookAt
{ get { return leye.LookTarget; } set { leye.LookTarget = value; reye.LookTarget = value; } }
Yes, we are setting two properties (one in each EyeSprite
) with one Character
property, but the getter only returns the left EyeSprite.LookTarget
value. That’s because we never set one without setting the other, and that ensures that they are always the same value.
Let’s start simple and set the LookAt
inside the MoveState.Action()
method. It’s a simple one-line change (bold-faced below):
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { if (targetLoc != Vector2.Zero) { //give a bored expression self.SetExpression(3); self.Waypoint = targetLoc; targetLoc = Vector2.Zero; //give the character something to look at self.LookAt = self.Waypoint; } }
We can add the very same line to the RandomWalkState.Action()
method and the DanceState.Action()
method, right after the waypoint is set. The other states will require something different, but in some cases, it will be pretty simple. For example, we can do almost the exact same thing in the ChatState.Action()
method, but we will point the LookAt
property at the other character participating, like this (changes are in bold):
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { if (target.FSM.CurrentState.Identifier != "CHAT" && init.Name.Equals (self.Name)) { target.FSM.Token = "CHAT"; target.FSM.ExecuteTransition(); target.FSM.CurrentState.Target = self; (target.FSM.CurrentState as CharacterFSMStates.ChatState). InitCharacter = self; } //give a happy expression self.SetExpression(0); //look at the other interactor self.LookAt = target.Position;
ForgetState
is so transitory that it doesn’t need any changes (we might add some spinning eyes later if need be). In the GoToState
, the ChaseState
, and the EvadeState
actions, we will add the same line as above.
In StopState
, we want to mix it up a bit, and in a way that conveys boredom. To do that, check out this code:
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { self.ClearWaypoints(); //give a disgusted expression self.SetExpression(5); //every so often deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if (deltaT > INTERVAL)
{ int r = rand.Next(100); //watch the mouse self.LookAt = new Vector2(Game1.GameStateMan.MHandler.LEFT, Game1.GameStateMan.MHandler.TOP); //or… if (r < 30) { //watch a random character int i = rand.Next(Game1.GameStateMan.CharManager.ToonCount); if (i == Game1.GameStateMan.CharManager.GetToonId(self)) { if (i == 0) { i++; } else { i- -; } } self.LookAt = Game1.GameStateMan.CharManager.GetToon(i).Position; } else if ( r < 60 ) { //stare at the player self.ResetEyes(gameTime); } deltaT = 0; } }
What we are doing here is setting the LookAt
property to look at the mouse position and then every so often deciding randomly to keep looking at the mouse or to switch to staring at a player or another character picked at random.
The next state I want to deal with is SleepState
; in that state, the character’s eyes should close. No problem, though, because we were thinking ahead! Remember that height scale attribute we put in the EyeSprite
class? It’s time to use it. Using it is super easy, too….
public void Update(GameTime gameTime) { if (hscale < scale) { ballSrcRectangle = new Rectangle(0, 0, eyeTexture.Width, (int) (eyeTexture.Height*hscale)); pupSrcRectangle = new Rectangle(0, 0, pupilTexture.Width, (int) (pupilTexture.Height*hscale)); }
Yep, that’s all we need to do from inside the EyeSprite
class to apply the changes in height. That will allow us to open and close characters’ eyes—if we add these six methods to Character.cs:
/// <summary> /// Cause the left eye to close (by scaling the height of the image to zero) /// </summary> /// <returns>True when the HScale property is 0</returns> public Boolean CloseLeftEye() { if ( leye.HScale > 0 ) { leye.HScale -= 0.05f; return false; } leye.HScale = 0f; return true; } /// <summary> /// Cause the right eye to close (by scaling the height of the image to zero) /// </summary> /// <returns>True when the HScale property is 0</returns> public Boolean CloseRightEye() { if ( reye.HScale > 0 ) { reye.HScale -= 0.05f; return false; } reye.HScale = 0f; return true;
} /// <summary> /// Cause both eyes to close (by scaling the height of the image to zero) /// </summary> /// <returns>True when the HScale property is 0</returns> public Boolean CloseEyes() { Boolean l = CloseLeftEye(); Boolean r = CloseRightEye(); return (l && r); } /// <summary> /// Cause the left eye to open (by scaling the height of the image to one) /// </summary> /// <returns>True when the HScale property is 1</returns> public Boolean OpenLeftEye() { if (leye.HScale < 1) { leye.HScale += 0.05f; return false; } leye.HScale = 1f; return true; } /// <summary> /// Cause the right eye to open (by scaling the height of the image to one) /// </summary> /// <returns>True when the HScale property is 1</returns> public Boolean OpenRightEye() { if (reye.HScale < 1) { reye.HScale += 0.05f; return false; } reye.HScale = 1f; return true; } /// <summary> /// Cause both eyes to open (by scaling the height of the image to one) /// </summary>
/// <returns>True when the HScale property is 1</returns> public Boolean OpenEyes() { Boolean l = OpenLeftEye(); Boolean r = OpenRightEye(); return (l && r); }
As you can see, all we are really doing here is ramping down the HScale
property in each EyeSprite
class to 0 when we want to close the eye, and back up to 1 when we want to open the eye. However, to make this look right, we need to call these methods in the correct way.
To cause the character to close his eyes for sleeping, we can do this in the Update()
method:
/// <summary> /// Performs update processing on the character's position, logical components, /// and sprite assets. /// </summary> /// <param name="gameTime"></param> public void Update(GameTime gameTime) { if (isSleeping) { CloseEyes(); leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; }
Nothing too fancy here; we’re just calling CloseEyes()
during every update cycle while isSleeping
is set to true. We don’t need to use the return from CloseEyes()
unless we take a huge performance hit (which we won’t in this game) from calling the checks in the two sub-methods every update.
To open the character’s eyes when he wakes up, we need to add another attribute to the Character
class—a Boolean called wasSleeping
. We will use this attribute like this:
/// <summary> /// Performs update processing on the character's position, logical components, /// and sprite assets. /// </summary>
/// <param name="gameTime"></param> public void Update(GameTime gameTime) { if (isSleeping) { CloseEyes(); leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; } else if ( wasSleeping ) { wasSleeping = ! OpenEyes(); if (!wasSleeping) { deltaT = 0; blinking = false; blinkClosed = false; } leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; }
Here we use the return value from OpenEyes
to control how long we will get into this code block (and to reset some stuff that will let our little guys blink!). Blinking is easy, given what we have here, so we might as well do that too.
We need a few attributes. Specifically, we need two constants, one that controls how fast a blink is (INTERVAL
) and one that controls how long we go between blinks (BINTERVAL
); two Booleans, for flagging the blink state (blinking
and blinkClosed
); and another time-counter gizmo called deltaT
. We will update deltaT
just like we did in the mouse animation, but with new and improved state testing.
//if we're not blinking… if ( ! blinking && ! blinkClosed) { deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds;
if ( deltaT > INTERVAL ) { if ( !blinking ) { blinkClosed = CloseEyes(); if ( blinkClosed ) { blinking = true; } } deltaT = 0f; } } else if (blinking && blinkClosed) { deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if ( deltaT > INTERVAL ) { blinking = ! OpenEyes(); deltaT = 0f; } } else if (!blinking) { deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if (deltaT > BINTERVAL) { blinkClosed = false; deltaT = 0f; } }
Of course, we don’t want every character in the game blinking at exactly the same time (as will happen if we were to run the code in its current state), so we need to change the constructor a bit to start each character in a random state. To accomplish this, we need the following changes (in bold):
public Character(Texture2D tex, Texture2D mask, Texture2D eball, Texture2D pupil, Texture2D[] mouths, Vector2 p, float s, float spd, Color bColor, Color bMaskColor, String n, float blinkOffset)
{ pos = p; scale = s; speed = spd; bodyColor = bColor; bodyMaskColor = bMaskColor; drawMask = false; name = n; idleBodyFixed = new Sprite.CharacterSprite(tex, pos, scale); idleBodyFixed.Tint = bodyColor; idleBodyFixedMask = new Sprite.CharacterSprite(mask, pos, scale); idleBodyFixedMask.Tint = bodyMaskColor; mouthHappy = new Sprite.CharacterSprite(mouths[0], pos, scale); mouthSad = new Sprite.CharacterSprite(mouths[1], pos, scale); mouthConfused = new Sprite.CharacterSprite(mouths[2], pos, scale); mouthBored = new Sprite.CharacterSprite(mouths[3], pos, scale); mouthAngry = new Sprite.CharacterSprite(mouths[4], pos, scale); mouthDisgusted = new Sprite.CharacterSprite(mouths[5], pos, scale); leye = new Sprite.EyeSprite(eball, pupil, pos, lEyePos, lPupCent, 1.of); reye = new Sprite.EyeSprite(eball, pupil, pos, rEyePos, rPupCent, 1.of); //for testing!! mouth = mouthBored; path = new Logic.CharacterPath(); bBox = new Rectangle((int)pos.X + x, (int)pos.Y + y, w, h); nameSprite = new GUI.ScreenText(); isSleeping = false; wasSleeping = false; blinking = false; blinkClosed = true; deltaT = blinkOffset; fsm = new Logic.CharacterFSM(this); }
What the blinkOffset
parameter does is enable us to give each new character some random amount of time toward starting his next blink cycle (provided, of course, that we update the CharacterManager.LoadContent()
class so that it passes in a non-zero value there …). Here’s the method, with changes in bold:
public void LoadContent(ContentManager Content)
{
names[0] = "Xret";
names[1] = "Biix";
names[2] = "Laka";
names[3] = "Potr";
names[4] = "Mati";
names[5] = "Dibu";
names[6] = "Fhet";
names[7] = "Raas";
names[8] = "Tyeu";
names[9] = "Skao";
int y = rand.Next(248) + 410; //the walkable y-space in our environment
int x = rand.Next(904) + 10; //the walkable x-space in our environment
Color sumaColor = new Color(0,240, 75, 255);
float spd = 150f;
float blink = (float)(rand.NextDouble())*1000f;
Texture2D[] m = new Texture2D[6];
m[0] = Content.Load<Texture2D>("Character/SumaHappyMouth");
m[1] = Content.Load<Texture2D>("Character/SumaSadMouth");
m[2] = Content.Load<Texture2D>("Character/SumaConfusedMouth");
m[3] = Content.Load<Texture2D>("Character/SumaBoredMouth");
m[4] = Content.Load<Texture2D>("Character/SumaAngryMouth");
m[5] = Content.Load<Texture2D>("Character/SumaDisgustedMouth");
Texture2D bodyFixed = Content.Load<Texture2D>("Character/SumaFixed");
Texture2D bodyGM = Content.Load<Texture2D>("Character/SumaFixedGlowMask");
Texture2D eyeBall = Content.Load<Texture2D>("Character/SumaEyeBall");
Texture2D pupil = Content.Load<Texture2D>("Character/SumaPupil");
for (int i = 0; i < NUM_TOONS; i++)
{
toons[i] = new Character( bodyFixed,
bodyGM,
eyeBall,
pupil,
m,
new Vector2(x, y),
1.of,
spd,
sumaColor,
Color.GhostWhite, names[i], blink); toons[i].LoadContent(Content); y = rand.Next(248) + 410; //the walkable y-space in our environment x = rand.Next(904) + 10; //the walkable x-space in our environment blink = (float)(rand.NextDouble()) * 1000f; } }
There you have it: magical blinking gizbots from outer space.
Now that we coded all these cases to accommodate eye movement, we need to have eyes that actually move—but we have no way to do that right now. Of course, there is good news: We’ve proactively made adding this functionality easy for ourselves. All we need to do is add two attributes to the EyeSprite
class (deltaX
and deltaY
) and then implement a very simple algorithm. We will just pretend the pupil of each eye is in a rectangle and move the pupil around inside that box (by comparing the pupil’s x and y to the target’s x and y, and then manipulating by 1 until the bounds are reached). Here’s the code for EyeSprite.cs:
public void Update(GameTime gameTime) { if (hscale < scale) { ballSrcRectangle = new Rectangle(0, 0, eyeTexture.Width, (int) (eyeTexture.Height*hscale)); pupSrcRectangle = new Rectangle(0, 0, pupilTexture.Width, (int) (pupilTexture.Height*hscale)); } if (lookatPos == Vector2.Zero) { deltaX = 0; deltaY = 0; return; } if (lookatPos.X < pupilPos.X && pupilPos.X >= position.X + 1f)
{ deltaX++; pupilPos.X -= deltaX; } else if (lookatPos.X > pupilPos.X && pupilPos.X <= position.X + 7f) { deltaX++; pupilPos.X += deltaX; } else { deltaX = 0; } if (lookatPos.Y < pupilPos.Y && pupilPos.Y >= position.Y + 1f) { deltaY++; pupilPos.Y -= deltaY; } else if (lookatPos.Y > pupilPos.Y && pupilPos.Y <= position.Y + 4f) { deltaY++; pupilPos.Y += deltaY; } else { deltaY = 0; } if (pupilPos.X < position.X + 1f) { pupilPos.X = position.X + 1f; } else if (pupilPos.X > position.X + 7f) { pupilPos.X = position.X + 7f; } if (pupilPos.Y < position.Y + 1f) { pupilPos.Y = position.Y + 1f;
} else if (pupilPos.Y > position.Y + 4f) { pupilPos.Y = position.Y + 4f; } }
As you can see, we just increment or decrement the pupil position each update cycle (as long as we have a target, that is) until the bounds are reached. If the bounds are exceeded, we just hard set the values back to the bounds. Simple, easy, likely to elicit a “BAM!” from Emeril….
Go ahead and run it (making sure you add the eye behaviors to the states we skipped above). It is interesting to contrast how much different the characters seem now—how much more alive, how much more comfortable to look at, all because of a few simple changes and because we gave them the ability to blink (even if the blink is alien, as ours is).
Yes, the algorithm we used makes our guys go a bit cross-eyed at times, but that’s how I wanted it. To eliminate that problem, we simply couple the two eyes together by using the x and y update calculations from one eye to update the second. However, I want the characters to do funny things at times, and later, I want to be able to get all procedural-animation-fu with the eyes.
I think we’ve finally gotten to a good place in terms of modeling fidelity, and that we’ve started to address some basic animations. Before we can get much more traction in this regard, we need to talk about and then implement sprite-sheet animation for this game.
In Chapter 3, “Building 2D Sprite-Based Games with Microsoft XNA 3.0,” we discussed sprite sheets and sprite animation. Until now, we’ve been able to accomplish everything we wanted by using procedural animation (e.g., moving the stationary sprite around programmatically). Now, however, we’re going to venture into the mystical world of sprite-sheet animation. In other words, we’re going to load some sprite-sheet textures and “walk” them to produce frames of animation. Walking a sprite sheet simply means displaying the first frame, waiting for the right time to show the next frame (e.g., allowing the animation interval to expire), and then displaying the second frame, waiting again, and finally showing the final frame. By convention, when you reach the last frame in a sprite sheet, you just start over again at the beginning, but mileage may vary. Figures 5.8–5.10 show the sprite sheets we will use to fix our little animation-fidelity problem.
Again with the looming questions, your mind, and my psionic powers of mental domination. Yes, we need to know both the height of the sprite and the width of the sprite to make this walking stuff workable. We also need to know the number of frames in the sheet. After that, the algorithm is pretty simple.
As you already know, in XNA, we get to use this cool and nifty abstraction thing called a spriteBatch
. One of the ninja-like features of the spriteBatch
class is an override to the Draw()
method that allows us to specify a texture, a source rectangle, and a destination rectangle. With that in mind, the algorithm for a sprite sheet walk is simply this:
Check to see if the total time elapsed since the last frame update exceeds the animation interval. If so, increment the current frame index.
Check to see if the current frame index is outside the sprite sheet (e.g., compare the current index to the frame count). If it is, set the current frame index to 0.
Update the source rectangle that we will pass into the aforementioned spriteBatch
method with the coordinates in the sprite sheet for the next frame.
Update the destination rectangle’s x and y attributes with the position of the character.
Draw the frame as usual.
With a few minor bookkeeping issues, we can code a spriteSheet
class to do all this very easily. In the Character/Sprite directory, create a new public class called CharacterSpriteSheet.cs.
We will add the private members to represent the art asset, two rectangles, a color, a frame counter integer, width and height integers, a current frame counter integer, a float to hold the elapsed time, and a float to hold the animation rate interval. Kinda like this:
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework; namespace _2DSpriteGame.Character.Sprite { /// <summary> /// A "one-way," fire-and-forget sprite-sheet implementation (i.e., starts /// at frame 0, runs to the end, then repeats) /// </summary> public class CharacterSpriteSheet { #region members private Texture2D sheet; //the sprite-sheet texture private Rectangle srcRectangle; //the window into the sprite sheet that holds the current frame private Rectangle destRectangle; //the spot in the game where we will draw the frame private Color myColor; //the color to tint the sprite sheet with private Vector2 position; //the position of the character showing this sheet private int frameCount = 0; //the total number of frames in the sprite sheet private int spriteHeight = 0; //height in pixels of each frame private int spriteWidth = 0; //width in pixels of each frame private int currFrame = 0; //current frame to be displayed private float timer = of; //current total elapsed time since we changed frames //constants
private const float INTERVAL = 1000f / 18f; //18 fps will be the *animation rate* for this sprite sheet, so the interval, in milliseconds, is 1000/18 #endregion
As Tony the Tiger is wont to say: Grrrrreat! We are 43 percent done. Next, we need to add some properties for the sheet: destRectangle
, srcRectangle
, and position members. Only the position member needs a setter, so all the rest of the properties just get the get
methods. We also need a simple constructor that allows the necessary data to be passed in. So, after the member’s region above, we will add the following:
#region properties public Rectangle Destination { get { return destRectangle; } } public Rectangle Source { get { return srcRectangle; } } public Texture2D Sprite { get { return sheet; } } public Vector2 Position { get { return position; } set { position = value; destRectangle.X = (int)position.X; destRectangle.Y = (int)position.Y; } } #endregion #region constructors / init public CharacterSpriteSheet(Texture2D newSheet, Vector2 p, int cnt, int width, int height, Color c) { sheet = newSheet; position = p;
frameCount = cnt; spriteHeight = height; spriteWidth = width; destRectangle = new Rectangle(0, 0, spriteWidth, spriteHeight); myColor = c; } #endregion
As you can see, none of these concepts are really challenging; they’re just basic XNA-fu. All that’s left is to implement the algorithm specified above and to hook this bad boy up to the game project. The last few methods we will add to the class implement the following algorithm:
#region utility methods /// <summary> /// Resets the current frame pointer of the animation back to the starting frame /// </summary> public void Reset() { currFrame = 0; } /// <summary> /// Implements the sprite-sheet-animation algorithm, changing frames only at the /// designated frames per second but keeping the position updated every cycle. /// </summary> /// <param name="gameTime"></param> public void Update(GameTime gameTime) { float delta = (float)gameTime.ElapsedGameTime.TotalMilliseconds; timer += delta; if (timer > INTERVAL) { currFrame++; if (currFrame > frameCount - 1) { currFrame = 0; } timer = 0f; srcRectangle = new Rectangle(currFrame * spriteWidth, 0, spriteWidth, spriteHeight);
} destRectangle.X = (int)position.X; destRectangle.Y = (int)position.Y; } /// <summary> /// Renders the current frame of the animation to the screen /// </summary> /// <param name="sb">The sprite batch object</param> /// <param name="se">A SpriteEffects attribute used to flip the animation from /// "right moving" (SpriteEffects.None) to "left moving" (SpriteEffects. /// FlipHorizontal)</param> public void Draw(SpriteBatch sb, SpriteEffects se) { sb.Draw(sheet, destRectangle, srcRectangle, myColor, 0f, Vector2.Zero, se, 0f); } #endregion
That is, as Stephen King once said for 1,104 pages, it. The only bits here not explicitly covered by the algorithm are simple bookkeeping tasks. For example, each time we run this method, we have to update our timer member, which adds up all the elapsed milliseconds since the last frame change. To do this, we have to figure out how much time has elapsed—but, luckily, XNA does that for us.
The next bit is where we update the srcRectangle
method. You can see that we make its height and width the size of the frame we want to show, but we also set its y position to 0 and its x position to the current frame number multiplied by the sprite width. First things first: Why use 0 for the y position? The sprite sheets we’ve designed this class to use are single-row sheets. In other words, the y coordinate for every frame position in the sheet will be 0. The x position is where we walk across the sheet. By multiplying the current frame by the width and using our sheet from Figure 5.8, we set the x,y coordinates for the first frame to (0,0), to (100,0) for the second frame, and to (200,0) for the last frame. (Remember that as with everything else, we zero-index the frame counter.)
The last crucial bit of business is to update the destRectangle
. If you don’t, the game will always try to draw the animation at (0,0), which leads to very interesting ghost characters running around—nothing but a set of eyes and a smile. As you can see, we just set the x and y values of the destRectangle
with the x and y values from the position
attribute. As with the EyeSprite
class, it is crucial that the position be set prior to calling update.
One last bit of magic, but it’s brief magic: The Draw()
method requires a sprite Batch
object, which we already know all about, and some new gizmo called a spriteEffects
attribute. spriteEffects
can be viewed as a very useful switch. Its possible values are FlipVertically
, FlipHorizontally
, and None
. What these do is change how the image will be rendered. I will leave figuring out what each setting actually does to the image as an exercise (an exercise in il studio del ovvio).
Putting on my Reading Your Mind Now cap, I can hear you thinking: “Wow, that’s pretty simple.” It’s true, and in recognition of that fact, I will forego my usual demands for bribery and special treats. The good news is, it will stay this easy no matter how long the animation chains get or how many different animations we add for each character. We may have to start playing with optimizations, but the algorithm itself won’t change much.
To use this magical black box of pwn, we need to instantiate a few objects of the CharacterSpriteSheet
class in the Character
definition. That’s it. (Well, besides all the changes to all the state classes, the CharacterManager
, and the Character
class itself, if you want to get all technical and stuff.) In the Character
class, we need to add a few asset members, like so:
private Sprite.CharacterSpriteSheet walkSheet; private Sprite.CharacterSpriteSheet fightSheet; private Sprite.CharacterSpriteSheet danceSheet; private Sprite.CharacterSpriteSheet walkSheetMask; private Sprite.CharacterSpriteSheet fightSheetMask; private Sprite.CharacterSpriteSheet danceSheetMask; private SpriteEffects dir;
And we will need a couple of Boolean state tracking members:
private Boolean isWalking; private Boolean isDancing; private Boolean isFighting;
We will also need a few new properties to help us set the state correctly at the appropriate times. Here they are:
public Boolean IsWalking { get { return isWalking; } set { isWalking = value;
if (isWalking) { walkSheet.Reset(); walkSheetMask.Reset(); } } } public Boolean IsDancing { get { return isDancing; } set { isDancing = value; if (isDancing) { danceSheet.Reset(); danceSheetMask.Reset(); } } } public Boolean IsFighting { get { return isFighting; } set { isFighting = value; if (isFighting) { fightSheet.Reset(); fightSheetMask.Reset(); } } } public SpriteEffects FaceDirection { get { return dir; } set { dir = value; } }
We will also expand the constructor (again!) to accommodate these changes thusly (and the changes in bold this time, to surprise you with my flexibility):
public Character(Texture2D tex, Texture2D mask, Texture2D eball, Texture2D pupil, Texture2D[] mouths, Texture2D walk, Texture2D
dance, Texture2D fight, Texture2D walkmask, Texture2D dancemask, Texture2D fightmask, Vector2 p, float s, float spd, Color bColor, Color bMaskColor, String n, float blinkOffset) { pos = p; scale = s; speed = spd; bodyColor = bColor; bodyMaskColor = bMaskColor; drawMask = false; name = n; idleBodyFixed = new Sprite.CharacterSprite(tex, pos, scale); idleBodyFixed.Tint = bodyColor; idleBodyFixedMask = new Sprite.CharacterSprite(mask, pos, scale); idleBodyFixedMask.Tint = bodyMaskColor; mouthHappy = new Sprite.CharacterSprite(mouths[0], pos, scale); mouthSad = new Sprite.CharacterSprite(mouths[1], pos, scale); mouthConfused = new Sprite.CharacterSprite(mouths[2], pos, scale); mouthBored = new Sprite.CharacterSprite(mouths[3], pos, scale); mouthAngry = new Sprite.CharacterSprite(mouths[4], pos, scale); mouthDisgusted = new Sprite.CharacterSprite(mouths[5], pos, scale); leye = new Sprite.EyeSprite(eball, pupil, pos, lEyePos, lPupCent, 1.0f); reye = new Sprite.EyeSprite(eball, pupil, pos, rEyePos, rPupCent, 1.0f); walkSheet = new Sprite.CharacterSpriteSheet(walk, pos, 3, 100, 100, bColor); fightSheet = new Sprite.CharacterSpriteSheet(fight, pos, 6, 100, 100, bColor); danceSheet = new Sprite.CharacterSpriteSheet(dance, pos, 3, 100, 100, bColor); walkSheetMask = new Sprite.CharacterSpriteSheet(walkmask, pos, 3, 100, 100, bMaskColor); fightSheetMask = new Sprite.CharacterSpriteSheet(fightmask, pos, 6, 100, 100, bMaskColor); danceSheetMask = new Sprite.CharacterSpriteSheet(dancemask, pos, 3, 100, 100, bMaskColor); //for testing!! mouth = mouthBored; path = new Logic.CharacterPath(); bBox = new Rectangle((int)pos.X + x, (int)pos.Y + y, w, h);
nameSprite = new GUI.ScreenText(); isSleeping = false; wasSleeping = false; isWalking = false; isDancing = false; isFighting = false; dir = SpriteEffects.None; blinking = false; blinkClosed = true; deltaT = blinkOffset; fsm = new Logic.CharacterFSM(this); }
Next, we need to change the Update()
method to keep track of all this mess. It’s not as ugly as you may think, however. Here ’tis, with changes in bold:
/// <summary> /// Performs update processing on the character's position, logical components, /// and sprite assets. /// </summary> /// <param name="gameTime"></param> public void Update(GameTime gameTime) { if (isSleeping) { CloseEyes(); leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; } else if ( wasSleeping ) { wasSleeping = ! OpenEyes(); if (!wasSleeping) { deltaT = 0; blinking = false; blinkClosed = false; } leye.Position = pos;
reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; } //if we're not blinking… if ( ! blinking && ! blinkClosed) { deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if ( deltaT > INTERVAL ) { if ( !blinking ) { blinkClosed = CloseEyes(); if ( blinkClosed ) { blinking = true; } } deltaT = 0f; } } else if (blinking && blinkClosed) { deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if ( deltaT > INTERVAL ) { blinking = ! OpenEyes(); deltaT = 0f; } } else if (!blinking) { deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if (deltaT > BINTERVAL) { blinkClosed = false; deltaT = 0f; }
} path.Update(pos); if (Waypoint != LookAt) { if (! ( (fsm.CurrentState is Logic.CharacterFSMStates.ChatState) || (fsm.CurrentState is Logic.CharacterFSMStates. ChaseState) || (fsm.CurrentState is Logic.CharacterFSMStates. EvadeState) || (fsm.CurrentState is Logic.CharacterFSMStates. FightState) || (fsm.CurrentState is Logic.CharacterFSMStates.GoToState) || (fsm.CurrentState is Logic.CharacterFSMStates. StopState) || (fsm.CurrentState is Logic.CharacterFSMStates. DanceWithState) ) ) { LookAt = Waypoint; } } nameSprite.RemoveNewest(); nameSprite.Print(name, new Vector2(pos.X + 32, pos.Y - 12), Color. Snow, (int)GUI.ScreenText.FontName.DEFAULT); if (path.Waypoint == Vector2.Zero) { //reset the walk sprite sheet when we've reached the waypoint if (isWalking) { isWalking = false; } if (! ( (fsm.CurrentState is Logic.CharacterFSMStates.ChatState) || (fsm.CurrentState is Logic.CharacterFSMStates. ChaseState) || (fsm.CurrentState is Logic.CharacterFSMStates. EvadeState) || (fsm.CurrentState is Logic.CharacterFSMStates. FightState) || (fsm.CurrentState is Logic.CharacterFSMStates.GoToState) ||
(fsm.CurrentState is Logic.CharacterFSMStates.StopState) || (fsm.CurrentState is Logic.CharacterFSMStates. DanceWithState) ) ) { ResetEyes(gameTime); } else { leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); } return; } //compute the size of the next step Vector2 step = Vector2.Normalize(Vector2.Subtract(pos, path.Waypoint)) * speed * (float)gameTime.ElapsedGameTime.TotalSeconds; //if we are not within our "there" range, if ((Math.Max(2.0f, Math.Abs(pos.X - path.Waypoint.X)) > 2.0f) || (Math.Max(2.0f, Math.Abs(pos.Y - path.Waypoint.Y)) > 2.0f)) { pos -= step; } idleBodyFixed.Position = pos; idleBodyFixedMask.Position = pos; mouth.Position = pos; leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); walkSheet.Position = pos; danceSheet.Position = pos; fightSheet.Position = pos; walkSheetMask.Position = pos; danceSheetMask.Position = pos;
fightSheetMask.Position = pos; bBox.X = (int)pos.X + x; bBox.Y = (int)pos.Y + y; if (isWalking) { walkSheet.Update(gameTime); walkSheetMask.Update(gameTime); } else if (isDancing) { danceSheet.Update(gameTime); danceSheetMask.Update(gameTime); } else if (isFighting) { fightSheet.Update(gameTime); fightSheetMask.Update(gameTime); } }
All pretty self-explanatory (just setting positions and calling Update()
methods when needed), so let’s jump to the Draw()
method:
/// <summary> /// Draws the sprite assets of the character in the correct order when appropriate. /// </summary> /// <param name="sb"></param> public void Draw(SpriteBatch sb) { if (isWalking) { if (drawMask) { walkSheetMask.Draw(sb, dir); } walkSheet.Draw(sb, dir); } else if (isDancing) { if (drawMask) { danceSheetMask.Draw(sb, dir);
} danceSheet.Draw(sb, dir); } else if (isFighting) { if (drawMask) { fightSheetMask.Draw(sb, dir); } fightSheet.Draw(sb, dir); } else { if (drawMask) { idleBodyFixedMask.Draw(sb); } idleBodyFixed.Draw(sb); } mouth.Draw(sb); leye.Draw(sb); reye.Draw(sb); nameSprite.Draw(sb); }
Yes, this method continues on with the theme of being really easy, but hey, I did promise that the sprite-sheet animation wouldn’t be very hard. This time, you can trust me. (Not like in Appendix A, “Getting Started with Microsoft XNA,” where I say “Trust me,” and then the evil overcomes me, and I have to make it all complicated.) No, really.
Ahem. Moving right along, let’s turn our attention to the CharacterManager
class. Simple changes are required here, too, and only to the dreaded LoadContent()
method (which is surely just desserts):
public void LoadContent(ContentManager Content) { names[0] = "Xret"; names[1] = "Biix"; names[2] = "Laka"; names[3] = "Potr"; names[4] = "Mati"; names[5] = "Dibu";
names[6] = "Fhet"; names[7] = "Raas"; names[8] = "Tyeu"; names[9] = "Skao"; int y = rand.Next(248) + 410; //the walkable y-space in our environment int x = rand.Next(904) + 10; //the walkable x-space in our environment Color sumaColor = new Color(0,240, 75, 255); float spd = 150f; float blink = (float)(rand.NextDouble())*1000f; Texture2D[] m = new Texture2D[6]; m[0] = Content.Load<Texture2D>("Character/SumaHappyMouth"); m[1] = Content.Load<Texture2D>("Character/SumaSadMouth"); m[2] = Content.Load<Texture2D>("Character/SumaConfusedMouth"); m[3] = Content.Load<Texture2D>("Character/SumaBoredMouth"); m[4] = Content.Load<Texture2D>("Character/SumaAngryMouth"); m[5] = Content.Load<Texture2D>("Character/SumaDisgustedMouth"); Texture2D bodyFixed = Content.Load<Texture2D>("Character/SumaFixed"); Texture2D bodyGM = Content.Load<Texture2D>("Character/SumaFixedGlowMask"); Texture2D eyeBall = Content.Load<Texture2D>("Character/SumaEyeBall"); Texture2D pupil = Content.Load<Texture2D>("Character/SumaPupil"); Texture2D walk = Content.Load<Texture2D>("Character/SumaWalkSheet"); Texture2D dance = Content.Load<Texture2D>("Character/SumaDanceSheet"); Texture2D fight = Content.Load<Texture2D>("Character/SumaFightSheet"); Texture2D walkmask = Content.Load<Texture2D>("Character/SumaWalkSheet GlowMask"); Texture2D dancemask = Content.Load<Texture2D>("Character/SumaDanceSheet GlowMask"); Texture2D fightmask = Content.Load<Texture2D>("Character/SumaFightSheet GlowMask"); for (int i = 0; i < NUM_TOONS; i++) { toons[i] = new Character( bodyFixed, bodyGM, eyeBall, pupil, m, walk, dance,
fight, walkmask, dancemask, fightmask, new Vector2(x, y), 1.0f, spd, sumaColor, Color.GhostWhite, names[i], blink); toons[i].LoadContent(Content); y = rand.Next(248) + 410; //the walkable y-space in our environment x = rand.Next(904) + 10; //the walkable x-space in our environment blink = (float)(rand.NextDouble()) * 1000f; } }
Yeah, so that was really simple. In fact, I am ashamed that I even cut and pasted that code in here. If I was a real wizard, I would leave it out of the book entirely, purely to frustrate you, Valued Reader. Lucky for you, I’m more of a berserker.
Lastly, the fun stuff: We need to use all this stuff in our state code. I’m going to show a couple of examples rather than drag you through the same code in every other state class (and I really mean the same code…).
In MoveState
, EvadeState
, ChaseState
, and RandomWalkState
, we will simply cut and paste the same 15 lines of code into the Action()
method. Here is MoveState.Action()
with the telltale 15 lines in bold:
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { self.IsFighting = false; self.IsDancing = false; if (targetLoc != Vector2.Zero) {
//give a bored expression self.SetExpression(3); self.Waypoint = targetLoc; targetLoc = Vector2.Zero; if (!self.IsWalking) { self.IsWalking = true; } //give the character something to look at self.LookAt = self.Waypoint; //make sure the animation "faces" the right way if (self.Waypoint.X < self.Position.X) { self.FaceDirection = SpriteEffects.FlipHorizontally; } else { self.FaceDirection = SpriteEffects.None; } } }
Wow, that’s so simple, it’s almost beautiful! I wonder who designed such a marvelously easy-to-use system? He should get an award or something (like a Twinkie!). Yep, all we need to do to use these wonderful sprite sheets is set the appropriate state variables and then make sure we are facing the sprite sheet in the correct direction.
In DanceState
, we can almost use exactly the same lines—but of course we want to show the dance sprite sheet instead of the walking sprite sheet, so we do this:
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { //give a happy expression self.SetExpression(0);
if (!self.IsDancing) { self.IsDancing = true; self.IsWalking = false; self.IsFighting = false; } //randomly select a new waypoint that is relatively close by (within +/- //50 on both axes) int y = rand.Next(75); int x = rand.Next(75); int chance = rand.Next(100); if (chance < 25) { x *= -1; y *= -1; } else if (chance < 50) { x *= -1; } else if (chance < 75) { y *= -1; } //and push it on the path self.Waypoint = new Vector2(self.Position.X + x, self.Position.Y+ y); //give the character something to look at self.LookAt = self.Waypoint; //make sure the animation "faces" the right way if (self.Waypoint.X < self.Position.X) { self.FaceDirection = SpriteEffects.FlipHorizontally; } else { self.FaceDirection = SpriteEffects.None; } }
Bam! Change which state variable we are setting to true, leaving everything else exactly the same, and presto change-o, we’re now high-skill animators.
Okay, enough with the easy stuff. Let’s take a look at the special cases. First up, let’s look at the StopState
.Action()
routine:
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { self.ClearWaypoints(); //give a disgusted expression self.SetExpression(5); self.IsFighting = false; self.IsDancing = false; self.IsWalking = false; //every so often deltaT += (float)gameTime.ElapsedGameTime.TotalMilliseconds; if (deltaT > INTERVAL) { int r = rand.Next(100); //watch the mouse self.LookAt = new Vector2(Game1.GameStateMan.MHandler.LEFT, Game1.GameStateMan.MHandler.TOP); //or… if (r < 30) { //watch a random character int i = rand.Next(Game1.GameStateMan.CharManager.ToonCount); if (i == Game1.GameStateMan.CharManager.GetToonId(self)) { if (i == 0) { i++;
} else { i- -; } } self.LookAt = Game1.GameStateMan.CharManager.GetToon(i).Position; } else if ( r < 60 ) { //stare at the player self.ResetEyes(gameTime); } deltaT = 0; } }
Since we want this state to force a stop, all we do is turn off all the sprite sheets. How’s that for redefining hard to mean easy? We do the same in SleepState
, except we turn on isSleeping
and turn off everything else:
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { self.IsSleeping = true; self.IsFighting = false; self.IsDancing = false; self.IsWalking = false; //give a bored expression self.SetExpression(3); }
In the paired interaction states, we actually built two components into each. First, we walk the two characters together, and then we get down to business. In DanceWithState
, FightState
, and ChatState
, we do much like we did above, but we do it multiple times to accommodate the different behaviors. I’m going to show you FightState.Action()
because I want to. You got a problem with that? Huh?
/// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { if (target.FSM.CurrentState.Identifier != "FIGHT" && init.Name.Equals (self.Name)) { if (target.FSM.CurrentState is Logic.CharacterFSMStates.SleepState) { target.FSM.Token = "SLEEP"; target.FSM.ExecuteTransition(); } target.FSM.Token = "FIGHT"; target.FSM.ExecuteTransition(); target.FSM.CurrentState.Target = self; (target.FSM.CurrentState as CharacterFSMStates.FightState). InitCharacter = self; } //give a angry expression self.SetExpression(4); //look at the other interactor self.LookAt = target.Position; if (Vector2.DistanceSquared(self.Position, target.Position) <= 8600f) { if (!self.IsFighting) { self.IsFighting = true; self.IsDancing = false; self.IsWalking = false; } int y = rand.Next(25); int x = rand.Next(25); int chance = rand.Next(100); if (chance < 50)
{ x *= -1; y *= -1; } self.Waypoint = new Vector2(self.Position.X + x, self.Position.Y + y); //make sure the animation "faces" the right way if (target.Position.X < self.Position.X) { self.FaceDirection = SpriteEffects.FlipHorizontally; } else { self.FaceDirection = SpriteEffects.None; } } else { float deltaX = (float)Math.Abs(self.Position.X - target.Position.X) / 2; float deltaY = (float)Math.Abs(self.Position.Y - target.Position.Y) / 2; self.ClearWaypoints(); if (!self.IsWalking) { self.IsFighting = false; self.IsDancing = false; self.IsWalking = true; } Vector2 selfWP = self.Position; if (self.Position.X < target.Position.X) { selfWP.X += deltaX; } else if (self.Position.X > target.Position.X) { selfWP.X -= deltaX; } if (self.Position.Y < target.Position.Y)
{ selfWP.Y += deltaY; } else if (self.Position.Y > target.Position.Y) { selfWP.Y -= deltaY; } self.Waypoint = selfWP; //make sure the animation "faces" the right way if (self.Waypoint.X < self.Position.X) { self.FaceDirection = SpriteEffects.FlipHorizontally; } else { self.FaceDirection = SpriteEffects.None; } } }
As you can see, this is more of the same thing, except that when we are playing the fight animation, we set our facing direction based on where the target character is rather than the next waypoint. That keeps our little friends from kicking randomly in space like a bunch of lunatics.
If you can read and type code at the same time, like any good game developer, you should have all the states updated and ready to go by the time I finish this sentence. Run it and check out the massive improvement in the game, all due to a little magic black box, the smoke that lives inside the box, and some massively good cut-and-paste abilities.
My lack of artistic abilities aside, I think we have a pretty balanced presentation. Our modeling fidelity is appropriate for vaguely frog-shaped aliens bent on destroying Earth, and our animation fidelity is pretty good in relation to the modeling fidelity. Sure, it could be better (if, say, I was someone else who had a modicum of artistic skill), but the point is not that it should be better, the point is simply that the animation fidelity needs to surpass the modeling fidelity.
Since we’ve killed all the zombies, and since we still have 14 lives left … ohne weitere Umstaände, can we talk about a man named Geppetto and his “nephew”?
Since we’ve left the look and move part of the Uncanny Valley in our dust, we might be tempted to stop here and go make a lot of money selling games. If Mori were the only judge, we might get away with it, too. But I’m sure you will agree, Valued Reader, that after the “cuteness” (as my wife puts it) of our little characters begins to fade, there’s not much to keep us interested.
In fact, we can easily say that our characters are beyond stupid. They are also lazy and unmotivated. All together, that makes for a particularly boring partner or opponent to play games with (although it might be great for reality television). In fact, research into synthetic characters has consistently shown that humans can comfortably interact with these constructs only when they feel they can understand what is going on in the characters’ minds. It’s true that we added some expressions and eye movements to try to trick a player into thinking that our little green men have something going on upstairs, but we know better—and so will the player after 48 seconds.
Fundamental to that understanding of what’s going on inside a character’s mind is the belief that the character’s actions are driven by its beliefs and desires. This concept is known as intentionality—the perceived link between a character’s actions and its state of mind. It can’t be more clear that our characters have no state of mind from this definition; they have no capacity for independent action!
What we have so far are puppets. We can pull their strings and make them do some transiently interesting stuff, but at the end of the day, none of that matters, because they can’t do anything themselves, and also because what we make them do has absolutely no bearing on them internally.
Let’s define cognitive fidelity as the quality of action selection, perception, planning, decision making, learning, etc. that a character is capable of displaying to the player. Using that definition, I think we can all agree that our characters presently display absolutely no cognitive fidelity.
This brings us right back to the discussion of the fidelity hierarchy. If it’s true that a character with higher modeling fidelity than animation fidelity is a zombie, I think we’ve just shown that a character with higher animation fidelity than cognitive fidelity is a puppet. Increasing cognitive fidelity doesn’t let us off the hook for animation and modeling fidelity balance, as many, many video games on the shelves today prove beyond the shadow of a doubt. Putting what we have so far into a nice tabular form, we can see failure to achieve the proper balance of fidelity leads to disaster.
Despite the fact that we might have added enough animation fidelity to get us past Mori’s version of the Uncanny Valley, we certainly can’t get past the Freudian definition of uncanny, let alone the more constraining definition given by Jentsch, without adding a bit of VGAI.
In many cases, a developer might jump right in by adding a way for a character to navigate in the world. We already did that when we implemented the base behaviors represented in CharacterFSM.cs, so what we need next is a method for selecting which action to perform.
To get started on this path, let’s begin tracking how much any given character likes his peers and how much affinity a character has for any particular action. It’s not as hard as it sounds. First, we need two float arrays in Character.cs:
//ai data private float[] attraction; private float[] activities;
Next, we need to initialize those arrays with values. This is the point where many get started with the wrong foot leading. Sometimes, these values might be initialized to the same numbers for all characters or, even worse, at 0. Both of these values, while seemingly innocent, lead to fairly boring AI. Why? All the characters are exactly the same until sufficient time has passed, and even then, they may be achingly similar. No, what we want to do is randomly initialize these values in the Character
constructor like this:
Random r = new Random(); int neg; float val; //initialize the likes and dislikes of this character randomly for (int i = 0; i < tc; i++) { neg = r.Next(100);
val = (float)r.NextDouble() * 100f; if (neg < 50) { attraction[i] = val; } else { attraction[i] = -1 * val; } } for ( int i = 0; i < sc; i++ ) { neg = r.Next(100); val = (float)r.NextDouble() * 100f; if (neg < 50) { activities[i] = val; } else { activities[i] = -1 * val; } }
Yep, pretty simple—and we can even tune it later to skew the results toward negative or positive initial values. Next, we need to add four methods to Character.cs:
/// <summary> /// Accesses the affinity value this character has for the activity represented /// by actID /// </summary> /// <param name="actID">A state id</param> /// <returns>the affinity value for the activity referenced</returns> public float GetActivityAffinity(int actID) { return activities[actID]; } /// <summary> /// Change the affinity value this character has for the activity represented by /// actID, by delta amount. Pass in a negative for a negative change.
/// </summary> /// <param name="actID">A state id</param> /// <param name="delta">The amount of change required (pass in a negative for a /// decrement)</param> public void ChangeActivityAffinity(int actID, float delta) { activities[actID] += delta; if (activities[actID] > 100f) { activities[actID] = 100f; } else if (activities[actID] < -100f) { activities[actID] = -100f; } } /// <summary> /// Accesses the attraction value for the character represented by charID /// </summary> /// <param name="charID">The toon id for the character whose attractiveness /// should change</param> /// <returns>the attraction value for the character referenced</returns> public float GetAttraction(int charID) { return attraction[charID]; } /// <summary> /// Change the amount of attraction felt for the character represented by charId, /// by delta amount. Pass in a negative for a negative change. /// </summary> /// <param name="charID">The toon id for the character whose attractiveness /// should change</param> /// <param name="delta">The amount of change required (pass in a negative for a /// decrement)</param> public void ChangeAttraction(int charID, float delta) { attraction[charID] += delta; if (attraction[charID] > 100f) { attraction[charID] = 100f; } else if (attraction[charID] < -100f)
{ attraction[charID] = -100f; } }
They are just basic accessors, except we guard the range of values being set in both cases to between −100 (super yucky) and 100 (I think I will marry this activity). We could use any number there, but 100 always seems to fit my mindset, so I always choose 100.
One last thing needs to be added to the Character
class, and it’s something we haven’t really talked about yet: a struct. A struct is a complex data structure (or aggregate type, if you prefer) that is composed of one or more primitive types. In other words, it’s a fancy container thing to group similar data with. In C# (and thus in XNA), structs are a lot like classes, with the major exception that while classes are reference types (i.e., they are references to an address in object memory), structs are value types (i.e., they hold their values in memory where they are declared). Also, structs can’t implement inheritance (although they can implement interfaces).
Though classes and structs are fundamentally different, their syntax is very similar. In fact, the only real syntax difference is that we use the keyword struct
to define a struct. My, that’s convenient!
In Character.cs, we need to define a struct that will hold three integers and three floats. The purpose of these variables will be to sort the top three activity affinities and make those sorted values available outside the class. Here is the struct definition:
//complex types public struct TopThreeActivities { public int best1; public int best2; public int best3; public float bestVal1; public float bestVal2; public float bestVal3; public TopThreeActivities(int ival, float fval) {
best1 = ival; best2 = ival; best3 = ival; bestVal1 = fval; bestVal2 = fval; bestVal3 = fval; } } private TopThreeActivities bestThree;
As you can see, we will initialize the values of all six struct members in their constructors (which, due to another interesting implementation choice, can’t be a parameter-less constructor because of syntax rules in C#), and each Character
will keep a private instance of this struct. The instance will be initialized in the Character
constructor method, like this:
bestThree = new TopThreeActivities(-1, -101f);
To provide access to the instance, we need a property that implements only the get
, as all maintenance will be internal to the Character
class. However, in order for external entities to use the data, the struct itself needs to be public.
The algorithm for updating this data is simple, really; we’ll just use an insertion sort from the original array into a short list of three. That just means that we will iterate through the activities array, and if the value at the current index is larger than the value in one of the slots, we will do some shifting and insert the new value. The code looks like this:
/// <summary> /// This method will update the TopThreeActivities struct for this character, /// keeping the three highest-ranked activity indexes in Best1, Best2, and Best3, /// as well as keeping the value of each affinity in BestVal1, BestVal2, and /// BestVal3. This is a basic insertion sort. /// </summary> public void UpdateTopThree() { float val; bestThree = new TopThreeActivities(-1, -101f); for (int j = 0; j < fsm.StateCount; j++) { val = GetActivityAffinity(j); if (val > bestThree.bestVal1) {
bestThree.bestVal3 = bestThree.bestVal2; bestThree.best3 = bestThree.best2; bestThree.bestVal2 = bestThree.bestVal1; bestThree.best2 = bestThree.best1; bestThree.bestVal1 = val; bestThree.best1 = j; } else if (val > bestThree.bestVal2) { bestThree.bestVal3 = bestThree.bestVal2; bestThree.best3 = bestThree.best2; bestThree.bestVal2 = val; bestThree.best2 = j; } else if (val > bestThree.bestVal3) { bestThree.bestVal3 = val; bestThree.best3 = j; } } }
To hook this masterpiece up, we just need to call the UpdateTopThree()
method from the first line of the Update()
method, like this:
public void Update(GameTime gameTime) { //find the top three activity preferences for this cycle UpdateTopThree();
To enable testing as we move on (and to test our random value initialization), we need to alter the UpdateLeftGui()
and UpdateRightGui()
methods in Char-acterManager
. I’ll just replace the current routine that causes text to be printed to the screen with this code for the left panel:
//stage 2 info tWalk.Y += 28; String txt; float val; int font; tWalk.Y += 18; txt = "Favorite " + left.FSM.GetState(left.BestThree.best1).Identifier + " (" + left.BestThree.bestVal1.ToString("#.00") + ")";
gm.LeftText.Print(txt, tWalk, Color.Green, (int)GUI.ScreenText.FontName. SMALL); tWalk.Y += 12; txt = "Second " + left.FSM.GetState(left.BestThree.best2).Identifier + " (" + left.BestThree.bestVal2.ToString("#.00") + ")"; gm.LeftText.Print(txt, tWalk, Color.YellowGreen, (int)GUI.ScreenText. FontName.SMALL); tWalk.Y += 12; txt = "Third " + left.FSM.GetState(left.BestThree.best3).Identifier + " (" + left.BestThree.bestVal3.ToString("#.00") + ")"; gm.LeftText.Print(txt, tWalk, Color.Yellow, (int)GUI.ScreenText.FontName. SMALL); tWalk.Y += 28; for (int i = 0; i < NUM_TOONS; i++) { if (i != GetToonId(left)) { val = left.GetAttraction(i); txt = "" + toons[i].Name + " attraction = " + val.ToString("#.00"); if (Game1.GameStateMan.GuiManager.RightTargetId == i) { font = (int)GUI.ScreenText.FontName.SMALL_EMPHASIS; } else { font = (int)GUI.ScreenText.FontName.SMALL; } if (val > -9.9f && val < 9.9f) { gm.LeftText.Print(txt, tWalk, Color.White, font); } else if (val > 10f && val < 49.9f) { gm.LeftText.Print(txt, tWalk, Color.Yellow, font); } else if (val > 50f && val < 84.9f) { gm.LeftText.Print(txt, tWalk, Color.YellowGreen, font); } else if ( val > 85f ) { gm.LeftText.Print(txt, tWalk, Color.Green, font);
} else if (val < -10f && val > -49.9f) { gm.LeftText.Print(txt, tWalk, Color.Orange, font); } else if (val < -50f && val > -84.9f) { gm.LeftText.Print(txt, tWalk, Color.OrangeRed, font); } else if (val < -85f) { gm.LeftText.Print(txt, tWalk, Color.Crimson, font); } tWalk.Y += 12; } }
This will replace all the state, position, and waypoint information from the previous version, with a list of characters and the attraction the current left-panel target feels for each. For the right panel, let’s do this:
//stage 2 info tWalk.Y += 28; String txt; float val; tWalk.Y += 18; txt = "Favorite " + right.FSM.GetState(right.BestThree.best1).Identifier + " (" + right.BestThree.bestVal1.ToString("#.00") + ")"; gm.RightText.Print(txt, tWalk, Color.Green, (int)GUI.ScreenText.FontName. SMALL_EMPHASIS); tWalk.Y += 12; txt = "Second " + right.FSM.GetState(right.BestThree.best2).Identifier + " (" + right.BestThree.bestVal2.ToString("#.00") + ")"; gm.RightText.Print(txt, tWalk, Color.YellowGreen, (int)GUI.ScreenText. FontName.SMALL); tWalk.Y += 12; txt = "Third " + right.FSM.GetState(right.BestThree.best3).Identifier + " (" + right.BestThree.bestVal3.ToString("#.00") + ")"; gm.RightText.Print(txt, tWalk, Color.Yellow, (int)GUI.ScreenText.FontName. SMALL); tWalk.Y += 28;
txt = "Attraction to " + Game1.GameStateMan.GuiManager.LeftTarget.Name + " = " + right.GetAttraction(Game1.GameStateMan.GuiManager.LeftTargetId). ToString("#.00"); gm.RightText.Print(txt, tWalk, Color.White, (int)GUI.ScreenText.FontName. SMALL_EMPHASIS); tWalk.Y += 28; txt = "Current state = " + right.FSM.CurrentState.Identifier; gm.RightText.Print(txt, tWalk, Color.White, (int)GUI.ScreenText.FontName. SMALL); tWalk.Y += 18; if (right.FSM.CurrentState.Target != null) { txt = "Current target = " + right.FSM.CurrentState.Target.Name; } else { txt = "No current target."; } gm.RightText.Print(txt, tWalk, Color.White, (int)GUI.ScreenText.FontName. SMALL); tWalk.Y += 18;
This will print the right target’s liking for the left target, the right target’s current state and current target, as well as its three favorite actions.
To fit all that text into the left panel, I had to add a couple of new spriteFonts
: a 10-point Consolas and a 10-point Consolas Italic. Those fonts and appropriate changes were made in the ScreenText
class, but were trivial, so I won’t discuss them in detail.
With these changes made, we can run it and see who likes who and what—which, I can assure you, will be pretty boring, since nothing changes yet. For now, we will use these values to help the characters decide what to do, and we should work on getting these values to update prior to trying to get the decision code working.
Next we need to add a simple float called boredom
to Character
and build a property for it. We will initialize it to 0 in the constructor, and its range will fall between −100 (really bored) to 100 (really excited). We will also need a State
object called lastStateId
along with a property that we can use to determine how long we’ve been performing the same action.
Our basic rule here will be that every update cycle will cause the character’s affinity for that action to change, and will trigger a corresponding change in the boredom
attribute. Initially, activities that the character likes will cause an increase in the affinity and will cause a positive change in the boredom
attribute, but after a few minutes, the upward trend will reverse. Activities that the character does not like will immediately cause a change in the negative direction for both fields.
Because we only want to update this field when we are changing activity states, we will guard the setting of each new value in the Action()
method of each state. For instance, in each Action()
method, we will add this code:
//guarded update of the ai attribute for increasing boredom if (self.LastStateId != Identifier) { self.LastStateId = Identifier; }
To determine how long it’s been since we changed state, we need another point of data (a float) called stateAge
, which needs no property and should also be initialized to 0 in the constructor. We are going to determine the age by adding this code to the very top of the Character.Update()
method:
//update the stateAge value, allowing the boredom attribute to do its thing if (lastState == fsm.CurrentState.Identifier) { stateAge += gameTime.TotalGameTime.Milliseconds; } else { stateAge = 0f; } //get the current activity code int actID = fsm.GetStateIdKey(fsm.CurrentState.Identifier); float delta = -1 * ACT_CHANGE; //check to see if we should make a positive change because we like this activity if (stateAge < POS_INTERVAL) { if (activities[actID] > -10f) { delta *= -1; }
} //change both the affinity and the boredom value ChangeActivityAffinity(actID, delta); boredom += delta; if (boredom > 100f) { boredom = 100f; } else if (boredom < -100f) { boredom = -100f; }
Later, we’re going to augment this routine with information about the character we may be paired with, but for now, this is sufficient. We need to make small changes to the GUI update methods in CharacterManager
so that we can see the character’s boredom values.
Honestly, however, we should do something more elaborate with the boredom calculation. To take the character’s attraction for the target of its interaction, we can augment the code listed above like this:
//get the current activity code int actID = fsm.GetStateIdKey(fsm.CurrentState.Identifier); float delta = -1 * ACT_CHANGE; float attract = -1 * ATT_CHANGE; //check to see if we should make a positive change because we like this activity if (stateAge < STATE_INTERVAL) { if (activities[actID] > -10f) { delta *= -1; attract *= -1; } } int charID = -1; if (fsm.CurrentState.Target != null) { charID = Game1.GameStateMan.CharManager.GetToonId(fsm.CurrentState. Target);
} if (charID > -1) { //if we dislike this guy, affinity change gets worse if (attraction[charID] < -10f) { delta *= 2; } ChangeAttraction(charID, attract); } //change both the affinity and the boredom value ChangeActivityAffinity(actID, delta); if (activities[actID] > 75f) { //75 .. 100 if (delta < 0) { boredom += -3 * delta; } else { boredom += 3 * delta; } } else if (activities[actID] > 50f) { //50 .. 75 if (delta < 0) { boredom += -2 * delta; } else { boredom += 2 * delta; } } else if (activities[actID] > 10f) { //10 .. 50 if (delta < 0)
{ boredom += -1.5f * delta; } else { boredom += 1.5f * delta; } } else if (activities[actID] > -10f) { //-10 .. 10 if (delta < 0) { boredom += -1 * delta; } else { boredom += delta; } } else if (activities[actID] > -50f) { //-15 .. -10 boredom += 1.5f * delta; } else if (activities[actID] > -75f) { //-75 .. -50 boredom += 2 * delta; } else if (activities[actID] < -74.9f) { //-100 .. -75 boredom += 2 * delta; } if (boredom > 100f) { boredom = 100f; } else if (boredom < -100f) {
boredom = -100f; } if (boredom > 100f) { boredom = 100f; } else if (boredom < -100f) { boredom = -100f; }
This will allow the boredom
attribute to change with a multitude of rates, which will make it a bit more interesting. To allow the player to monitor boredom, we need to add the lines in bold to the left-panel update methods:
//stage 2 info tWalk.Y += 28; String txt; float val; int font; val = left.Boredom; if (val <= 0f) { txt = "Boredom = " + val.ToString("#.00"); gm.LeftText.Print(txt, tWalk, Color.Crimson, (int)GUI.ScreenText. FontName.SMALL); } else { txt = "Excitement = " + val.ToString("#.00"); gm.LeftText.Print(txt, tWalk, Color.Green, (int)GUI.ScreenText. FontName.SMALL); } tWalk.Y += 18;
We also need to add the bold lines in the following code to the right panel:
//stage 2 info
tWalk.Y += 28;
String txt;
float val;
val = right.Boredom;
if (val <= 0f) { txt = "Boredom = " + val.ToString("#.00"); gm.RightText.Print(txt, tWalk, Color.Crimson, (int)GUI.ScreenText. FontName.SMALL); } else { txt = "Excitement = " + val.ToString("#.00"); gm.RightText.Print(txt, tWalk, Color.Green, (int)GUI.ScreenText. FontName.SMALL); } tWalk.Y += 18;
Beyond the pretty colors that rapidly all go to red, what we’ve created here will be the basis for a VGAI that is driven to seek novel behaviors. If you played around with this at all, you probably noticed that very rapidly, these guys start hating every activity they perform (and will soon after also hate every other character, too). In order to overcome that, we need to very slowly increase the affinity for activities over time, but only while the character is not bored.
We will add the following lines of code right after the preceding segment but before the UpdateTopThree()
call:
//cause all hatred of activities and characters to degrade over time if (boredom >= 0f) { for (int j = 0; j < Game1.GameStateMan.CharManager.ToonCount; j++) { if (GetAttraction(j) < -85f) { ChangeAttraction(j, ATT_CHANGE); } else if (GetAttraction(j) < -10f) { ChangeAttraction(j, (ATT_CHANGE / 10)); } else if (GetAttraction(j) < 10f) { ChangeAttraction(j, (ATT_CHANGE / 100)); } } for (int j = 0; j < fsm.StateCount; j++)
{ if (GetActivityAffinity(j) < -85f) { ChangeActivityAffinity(j, ACT_CHANGE); } else if (GetActivityAffinity(j) < -10f) { ChangeActivityAffinity(j, (ACT_CHANGE / 10)); } else if (GetActivityAffinity(j) < 10f) { ChangeActivityAffinity(j, (ACT_CHANGE / 100)); } } }
Now, we have the technology to build the world’s first six-million-dollar … ahem. Sorry, about that—flashbacks to childhood and all. What I meant to say was: Now, we have the technology to enable our characters to start picking their own behaviors in a very rudimentary way.
I know we are in the middle of a deluge of code, and it is very easy to get wrapped up in coding and lose our focus. What we are attempting to do with all this self-directed behavior picking (not nose! Behavior!) is empower our characters to “decide” what they will do based on their internal motivations (which, for now, are limited to not being bored, and not doing something with Suma they hate). This is a direct application of increasing cognitive fidelity. Yeah, it’s true these guys are not out solving intractable math problems, but they are solving problems for themselves (which, in turn, solves a big problem for believability and immersion). These changes alone won’t be enough to get to our final goal, of course, but do serve as mandatory stepping stones towards that goal.
If our character is bored, we want it to move to an activity that will excite it. This will typically mean choosing the behavior at the index represented by value in bestThree.best1
, provided that’s not the state we are currently in. We also need to figure out a way to distinguish being bored and in a state we like from being bored in a state we’ve been in for a while and have grown to hate. Further, we need to know if the player has taken control of this character (for now, the player’s indirect control will remain absolute for the left target, as these VGAI routines do not get called when the player assumes control of a character).
Luckily for us, we already have ways of knowing all that stuff. Since we are resetting the stateAge
variable every time we enter a new state, we can write a conditional that keeps us in a new state for a while, even if we are bored. Let’s set the limit to about eight minutes for now; we can always tune the value later if need be. To accomplish this task, we need to create a constant called STATE_ INTERVAL
and set its value to 500000 (Magic Eight Ball again), and then add this code after the UpdateTopThree()
method call:
//find the top three activity preferences for this cycle UpdateTopThree(); //check to see if we should change activities based on boredom if (stateAge < STATE_INTERVAL) { }
We can check to see if the player is in control by checking whether the character is currently the left-panel target. Adding this check to the preceding line allows us to kill two stones with one bird.
//check to see if we should change activities based on boredom if (stateAge > STATE_INTERVAL) { GUI.GuiManager gm = Game1.GameStateMan.GuiManager; CharacterManager cm = Game1.GameStateMan.CharManager; if (((gm.LeftTarget != null) && (!Name.Equals(gm.LeftTarget.Name))) || (gm.LeftTarget == null)) { } }
As you can see, this test is not as simple as it may appear. What we are checking is whether there is a left target in the GuiManager
and, if so, whether this character’s name is the same as the left target’s name. Whew. The problem is we have to have a left target selected in the GUI to pass that test alone, so we must add a separate test to see if there is no left target. Now, if that is true, or if the first part is true, we should pass this test, so we need the logical or.
Great, we’ve now guarded against changing behaviors willy-nilly and changing behaviors if the player is in control. Now we need to decide what to do. Let’s agree that any character whose affinity value for the current activity is between 0 and −50 will be mildly motivated to find a new activity that increases excitement while also increasing the attraction to a character at the same time. Let’s also say that an affinity value between −75 and −50 will create a strong motivation, and that a value between −100 and −75 will yield an actual compulsion to change. Let’s start with the first case, since it is kind of easy (although complicated):
Let’s start with the first case, since it is kind of easy (although complicated):
if (((gm.LeftTarget != null) && (!Name.Equals(gm.LeftTarget.Name))) || (gm.LeftTarget == null)) { String temp = null; Character targ = null; float test = activities[sID]; //if we're mildy bored, try to find a new behavior that will increase excitement and make a friend if ((test < 0f) && (test > -50f)) { //find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (cm.GetToon(i) == this) { continue; } if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); break; } } //if we don't hate *everyone*, if (targ != null) { int beh = 0; float val = GetActivityAffinity(beh); float val2 = GetActivityAffinity(1); //find the interactive activity with the highest affinity if (val2 > val) { beh = 1;
val = val2; } val2 = GetActivityAffinity(7); if (val2 > val) { beh = 7; val = val2; } val2 = GetActivityAffinity(9); if (val2 > val) { beh = 9; val = val2; } val2 = GetActivityAffinity(10); if (val2 > val) { beh = 10; val = val2; } val2 = GetActivityAffinity(11); if (val2 > val) { beh = 11; } //only make the change if the activity will be positive if (activities[beh] > -10f) { //if so, cause an FSM transition as appropriate temp = fsm.GetStateId(beh); } } }
All we are doing here is finding the interactive activity with the highest affinity and, if we don’t absolutely hate doing it, selecting it. We also pick the first character we come across that we are able to stand at all.
The next case is also somewhat complicated—but easy. In this case, we will pick one of the three favorite activities, and if it is an interactive state, pick the first character we come across that we don’t hate. If it is a non-interactive state, we do that instead.
//or if we are majorly bored, try to find any behavior that's better than this else if ((test < -49.9f) && (test > -75f)) { int beh = -1; int sID = fsm.GetStateIdKey(fsm.CurrentState.Identifier); if (sID != bestThree.best1) { beh = bestThree.best1; } else if (sID != bestThree.best2) { beh = bestThree.best2; } else if (sID != bestThree.best3) { beh = bestThree.best3; } if (beh >= 0) { switch (beh) { case 0: case 1: case 7: case 9: case 10: case 11: temp = fsm.GetStateId(beh); //find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves…
if (cm.GetToon(i) == this) { continue; } if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); break; } } if (targ == null) { if (cm.GetToon(0) == this) { targ = cm.GetToon(1); } else { targ = cm.GetToon(0); } } break; default: temp = fsm.GetStateId(beh); break; } } }
The final case will be even easier yet. We will try to pick one of the top three, but if that fails, we will randomly select a non-interactive state:
//or if insanely bored, DO ANYTHING ELSE!! else if (test < -74.9f) { int sID = fsm.GetStateIdKey(fsm.CurrentState.Identifier); int beh = -1; if (sID != bestThree.best1) { beh = bestThree.best1; }
else if (sID != bestThree.best2) { beh = bestThree.best2; } else if (sID != bestThree.best3) { beh = bestThree.best3; } if (beh >= 0) { switch (beh) { case 0: case 1: case 7: case 9: case 10: case 11: temp = fsm.GetStateId(beh); //find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (cm.GetToon(0) == this) { continue; } targ = cm.GetToon(i); } if (targ == null) { temp = null; } break; default: temp = fsm.GetStateId(beh); break; } } if (temp == null)
{ beh = rand.Next(fsm.StateCount); while (beh != sID) { beh = rand.Next(fsm.StateCount); } //pick a unary behavior switch (beh) { case 0: beh = 2; break; case 1: beh = 3; break; case 7: beh = 4; break; case 9: beh = 5; break; case 10: beh = 6; break; case 11: beh = 8; break; default: //go to sleep beh = 5; break; } temp = fsm.GetStateId(beh); } }
Finally, if we have a behavior selected, we need to do the same kind of setup as we did in the CollisionHandler
class to make sure all the states are initialized correctly. Here’s the code:
if (temp != null) {
switch (temp) { //handle the mutual interaction states first (DANCE_WITH, //FIGHT,CHAT) case "DANCE_WITH": fsm.Token = temp; fsm.ExecuteTransition(false); if (fsm.CurrentState.GetType().Name.Equals("DanceWith State")) { (fsm.CurrentState as Logic.CharacterFSMStates. DanceWithState).InitCharacter = this; fsm.CurrentState.Target = targ; } break; case "FIGHT": fsm.Token = temp; fsm.ExecuteTransition(false); if (fsm.CurrentState.GetType().Name.Equals("FightState")) { (fsm.CurrentState as Logic.CharacterFSMStates. FightState).InitCharacter = this; fsm.CurrentState.Target = targ; } break; case "CHAT": fsm.Token = temp; fsm.ExecuteTransition(false); if (fsm.CurrentState.GetType().Name.Equals("ChatState")) { (fsm.CurrentState as Logic.CharacterFSMStates. ChatState).InitCharacter = this; fsm.CurrentState.Target = targ; } break; //then states with special processing (GOTO) case "GOTO": fsm.Token = temp; fsm.ExecuteTransition(false); if (fsm.CurrentState.GetType().Name.Equals("GoToState")) { fsm.CurrentState.Target = targ;
if (!(fsm.CurrentState as Logic.CharacterFSMStates. GoToState).FirstRun) { (fsm.CurrentState as Logic.CharacterFSMStates. GoToState).FirstRun = true; } } break; //then the rest of the interactive states case "CHASE": case "EVADE": fsm.Token = temp; fsm.ExecuteTransition(false); fsm.CurrentState.Target = targ; break; //then all the non-interactive states default: fsm.Token = temp; fsm.ExecuteTransition(false); fsm.CurrentState.Target = null; break; } targ = null; temp = null; fsm.CurrentState.AIControl = true; } } }
All that’s left is a lot of little bookkeeping tasks. Oh, and a new property slipped into State.cs when you weren’t looking. (You were probably hunting for a Twinkie!) All I’ve done is add a private Boolean member named isAI
and the AIControl
property. This will help the game know whether the player or the AI is in control of the character (which turns out to be pretty important unless you want the player to be really frustrated when the character he is controlling done R-U-N-N-O-F-T).
The only other bit worth discussing here is the ExecuteTransition(false)
business. We originally wrote those methods so we would have a way of ignoring any code that tried to set GUI buttons when it shouldn’t. You know, like when the AI is changing states, so we have a perfect opportunity to use them here. This allows the AI to change states without messing up the GUI for the character currently selected.
Okay, on to the bookkeeping, rapid-fire style. In the CollisionHandler.DoGui PanelCollision()
method, we have to tell the new state selected that it is not under AI control. We do this by changing this code:
gm.LeftTarget.FSM.Token = temp; gm.LeftTarget.FSM.ExecuteTransition();
to this:
gm.LeftTarget.FSM.Token = temp; gm.LeftTarget.FSM.ExecuteTransition(); gm.LeftTarget.FSM.CurrentState.AIControl = false;
for all those cases.
Next, we need to make a couple of small changes to the MouseHandler.Update()
method:
//handle left click if (oldState.LeftButton == ButtonState.Pressed && mouseState.LeftButton ButtonState.Released) { //a mouse click occurred mouse = mice[(int)MouseTypes.LEFTCLICK1]; deltaT = 0f; if (mouseOver != -1) { gm.ClosePanel(GUI.GuiPanel.PanelType.RIGHT); if (cm.LeftMouseTarget != null) { if (mouseOver != cm.GetToonId(cm.LeftMouseTarget)) { cm.LeftMouseTarget.FSM.CurrentState.AIControl = true; } } cm.SetDrawMask(mouseOver); gm.EnableAllLeftPanelButtons(); gm.ResetAllLeftPanelButtons(); cm.UpdateLeftGui(mouseOver); cm.LeftMouseTarget.FSM.CurrentState.AIControl = false; if (cm.LeftMouseTarget.FSM.CurrentState.Target != null) {
gm.RightTargetId = cm.GetToonId(cm.LeftMouseTarget.FSM. CurrentState.Target); gm.OpenPanel(GUI.GuiPanel.PanelType.RIGHT); cm.UpdateRightGui(gm.RightTargetId); gm.EnableAllRightPanelButtons(); gm.ResetAllRightPanelButtons(); } cm.GetToon(mouseOver).FSM.CurrentState.SetButtons(); cm.GetToon(mouseOver).FSM.CurrentState.TurnOnButton(); }
Now we’re almost done, but if we run it the way it is, we will get into mysterious-disappearing-body-graphics-fu whenever the characters are woken up to change state. We will fix that in advance (thank goodness for Magic Eight Balls and their omniscient coding abilities) by adding a new Boolean and property to the Character
class (called fsmUp
and FSMUpdate
, respectively), and then making two changes. The first is in Character.Update()
:
if (isSleeping) { CloseEyes(); leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; } else if ( wasSleeping ) { wasSleeping = ! OpenEyes(); if (!wasSleeping) { deltaT = 0; blinking = false; blinkClosed = false; } leye.Position = pos; reye.Position = pos; leye.Update(gameTime); reye.Update(gameTime); return; }
if (fsmUp) { fsm.Update(gameTime); fsmUp = false; }
The second is in the CharacterManager.Update()
method:
public void Update(GameTime gameTime)
{
deltaTime += (float)gameTime.ElapsedGameTime.TotalMilliseconds;
if (deltaTime > INTERVAL)
{
for (int i = 0; i < NUM_TOONS; i++)
{
toons[i].FSMUpdate = true;
}
deltaTime = 0f;
}
where we set the Boolean flag rather than executing the toons[i].FSM.Update()
call directly. This change will stop the state code from adding new and important instructions (a bunch of “honey-dos”) and blathering on about important information (a sale on boots) before the character is fully awake. (If only I could write this change into my wife’s finite state machine!)
Now, in the states themselves, they all need the following code at the very beginning of their Action()
methods:
if (self.WasSleeping) { return; }
which is just added insurance that no rowdy characters start executing their FSM.Update()
methods before they should.
We also need to ensure that all states have a full transition function (because the AI is not constrained at this point by the “appropriate” states as the player is). Also, the three major interactive states all require a bit of special handling to ensure that they don’t trump a character that the player is currently controlling (or step on the GUI). Here’s a sample extracted from the DanceWithState
:
if (target.FSM.CurrentState.Identifier != "DANCE_WITH" && init.Name.Equals (self.Name) && target.FSM.CurrentState.AIControl) { if (target.FSM.CurrentState is Logic.CharacterFSMStates.SleepState) { target.FSM.Token = "SLEEP"; target.FSM.ExecuteTransition(false); } target.FSM.Token = "DANCE_WITH"; target.FSM.ExecuteTransition(false); target.FSM.CurrentState.AIControl = true; target.FSM.CurrentState.Target = self; (target.FSM.CurrentState as CharacterFSMStates.DanceWithState). InitCharacter = self; }
Last but not least, the SleepState
transition code needs some attention. Basically, we will end up with two overridden methods like so:
/// <summary> /// Overrides the parent class Transition method in order to restart the update /// processing in the character. /// </summary> /// <param name="inputKey">input key into the transition data structure</param> /// <returns></returns> public override String Transition(int inputKey) { if ( ! isAI ) { //this is a player-controlled change of state if (inputKey == fsmRef.GetInputKey("SLEEP")) { self.IsSleeping = false; self.WasSleeping = true; if (LastState != null) { SetButtons(LastState); //this state should always return control to the preceding //state
return LastState; } else { SetButtons("STOP"); //no last state? then stop (in the name of love) return "STOP"; } } SetButtons(id); return id; } else { self.IsSleeping = false; self.WasSleeping = true; //this is an AI-controlled change of state return base.Transition(inputKey); } } /// <summary> /// Overrides the parent class Transition method in order to restart the update /// processing in the character. /// </summary> /// <param name="inputKey">input key into the transition datastructure</param> /// <param name="set">true to change the GUI buttons, false ow</param> /// <returns></returns> public override string Transition(int inputKey, bool set) { if (!isAI) { //this is a player-controlled change of state if (inputKey == fsmRef.GetInputKey("SLEEP")) { self.IsSleeping = false; self.WasSleeping = true; if (LastState != null) {
if (set) { SetButtons(LastState); } //this state should always return control to the preceding //state return LastState; } else { if (set) { SetButtons("STOP"); } //no last state? then stop (in the name of love) return "STOP"; } } if (set) { SetButtons(id); } return id; } else { self.IsSleeping = false; self.WasSleeping = true; //this is an AI controlled change of state return base.Transition(inputKey, set); } }
Ta-da! Little beasties with minds of their own, cavorting with reckless abandon. There are, however, a few annoying “features” at present. First, they all tend to bunch up in a corner. This is caused by two things: They are all selecting the same character to interact with a lot of the time, and we stop them dead in the corners with our collision detection. The latter is easy to resolve. Instead of stopping when the character hits a left or right edge, we will allow it to walk off the screen 100 pixels, and will then wrap it to the other side of the screen (this is called edge wrapping). Here’s how (changes are in bold):
public void CheckBounds() { Environment.BackgroundManager bgm = Game1.GameStateMan.BGManager; Character.CharacterManager cm = Game1.GameStateMan.CharManager; Character.Character worker; Boolean wpChanged = false; for (int i = 0; i < Character.CharacterManager.NUM_TOONS; i++) { worker = cm.GetToon(i); //check to see if we can get by with edge wrapping Rectangle test = new Rectangle(562, (int)worker.Waypoint.Y, 100, 100); //this is tricksy!! if the worker is out of bounds wrt the y axis, //AbortWaypoint should be called if (!bgm.InBounds(test)) { worker.AbortWaypoint(); } //our next step is to handle the offending edge(s)… Vector2 wp = worker.Waypoint; if (wp == Vector2.Zero) { wp = worker.Position; } if (worker.BOTTOM > bgm.BOTTOM) { worker.Position = new Vector2(worker.Position.X, bgm.BOTTOM - 75); } else if (worker.TOP < bgm.TOP) { worker.Position = new Vector2(worker.Position.X, bgm.TOP + 20); } if (worker.LEFT < bgm.LEFT - 100) { wp = new Vector2(bgm.RIGHT - 150, wp.Y);
worker.Position = new Vector2(bgm.RIGHT, worker.Position.Y); wpChanged = true; } else if (worker.RIGHT > bgm.RIGHT + 100) { wp = new Vector2(bgm.LEFT + 150, wp.Y); worker.Position = new Vector2(bgm.LEFT - 100, worker.Position.Y); wpChanged = true; } if (wpChanged) { worker.Waypoint = wp; wpChanged = false; } } }
If only the other problem were so easy. Fair warning, Valued Reader: Our state machine just got annoyingly complicated. Let’s look at the easy cases first. For example, not much needs to be changed for the unary states … yet. Let’s look at the states that require a target character of some kind, but don’t really require interaction from the target. Those states are EvadeState
, ChaseState
, and GoToState
. Let’s say that these states require targets as opposed to the binary interaction states (DanceWithState
, FightState
, and ChatState
), which require a single “partner.”
To avoid having every character in the game target the same extremely frog-like dude, let’s impose a constraint. Let’s say that any given character can only be targeted by up to three characters at any given time. To accomplish this, we just need to add a data structure in Character.cs to contain the active targets and then add some code to maintain it. Let’s add an int
array called targeters
, set its max length to 3, and initialize all its values to −1. We will need an index member, called targCnt
, to access the array. Then we will create the following property:
public Boolean AvailableTarget { get { return (targCnt < MAX_TARGETERS); } }
Now, any time a character wishes to target this character, it must first check this AvailableTarget
property. If the character is available, the targeting character needs to add its own ID number to the array in the targeted character. Once the targeting character is finished with the activity, it must remove its ID number from the targeted character.
Awesome! We need a few new methods in the character class:
/// <summary> /// Performs a check to see if the id passed in is already in the targeter's list /// </summary> /// <param name="id">Id number of the character to check</param> /// <returns>true if the character is already targeting this character</returns> public Boolean IsTargetedBy(int id) { for (int i = 0; i < MAX_TARGETERS; i++) { if (targeters[i] == id) { return true; } } return false; } /// <summary> /// Adds the character represented by the id number to this character's list of /// targeters. If that id is already in the list, it is ignored. /// </summary> /// <param name="id">The id of the character wanting to target this character /// </param> public void SetTarget(int id) { if (IsTargetedBy(id)) { return; } if (targCnt < MAX_TARGETERS) { targeters[targCnt] = id; targCnt++; } } /// <summary>
/// Safely removes the id number from this character's list of targeters and /// repacks the array. /// </summary> /// <param name="id">The id of the character no longer wanting to target this /// character</param> public void RemoveTarget(int id) { int del = -1; for (int i = 0; i < MAX_TARGETERS; i++) { if (targeters[i] == id) { targeters[i] = -1; del = i; break; } } if (del == -1) { return; } for (int i = del; i < MAX_TARGETERS - 1; i++) { targeters[i] = targeters[i + 1]; targeters[i + 1] = -1; } targCnt–; }
The last bits are to use these methods and this algorithm during action selection and when transitioning out of a given state. Allow me to delay showing the action selection code until after we’ve talked about tracking partners. As an example of a targeting state, let’s look at ChaseState
. In the Transition()
functions for the state, we need to add this code:
//maintain the target's targeter list if (target != null) { target.RemoveTarget(Game1.GameStateMan.CharManager.GetToonId(self)); }
Then we need to call the base.Transition()
method. Simple. Easy. Wonderfully uncomplicated.
The next bit is marvelously deceptive. At first glance, adding partner tracking seems like a breeze. We simply add an int
to Character.cs that tracks the partner’s ID number. Then, we add these accessors:
public int Partner { get { return partner; } set { partner = value; } } public Boolean AvailablePartner { get { if (Partner != -1) { return false; } return true; } }
Bam! Done, right? Wrong. Maintaining the partner
attribute gets extremely hairy when we turn on the AI—even more so if we allow the AI to run as fast as it wants (which is silly anyway, since at the moment, these little dudes will flip out and change states incredibly quickly).
As our first step toward maintaining the partner
attribute, let’s cap the execution speed of the AI processing and look at the action selection for both target states and partner states at the same time. The first thing we need is to do a bit of extra processing at the start of the Update()
method:
public void Update(GameTime gameTime) { //update the stateAge value, allowing the boredom attribute to do its thing if (lastState == fsm.CurrentState.Identifier) { updateMillies += gameTime.TotalGameTime.Milliseconds + changeDesire; int testVal = (int)(updateMillies / MILLIES_INTERVAL); if (testVal > 0) { stateAge += updateMillies; updateMillies = -1f; }
} else { updateMillies = 0f; stateAge = 0f; } //get the current activity code int actID = fsm.GetStateIdKey(fsm.CurrentState.Identifier); float delta = -1 * ACT_CHANGE; float attract = -1 * ATT_CHANGE;
What we are doing here is allowing a throttling of the number of executions per time frame, but allowing the stateAge
attribute to be maintained accurately. The changeDesire
attribute is just a little fluff to allow some characters to appear to want more variety than others. In Chapter 6, “Pretending to Be an Individual: Synthetic Personality,” we will use this value and make it meaningful. For now, it’s just a random float value.
As you can see, once the MILLIES_INTERVAL
has been exceeded, we update the stateAge
value and set updateMillies
to −1. We do this as an execution flag for the main AI block:
//restrict attribute changes and AI processing if (updateMillies == -1f) { updateMillies = 0f; //check to see if we should make a positive change because we like this // activity if (stateAge < POS_INTERVAL) { if (activities[actID] > -10f) { delta *= -1; attract *= -1; } }
Next up, we’ll jump down to the beginning of the action-selection code:
//check to see if we should change activities based on boredom if (stateAge > STATE_INTERVAL) {
GUI.GuiManager gm = Game1.GameStateMan.GuiManager;
CharacterManager cm = Game1.GameStateMan.CharManager;
if (fsm.CurrentState.AIControl)
{
I’ve swapped out that big ugly test to determine whether the character is the left target with a simple check of the Boolean State
property described earlier. As long as we remember to maintain this value accordingly, this test will work beautifully. We will update some code a bit later to ensure that we always have a good value for this in all states.
Let’s agree that if a character is enjoying the activity, but doesn’t like his target, he will try to change targets. Having agreed thusly, we’ll implement that now:
String temp = null; Character targ = null; int sID = fsm.GetStateIdKey(fsm.CurrentState. Identifier); int id = cm.GetToonId(this); float test = activities[sID]; //boredom; if ((fsm.CurrentState.Target != null) && (test > 0f)) { //we have a target, but are in an activity we like if (partner != -1) { //we have a partner, and one that we don't much //like… if (attraction[partner] < -10f) { //find a new partner for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (i == id) { continue; } if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); if (targ != null)
{ if (targ.AvailablePartner) { partner = i; targ.Partner = id; break; } else { targ = null; } } } } if (targ == null) { temp = "STOP"; } else { fsm.CurrentState.Target.Partner = -1; fsm.CurrentState.Target. FSM.CurrentState.Target = null; fsm.CurrentState.Target = targ; } } } else if (attraction[cm.GetToonId(fsm.Current State.Target)] < -10f) { //this is a target state, but we don't like //our target, so we can just switch targets for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (i == id) { continue; } if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); if (targ != null)
{ if (targ.AvailableTarget) { break; } else { targ = null; } } } } if (targ == null) { temp = "STOP"; } else { fsm.CurrentState.Target.RemoveTarget(id); targ.SetTarget(id); fsm.CurrentState.Target = targ; } } }
What we do in that chunk of code is identify whether we have a partner or merely a target. Either way, if we don’t like it, we try to find a new partner, and if we can’t, we stop the activity.
The easy action-selection case doesn’t change much, with the exception of trying to find a partner—in the same way as we did earlier:
//if we're mildy bored with the activity, try to find a new behavior that will
//increase excitement and make a friend
else if ((test < 0f) && (test > -50f))
{
//find someone to befriend (or increase attraction with)
for (int i = 0; i < cm.ToonCount; i++)
{
//don't select ourselves…
if (i == id)
{
continue;
} if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); if (targ != null) { if (targ.AvailablePartner) { break; } else { targ = null; } } } } //if we don't hate *everyone*, if (targ != null) { int beh = 9; float val = GetActivityAffinity(beh); float val2 = GetActivityAffinity(10); //find the interactive activity with the highest affinity if (val2 > val) { beh = 10; val = val2; } val2 = GetActivityAffinity(11); if (val2 > val) { beh = 11; } //only make the change if the activity will be positive if (activities[beh] > -10f) { //if so, cause an FSM transition as appropriate temp = fsm.GetStateId(beh);
targ.Partner = id; Partner = cm.GetToonId(targ); } else { targ = null; temp = null; } } }
The next case is only slightly more complicated in that we have to handle both partner states and target states:
//or if we are majorly bored with the activity, try to find any behavior that's // better than this else if ((test < -49.9f) && (test > -75f)) { int beh = -1; if (sID != bestThree.best1) { beh = bestThree.best1; } else if (sID != bestThree.best2) { beh = bestThree.best2; } else if (sID != bestThree.best3) { beh = bestThree.best3; } if (beh >= 0) { switch (beh) { case 0: case 1: case 7: temp = fsm.GetStateId(beh);
//find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (i == id) { continue; } if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); if (targ != null) { if (targ.AvailableTarget) { break; } else { targ = null; } } } } if (targ == null) { temp = null; } break; case 9: case 10: case 11: temp = fsm.GetStateId(beh); //find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (i == id) { continue; }
if (GetAttraction(i) >= -10f) { targ = cm.GetToon(i); if (targ != null) { if (targ.AvailablePartner) { targ.Partner = id; partner = cm.GetToonId(targ); break; } else { targ = null; } } } } if (targ == null) { temp = null; } break; default: temp = fsm.GetStateId(beh); break; } } }
I’m sure you are seeing the pattern here. The last case is again fairly straightforward:
//or if insanely bored with this activity, DO SOMETHING ELSE WITH ANYONE!! else if (test < -74.9f) { int beh = -1; if (sID != bestThree.best1) { beh = bestThree.best1; } else if (sID != bestThree.best2)
{ beh = bestThree.best2; } else if (sID != bestThree.best3) { beh = bestThree.best3; } if (beh >= 0) { switch (beh) { case 0: case 1: case 7: temp = fsm.GetStateId(beh); //find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (i == id) { continue; } targ = cm.GetToon(i); if (targ != null) { if (targ.AvailableTarget) { break; } else { targ = null; } } } if (targ == null) { temp = null;
} break; case 9: case 10: case 11: temp = fsm.GetStateId(beh); //find someone to befriend (or increase attraction with) for (int i = 0; i < cm.ToonCount; i++) { //don't select ourselves… if (i == id) { continue; } targ = cm.GetToon(i); if (targ != null) { if (targ.AvailablePartner) { targ.Partner = id; partner = cm.GetToonId(targ); break; } else { targ = null; } } } if (targ == null) { temp = null; } break; default: temp = fsm.GetStateId(beh); break; } } if (temp == null) { beh = rand.Next(fsm.StateCount);
while (beh == sID) { beh = rand.Next(fsm.StateCount); } //pick a unary behavior switch (beh) { case 0: beh = 2; break; case 1: beh = 3; break; case 7: beh = 4; break; case 9: beh = 5; break; case 10: beh = 6; break; case 11: beh = 8; break; default: //go to sleep beh = 5; break; } if (partner != -1) { partner = -1; } temp = fsm.GetStateId(beh); targ = null; } }
The only real change in the switch
statement that causes the transitions is that we need to add one line of code to each case. Immediately after the call to fsm.ExecuteTransition()
, we need to add this line of code in order to start maintaining the AIControl
property:
fsm.CurrentState.AIControl = true;
Of course, a similar change needs to happen in the CollisionHandler.DoGuiPanel Collision()
method, except there we will set AIControl
to false. Also, this method will need to have a GameTime
parameter added so that we can force the finite state machine to update when the player makes a selection.
Now for the fun part: In most states, we can get away with a few easy changes, so let’s start there. First, in the State
class, we need to add a few members with properties:
protected Character.Character lastTarget; //tracks the target from the last state (if any) protected Character.Character lastInit; //tracks the init from the last state (if any) protected Boolean lastAI; //was the last state AI controlled?
These properties will allow the AI to transition from any state to any state. To make this as easy as possible, we will maintain these by overriding the Finite StateMachine.ExecuteTransition()
method like this:
/// <summary> /// Overrides the parent method to track the last state attributes /// </summary> public override void ExecuteTransition() { Character i = null; if (CurrentState is Logic.CharacterFSMStates.FightState) { i = (CurrentState as Logic.CharacterFSMStates.FightState).InitCharacter; } else if (CurrentState is Logic.CharacterFSMStates.ChatState) { i = (CurrentState as Logic.CharacterFSMStates.ChatState).InitCharacter; } else if (CurrentState is Logic.CharacterFSMStates.DanceWithState) { i = (CurrentState as Logic.CharacterFSMStates.DanceWithState). InitCharacter; }
Character t = CurrentState.Target; Boolean a = CurrentState.AIControl; base.ExecuteTransition(); CurrentState.LastAIControl = a; CurrentState.LastInit = i; CurrentState.LastTarget = t; } /// <summary> /// Overrides the parent method to track the last state attributes /// </summary> /// <param name="set"></param> public override void ExecuteTransition(Boolean set) { Character i = null; if (CurrentState is Logic.CharacterFSMStates.FightState) { i = (CurrentState as Logic.CharacterFSMStates.FightState). InitCharacter; } else if (CurrentState is Logic.CharacterFSMStates.ChatState) { i = (CurrentState as Logic.CharacterFSMStates.ChatState).InitCharacter; } else if (CurrentState is Logic.CharacterFSMStates.DanceWithState) { i = (CurrentState as Logic.CharacterFSMStates.DanceWithState). InitCharacter; } Character t = CurrentState.Target; Boolean a = CurrentState.AIControl; base.ExecuteTransition(set); CurrentState.LastAIControl = a; CurrentState.LastInit = i; CurrentState.LastTarget = t; }
Yes, I agree—nothing terribly exciting there. But now that it’s done, our unary states don’t need to change much. We can get away with adding this line to the
if ((self.Partner != -1) && (self.Partner != Game1.GameStateMan.CharManager. GetToonId(self))) { self.Partner = -1; }
While that looks oogly, it really isn’t. To stop AI characters from grabbing the player-controlled character, we will set the player character’s partner to its own index value. In every other case that might cause us to be in a unary action method, the partner
value should be cleared (e.g., set to −1). Wham! Six states handled!
In the target states, we almost get off that easy, but we need to add a second transition method (for when the AI is in control) and a few extra lines in Action()
. Here is an example of the two Transition()
functions from the Evade State
followed by the necessary changes to its action state:
/// Overrides the parent class Transition method in order to restart the update /// processing in the character. /// </summary> /// <param name="inputKey">input key into the transition data structure</param> /// <returns></returns> public override string Transition(int inputKey) { //maintain the target's targeter list if (target != null) { target.RemoveTarget(Game1.GameStateMan.CharManager.GetToonId(self)); } //this is an AI-controlled change of state return base.Transition(inputKey); } /// <summary> /// Overrides the parent class Transition method in order to restart the update /// processing in the character. /// </summary> /// <param name="inputKey">input key into the transition datastructure</param> /// <param name="set">true to change the gui buttons, false otherwise</param>
/// <returns></returns> public override string Transition(int inputKey, bool set) { //maintain the target's targeter list if (target != null) { target.RemoveTarget(Game1.GameStateMan.CharManager.GetToonId(self)); } //this is an AI-controlled change of state return base.Transition(inputKey, set); } /// <summary> /// Implementation of abstract parent method. This method implements the /// behavior associated with this state. /// </summary> /// <param name="gameTime"></param> public override void Action(GameTime gameTime) { //maintain the target or stop if (target == null) { fsmRef.Token = "STOP"; fsmRef.ExecuteTransition(false); fsmRef.CurrentState.AIControl = AIControl; return; } if ((self.Partner != -1) && (self.Partner != Game1.GameStateMan. CharManager.GetToonId(self))) { self.Partner = -1; }
What we added to the Action()
call is a check to make sure that our target hasn’t disappeared when we weren’t looking, and if it has, to stop. The two Transition()
methods mainly let us keep track of AI-controlled versus player-controlled transitions and help maintain the GUI in its proper state, as long as the second method is called with a Boolean value of false.
We disrupt this torrent of code to bring you an important message from our Author. Again, I want to stop for a second and review where we are coming from and where we are going. It’s easy to get lost in all this code. We started by adding the ability for each character to decide on a new course of action based on its internal state. It is critical to believability and our final goals, however, that these AI decisions don’t end up looking stupid (e.g., character A goes off in the corner and fights with no one, A starts to dance with B, then C starts to chat with B, leaving A dancing with a character who is now ignoring it, etc.). Further, characters should be able to go back to what they were doing before they were (so rudely) interrupted.
Okay, back to our regularly scheduled discussion. Now, what you’ve all been waiting for: the real oogly mess, the code that made me pull my hair out and caused innumerable stack overflows … the partner
states update. Again, we will need two brand-new Transition()
methods (instead of the old Transition()
method) and some hefty changes to the Action()
method. I will use FightState
as an example because as I was writing this, I really wanted to fight someone.
Here’s the AI-controlled method:
/// <summary> /// Overrides the parent class Transition method in order to restart the update /// processing in the character. /// </summary> /// <param name="inputKey">input key into the transition datastructure</param> /// <param name="set">true to change the gui buttons, false otherwise</param> /// <returns></returns> public override string Transition(int inputKey, bool set) { if (Initiator.Equals(self.Name) && (target != null)) { String temp = fsmRef.GetInputToken(inputKey); if (self.Partner == Game1.GameStateMan.CharManager.GetToonId(target)) { if ((temp.Equals("STOP") || temp.Equals("FIGHT"))) { //maintain the target's partner target.Partner = -1; self.Partner = -1; target.FSM.Token = "STOP"; target.FSM.ExecuteTransition(false); target.FSM.CurrentState.AIControl = true; target.FSM.CurrentState.LastState = Identifier; target.FSM.CurrentState.LastTarget = self;
target.FSM.CurrentState.LastAIControl = AIControl; target.FSM.CurrentState.LastInit = init; } else if ((temp.Equals("CHAT") || temp.Equals("DANCE_WITH"))) { return base.Transition(inputKey, set); } else { //maintain the target's partner target.Partner = -1; self.Partner = -1; Character i = target.FSM.CurrentState.LastInit; Character t = target.FSM.CurrentState.LastTarget; Boolean a = target.FSM.CurrentState.LastAIControl; int targID = Game1.GameStateMan.CharManager.GetToonId(target); int partID = Game1.GameStateMan.CharManager.GetToonId(t); String recover = null; target.FSM.Token = target.FSM.CurrentState.LastState; target.FSM.ExecuteTransition(false); if ((target.FSM.CurrentState is EvadeState) || (target. FSM.CurrentState is ChaseState) || (target.FSM. CurrentState is GoToState)) { if (t != null) { if (t.AvailableTarget) { target.FSM.CurrentState.Target = t; t.SetTarget(targID); } else { recover = "STOP"; } } else { recover = "STOP"; }
} else if (target.FSM.CurrentState is ChatState) { if (t != null) { if (t.AvailablePartner) { target.Partner = partID; t.Partner = targID; target.FSM.CurrentState.Target = t; (target.FSM.CurrentState as ChatState). InitCharacter = target; } else { recover = "STOP"; } } else { recover = "STOP"; } } else if (target.FSM.CurrentState is FightState) { if (t != null) { if (t.AvailablePartner) { target.Partner = partID; t.Partner = targID; target.FSM.CurrentState.Target = t; (target.FSM.CurrentState as FightState). InitCharacter = target; } else { recover = "STOP"; } } else {
recover = "STOP"; } } else if (target.FSM.CurrentState is DanceWithState) { if (t != null) { if (t.AvailablePartner) { target.Partner = partID; t.Partner = targID; target.FSM.CurrentState.Target = t; (target.FSM.CurrentState as DanceWithState). InitCharacter = target; } else { recover = "STOP"; } } else { recover = "STOP"; } } if (recover != null) { target.FSM.Token = recover; target.FSM.ExecuteTransition(set); } target.FSM.CurrentState.AIControl = true; target.FSM.CurrentState.LastState = this.Identifier; target.FSM.CurrentState.LastInit = this.init; target.FSM.CurrentState.LastTarget = this.target; target.FSM.CurrentState.LastAIControl = AIControl; } } } else if ((target != null) && this.AIControl && (target.FSM.CurrentState is FightState)) {
Boolean a = target.FSM.CurrentState.LastAIControl; Character i = target.FSM.CurrentState.LastInit; Character t = target.FSM.CurrentState.LastTarget; target.Partner = -1; target.FSM.Token = target.FSM.CurrentState.LastState; target.FSM.ExecuteTransition(set); target.FSM.CurrentState.AIControl = a; if (target.FSM.CurrentState is FightState) { (target.FSM.CurrentState as FightState).InitCharacter = i; } else if (target.FSM.CurrentState is ChatState) { (target.FSM.CurrentState as ChatState).InitCharacter = i; } else if (target.FSM.CurrentState is DanceWithState) { (target.FSM.CurrentState as DanceWithState).InitCharacter = i; } if ((target.FSM.CurrentState is EvadeState) || (target.FSM.Current State is ChaseState) || (target.FSM.CurrentState is GoToState)) { if ((t != null) && (t.AvailableTarget)) { target.FSM.CurrentState.Target = t; t.SetTarget(Game1.GameStateMan.CharManager.GetToonId(target)); } else { target.FSM.Token = "STOP"; target.FSM.ExecuteTransition(false); } } target.FSM.CurrentState.LastState = Identifier; target.FSM.CurrentState.LastTarget = self; target.FSM.CurrentState.LastInit = init; target.FSM.CurrentState.LastAIControl = AIControl; self.Partner = -1; } else { self.Partner = -1; }
//this is an AI-controlled change of state return base.Transition(inputKey, set); }
Yeah … yech. All that code spent maintaining who is allowed to move when. The player-controlled method is much the same, with the exception of all assignments to AIControl
and the following line, which must occur before both of the return statements:
if (!AIControl) { self.Partner = Game1.GameStateMan.CharManager.GetToonId(self); }
The last line is so we can maintain player control after leaving a player-controlled binary interaction state. I burned a lot of brain cells tracking that nasty bit down; leaving it out pretty much breaks the indirect control portion of the game.
The Action()
method was equally painful, albeit shorter. Basically, the combination of an Action()
method that forces transitions and a Transition()
method that can force transitions in other characters gets pretty ugly. Maintaining the state while allowing the player to maintain control of the characters he is interacting with requires a lot of processing. Here are the changes to the code for FightState
:
//maintain the target or stop if ((target != null) && (init != null) && (!init.Name.Equals(self.Name) && !init. Name.Equals(target.Name))) { if ((self.Partner == Game1.GameStateMan.CharManager.GetToonId(target)) && ((target.Partner == Game1.GameStateMan.CharManager.GetToonId(self)) || target.Partner == -1)) { init = self; } else if ((self.Partner == Game1.GameStateMan.CharManager.GetToonId(init)) && (init.Partner == Game1.GameStateMan.CharManager.GetToonId(self))) { target = init; } else { init = self;
} } else if (((target != null) && target.Name.Equals(self.Name)) || (target == null)) { fsmRef.Token = "STOP"; fsmRef.ExecuteTransition(false); fsmRef.CurrentState.AIControl = AIControl; return; } //guarded update of the ai attribute for increasing boredom if (self.LastStateId != Identifier) { self.LastStateId = Identifier; } if (target.FSM.CurrentState.Identifier != "FIGHT" && Initiator.Equals(self. Name) && target.FSM.CurrentState.AIControl) { if ((target.AvailablePartner) || (!AIControl)) { target.Partner = Game1.GameStateMan.CharManager.GetToonId(self); } else if ((target.Partner != Game1.GameStateMan.CharManager.GetToonId (self)) && AIControl) { self.Partner = -1; target = null; return; } if (target.FSM.CurrentState is Logic.CharacterFSMStates.SleepState) { target.FSM.Token = "SLEEP"; target.FSM.ExecuteTransition(false); target.FSM.Update(gameTime); } target.FSM.Token = "FIGHT"; target.FSM.ExecuteTransition(false); target.FSM.CurrentState.AIControl = AIControl; target.FSM.CurrentState.Target = self; (target.FSM.CurrentState as CharacterFSMStates.FightState).InitCharacter = self; target.FSM.Update(gameTime);
} else if ((init != null) && (!init.Name.Equals(self.Name) && !(init.FSM. CurrentState is FightState))) { Boolean a = fsmRef.CurrentState.LastAIControl; Character i = fsmRef.CurrentState.LastInit; Character t = fsmRef.CurrentState.LastTarget; fsmRef.Token = LastState; InitCharacter = null; Target = null; fsmRef.ExecuteTransition(false); fsmRef.CurrentState.AIControl = a; if (fsmRef.CurrentState is FightState) { (fsmRef.CurrentState as FightState).InitCharacter = i; } else if (fsmRef.CurrentState is ChatState) { (fsmRef.CurrentState as ChatState).InitCharacter = i; } else if (fsmRef.CurrentState is DanceWithState) { (fsmRef.CurrentState as DanceWithState).InitCharacter = i; } if ((fsmRef.CurrentState is EvadeState) || (fsmRef.CurrentState is ChaseState) || (fsmRef.CurrentState is GoToState)) { if ((t != null) && (t.AvailableTarget)) { fsmRef.CurrentState.Target = t; t.SetTarget(Game1.GameStateMan.CharManager.GetToonId(target)); } else { fsmRef.Token = "STOP"; fsmRef.ExecuteTransition(false); } } fsmRef.CurrentState.LastState = Identifier; fsmRef.CurrentState.LastTarget = self; fsmRef.CurrentState.LastInit = init; fsmRef.CurrentState.LastAIControl = AIControl;
return; } if (self.WasSleeping) { return; }
Hey, I never said AI was easy. Just be thankful I resisted the urge to add n-ary interaction states!
There are two little Easter eggs buried in my version on the CD. The first is a marvelous ability to click anywhere outside the left or right panel, but still in the GUI area, and de-select your left target, reverting it back to AI control. The second is a nice huge debug information drop in the top GUI panel (see Figure 5.11), which lets us see internal variables, such as the state, target, initiating character for the
state, boredom, etc.—all crucial information when trying to figure out why the VGAI is doing something stupid.
At any rate, with similar code in all three binary interaction states, we have a complete and functioning VGAI. Run it—heck, let it run overnight—and see what you think. It’s interesting to note that there’s very little academic AI here. I mean, we’ve got finite state machines and some basic decision making, but nothing that would fit the bill for any kind of strong AI.
If this were a complete game with objectives and goals for the characters and the player, this system could be easily adapted to provide a fully featured single-player game. The cognitive fidelity would probably be adequate if we stopped with this basic functionality, given the design of the game.
We’re not stopping here, however (just the chapter is). In Chapter 6, we will adapt this limited AI to use modeled emotion and personality as inputs and to cause changes in those two systems in response to the AI and other characters. This will allow us to get even closer to that magical place where video-game characters are believable, fun, interactive, engaging, and immersive.
3.146.255.87