Getting started part 6: Dragon Eggs - Getting serious about AI
If you didn't complete tutorial 5 but still want to play along, grab this: DragonEggs_Tutorial6Start.zip
So far the player is using the default movement speeds, let's tweak it a bit.
Let's speed him up. Modify your player.lua's OnPostInit() function as follows: (the changed parts are in bold text)
function OnPostInit() //run after being placed on a map this:SetDesiredSpeed(12); this:SetMaxMovementSpeed(12); this:SetAcceleration(1); LogMsg("Hi World! My name is " .. this:GetName() .. " and I like long romantic walks on the beach"); ... snipped ..
After using SetDesiredSpeed, it's a good idea to set the SetMaxMovementSpeed to make sure it's possible to move at the desired speed. SetAcceleration gives us more 'oomph' when we start moving. This feels less sluggish because we can reach the top speed quicker.
Giving the Dragon eyes
The last thing we did on the dragon was give him a basic script that had him walk to each checkpoint. Let's get a bit more advanced now. Here is what we want:
- Randomly choose checkpoints to patrol
- If he sees the player, will drop what he's doing and chase him at a faster pace
- Will stop chasing the player if he gets too far away
- Will always be chasing/patrolling, even if the dragon is offscreen
- Can't see the player through walls
Ok. We've got a lot to do so let's get started. First, replace the function AddPatrolGoals with this one:
function AddPatrolGoals() local pointName = "point" .. random(1, 4); //a random number between 1 and 4. this:GetGoalManager():AddApproach(GetEntityByName(pointName):GetID(), C_DISTANCE_CLOSE); this:GetGoalManager():AddRunScriptString("AddPatrolGoals()"); end
Now the dragon is less predictable and will run around the map forever because after hitting a check point, it will run AddPatrolGoals() again which will choose a new checkpoint.
Next, add a new function called OnMapInsert.
function OnMapInsert() //run as the entity is placed on an actual map, but before OnPostInit() GetWatchManager:Add(this, C_TIME_FOREVER); //always function offscreen endNow the dragon will start 'thinking' and be active as soon as the map loads and will function offscreen.
Tip: If a function called OnMapInsert exists, it will be run when the entity is placed on the map. As opposed to OnPostInit() which isn't run the entity comes onscreen, generally.
Normally an entity like the dragon doesn't operate when it isn't visible. This allows us to have very large levels without slow-down.
However, in some cases you want an Entity to move no matter where he is. The WatchManager lets us specify that the Dragon is ALWAYS "on", no matter where he is in relation to what the camera is currently showing on screen.
The WatchManager can also accommodate tricky things like "let him move around for 10 seconds after he's off the screen" and such to avoid the player from thinking "hey, everything freezes when I'm not there, dumb! ".
Now for seeing the player and chasing him, modify the OnPostInit() and add the new functions LookAround() and SetupPlayerChase() as follows.
function OnPostInit() //run during the entity's first logic update this:GetBrainManager():Add("StandardBase",""); AddPatrolGoals(); LookAround(); this:SetTurnSpeed(6); //turn instantyl this:SetAcceleration(1); //speed up faster end function LookAround() //we already chasing him? if (this:GetGoalManager():IsGoalActiveByName("ChasingPlayer")) then //we're already chasing him. Should we stop? //if we're far away AND can't see him, yes, stop if (this:GetDistanceFromEntityByID(g_playerID) > 400 and not this:HasLineOfSightToPosition(GetPlayer:GetPos(), true) ) then //he's not visible and more than 400 pixels away. Stop. GetTextManager:Add("(panting) Where did he go?", this); this:SetDesiredSpeed(2.4); //walk speed again //cancel the chase, start patrolling this:GetGoalManager():RemoveAllSubgoals(); //stop whatever we were doing AddPatrolGoals(); this:GetGoalManager():PushDelay(2000); //wait two seconds first else //the player is still in range. Let's recalcuate our chase path. SetupPlayerChase(); end else //not currently chasing player. But should we? //is the player close? if (GetPlayer != nil and this:GetDistanceFromEntityByID(g_playerID) < 700 and this:HasLineOfSightToPosition(GetPlayer:GetPos(), true)) then //We can see the player! this:SetDesiredSpeed(5); //start running SetupPlayerChase(); GetTextManager:Add("I see you!", this); end end //call this function again, randomly chooses a time to look again Schedule(random(500,1000), this:GetID(), "LookAround()"); end function SetupPlayerChase() //cancel other goals and start chasing the player. this:GetGoalManager():RemoveAllSubgoals(); //stop whatever we were doing local goal = this:GetGoalManager():AddNewGoal("ChasingPlayer"); goal:AddApproachAndSay("(breaths fire)", g_playerID, C_DISTANCE_INSIDE); end
Ok, LookAround() was a bit of a monster function. Let me explain the key ideas here.
1. After looking around, it schedules LookAround() (itself) to be called again, this will happen throughout the dragon's life, regardless of what its GoalManager is doing. Schedule() is very useful to do things at regular intervals.
2. We schedule with a slightly random time, this is good practice, because if we have 20 dragons we don't want them all running this function at the exact time. The randomness staggers it out, making game play smoother for CPU intensive things like the HasLineOfSightToPosition check.
3. We use the globals g_playerID and GetPlayer, which returns the players ID and the player Entity object respectively. We could have also used GetEntityByName("Player") but using the global is a little faster. How did it know who the player is? That's what AssignPlayerToCameraIfNeeded(this); does in our player.lua.
Tip: A game does not NEED an official "player" entity, but there are several handy things we can do if we have one, as we'll see when we setup the dragon/player collision a bit later.
4. When we see the player, we clear all goals and add some new ones under a subgoal called "ChasingPlayer". The reason I did that was so I could easily check for that goal name later, to see if we just found the player or I was already chasing him.
5. I added the SetTurnSpeed call so he won't appear to "slow down" at corners. If we were using a visual profile with 'directions', it would look cool to turn, but for now it just looks weird because the same sprite is used for all directions.
6. We're assuming the dragon can see in all directions at once, because we only have the one sprite. However, we could easily limit his vision with a check to IsFacingTarget if we wanted to.
Now, to really see what is going on, enable Display->Show Entity Goal and AI Data and watch how the tree of goals appear and disappear as the script kills and adds them.
Time 2 Die
This isn't really a game until the dragon can actually kill the player, right?
Let's do that now by setting up a collision callback so when the dragon touches the player, he will call a function called OnDamage() on the player if it exists. Then, the player can handle however he wants to die himself. (Instead of trying to do it all from the dragon's script)
First, we'll let the dragon know we want to know in a script callback when collisions with the 'player' Entity occur.
Change the dragon.lua's OnPostInit() as follows:
function OnPostInit() //run during the entity's first logic update this:GetBrainManager():Add("StandardBase",""); AddPatrolGoals(); LookAround(); this:SetTurnSpeed(6); //turn instantly this:SetCollisionListenCategory(C_CATEGORY_PLAYER, true); end
Now we will receive information when any entity that is marked with the "C_CATEGORY_PLAYER" flag is touched. But we'll get an error unless we add a function called OnCollision to the dragon.lua script. Do so as follows:
function OnCollision(vPosition, vVelocity, vNormal, depth, materialID, entity, state) LogMsg("Hit the player!"); entity:RunFunction("OnDamage", vNormal, depth, nil, 5, 0, this); //let the player know we hit him and extra junk he may care about. end
If you play the game now and the dragon touches the player, nothing will happen! What's wrong? Well, we forgot to mark the player with the player flag. Add these two new lines:
function OnPostInit() //run during the entity's first logic update this:SetCategories(C_CATEGORIES_NONE); this:SetCategory(C_CATEGORY_PLAYER, true); this:SetDesiredSpeed(12); this:SetMaxMovementSpeed(12); this:SetAcceleration(1); ...more junk...Now, if you play the game and the dragon attacks the player, you'll see that the Dragon's OnCollision() call we added gets notified, and an error about the player.lua not having an OnDamage() is displayed. Let's add that now to the player.lua.
//entParent is whoever sent the damage, entObject is the actual damaging object/projectile if not nil function OnDamage(normal, depth, entParent, strength, extra, entObject) if (this:VariableExists("m_isDead")) then //we're already dead. ignore this return; end m_isDead = true; //create a variable so we know we're dead. //set it so we can't be controlled by input anymore GetInputManager:RemoveBindingsByEntity(this); //stop moving, in case we were ResetKeys(); //flash red for a long time this:GetBrainManager():Add("ColorFlash", "pulse_rate=50;g=-200;b=-200;remove_brain_by_pulses=1000"); this:GetGoalManager():AddSay("Aaaarrrrgggghhh!", C_FACING_NONE); this:GetGoalManager():AddSay("I guess the game is over. Press Ctrl-Shift-R to restart or reinit " .. "the player by hitting F1 and then dragging him a few pixels.", C_FACING_NONE); end
Now if you play, the dragon can actually hurt you. We're making some progress now. The dragon is a little too easy to dodge. No problem, cut and paste three more dragons onto the map and have them start in strategic postions. If you find they are getting stuck somewhere, add more path-nodes around that area, the dragon will use them to calculate alternate routes in conjested areas.
The heart of the game - The dragon eggs
When a player touches an egg, this is what should happen:
- A sound effect plays
- Egg shrinks down to nothing
- "(egg collected)" text should be displayed over the player
- If it's the LAST egg, text about winning should be displayed
Convert your egg tile you made earlier into an entity and set its script to "egg.lua" and click edit. (Note, this may change its visual alignment to 'centered', you can use the visual alignment editor to turn it back to "up left" so the collision we made earlier still fits right)
'Cut and paste this in for the egg.lua script:
function OnInit() //run upon initialization this:SetIsCreature(true); //hint to engine to ignore us when calculating pathfinding end function OnMapInsert() //count how many eggs have been created if (this:VariableExists("g_eggCount")) then //global already exists. Let's use it. _G.g_eggCount = g_eggCount + 1; else //no global exists yet, let's create it so all eggs can use it _G.g_eggCount = 1; //the _G. is the only way to set a global end end function OnPostInit() //run during the entity's first logic update //let's not actually 'collide' with anything, otherwise the dragons could push us around this:SetCollisionCategories(C_CATEGORIES_NONE); //no collision at all //register to get OnCollision calls when the player walks though us. this:SetCollisionListenCategory(C_CATEGORY_PLAYER, true); end function OnKill() //run when removed RemoveEggFromGlobalCount(); end function RemoveEggFromGlobalCount() if (this:VariableExists("m_removedEgg")) then //already removed it. return; end _G.g_eggCount = g_eggCount - 1; m_removedEgg = true; end function OnCollision(vPosition, vVelocity, vNormal, depth, materialID, entity, state) //if this is called, we must have hit the player, which will be passed as the "entity" var. //first, turn off our collision 'listening' so this won't get called again this:SetCollisionListenCategory(C_CATEGORY_PLAYER, false); //don't be notified anymore //sound effect local soundID = this:PlaySound("audio/gui/change_selection1.ogg"); GetSoundManager:SetSpeedFactor(soundID, 0.5); //slow down the sound //scale down to nothing and then delete itself this:GetBrainManager():Add("Scale", "scalex=0.1;scaley=0.1;scale_speed_ms=500;delete_entity=true"); //let the player know where he stands RemoveEggFromGlobalCount(); if (g_eggCount > 0) then //still more eggs after this GetTextManager:Add("(egg collected, " .. g_eggCount .. " left)", entity); else //this is the last egg! GetTextManager:Add("You collected the last egg, Huzzah! Uh. You win?", entity); end end
This looks a bit intimidating so let me break it down a bit.
1. We used OnMapInsert() again to get the script to do something right as it is added to the map. This is a perfect way to add 1 to a global variable so we can keep track of how many eggs exist.
2. If g_eggCount doesn't exist, we create it as a global by putting "_G. " in front of it when we assign something to it. If we didn't do that, a vairable called g_eggCount would be created in our LOCAL namespace per egg, which is not what we want. It's a lua thing, don't fight it.
3. We used VariableExists() to check for the global, because otherwise we'd get a "you tried to read from an unitted var" error if we tried to test it for nil or something.
4. The RemoveEggFromGlobalCount() function is designed so even if we call it more than once, it will only remove 1 from the global g_eggCount time. That's because I run it in the OnCollision, but I also run it in the OnKill(). Because what if an egg is deleted in the editor? We would still want g_eggCount decremented.
Scatter the eggs around the level (make sure you're editing the "master" files), place everything just so and then restart and click "New" to run as the player.
Don't forget to delete your original starting egg, otherwise the level will be impossible to finish because the player can't reach that egg!
How does it play? Well, my dragons were getting stuck against eachother in the hallways, so I added some more path-nodes.
Here is what I have now:
If you want to play my version, here it is.
It's now playable! But wouldn't it be nice to have a player that is actually animated? Let's see what we can do about that...
Tip: Remember to restart and click new when playing your game. Don't play in the editor! If you do, your eggs won't come back and the dragons won't start in the same place every time.
© Copyright Seth A. Robinson and Robinson Technologies