They're pretty simple enemies. If you recognize any of these from the Zelda series, you probably already know what they do:
These spike things (which the Internet actually tells me are called 'blade traps') lie waiting until the player crosses their horizontal or vertical axis, at which point they charge at the player. When they hit another spike, or a wall, they return to their original location. I thought this would be a simple enough starting point for implementing a very basic AI control mechanism.
I actually added a twist to mine--the player has half a second to get out of the way upon 'waking' the spike enemy before it charges at them.
The spike, therefore (at least, my implementation of it) has four states:
Standard - The spike's default state. It's at rest, waiting for the player to approach it
Waking - The player has woken the spike and has 0.5 seconds to get out of the way. Depending on this, the spike will either return to 'Standard', or move to...
Chasing - The spike moves in a straight horizontal or vertical line in the appropriate direction.
Returning - The spike goes back to its original position, and then returns to the Standard state.
This is probably the best place for a video, which shows the thing in action:
So, what did I have to do to get this working? A few things. As you might know from previous updates, entities function by having scripts (which could also be described as behaviors) attached to them. The following scripts are currently supported by the infrastructure:
Init - Called immediately when the entity is loaded
Update - Called every n to m seconds, depending on how the designer specifies. An update could be called every .5 seconds, every .03 seconds, or randomly every 1 to 4 seconds.
Collide - Occurs when the entity collides with another one. A previous post describes setting up collision categories for the collide event.
Enter - Occurs when the entity collides with another one, but doesn't occur again until they have 'uncollided' and collided again.
Leave - The complement event to 'enter'.
Trigger - This is a custom behavior which the engine doesn't call directly -- it is meant to be called by other scripts. For example, an enemy might call a custom 'damage' trigger (with custom arguments--say, the damage type and the strength) on another entity.
So, that's a quick recap of the scripting system I've set up. It's fairly simple, but allows for infinite possibilities.
I decided I'd implement the Entity State system by introducing an Entity State Collection, which consists of:
- The current state
- A collection of states.
An Entity State consists of:
- The name of the state (i.e. 'waking', 'chasing', etc.)
- A collection of script behaviors--collide, update, init, etc. Init is called when the state is changed.
So now, I can cleanly isolate scripting behaviors to certain states. In addition, entities still have a 'global' implied state; that is, script behaviors that are always running no matter what state the entity is in. For example, the player might leave footsteps behind (implemented by an Update script that runs every half second) whether he/she is walking, running, being knocked back, etc. This behavior would therefore be put into the global behavior collection, and not in a particular state.
So there you have it. One thing I don't have (yet) is an Entity Template Designer, so in the meantime I've temporarily hacked the engine to set up the spike enemy with the appropriate behaviors. Here is what the code looks like; as you can see, it's fairly straightforward:
Eventually, a designer tool of some sort will replace this code. The 'table' I'm indexing is from a Lua script file. That is all for this update. The actual script can be found below. Thanks for reading!
local t = { };
------------------------------------
t["init"] = function(e)
--Global initialization. Set the original position of the spike as two custom state variables
local entity = e.Entity
entity["returnX"] = entity.Position.X
entity["returnY"] = entity.Position.Y
end
------------------------------------
t["standard_init"] = function(e)
e.Entity:SetTexture("spike_sleep");
end
------------------------------------
t["standard_update"] = function(e)
local section = e.Section;
local entity = e.Entity;
local position = entity.Position;
local entity = e.Entity;
--Create two large rectangles used to test for collision against the player
local hpoly = polySquare(v2(position.X, position.Y), v2(1000, 1));
local vpoly = polySquare(v2(position.X, position.Y), v2(1, 1000));
--Actually run the collision, colliding against tag 'player'
local h = e.Section:PolyEntitiesTag(hpoly, "player");
--Check the horizontal (and vertical) collision results and set the state to waking if either hit
if (h.Count > 0) then
entity:SetState("waking");
else
local v = e.Section:PolyEntitiesTag(vpoly, "player");
if (v.Count > 0) then
entity:SetState("waking");
end
end
end
------------------------------------
t["waking_init"] = function(e)
local entity = e.Entity
--Set up function that will be run in .5 seconds
local stillAttack = function()
--Perform a similar collision detection as in the 'standard' state
local position = entity.Position;
local hpoly = polySquare(v2(position.X, position.Y), v2(1000, 1));
local vpoly = polySquare(v2(position.X, position.Y), v2(1, 1000));
local h = e.Section:PolyEntitiesTag(hpoly, "player");
local chase = false;
if (h.Count > 0) then
chase = true;
else
local v = e.Section:PolyEntitiesTag(vpoly, "player");
if (v.Count > 0) then
chase = true;
end
end
if chase then entity:SetState("chasing") else entity:SetState("standard") end
end
--Open the eye and then run the above function in .5 seconds
entity:SetTexture("spike_awake");
entity:ControlNamed(
sequence(
delay(.5),
action(stillAttack)), "wakecheck");
end
------------------------------------
t["chasing_init"] = function(e)
--We've entered the chasing state, let's figure out which way to actually go
local entity = e.Entity;
local player = e.Section:FindEntity("player")
if player == null then return end
--Do some angle calculations to figure out whether to go up, down, left, or right
local playerPosition = player.Position;
local position = entity.Position;
local rawAngle = math.atan2(playerPosition.Y - position.Y, playerPosition.X - position.X)
local angle = closestCardinalAngle(rawAngle)
local speed = 300;
--Move in that direction
entity:ControlNamed(motion(v2(math.cos(angle) * speed, math.sin(angle) * speed)), "motion")
end
------------------------------------
t["chasing_collide"] = function(e)
--More interesting things will happen here someday
e.Entity:SetState("returning");
end
------------------------------------
t["returning_init"] = function(e)
--Go back to start
local entity = e.Entity;
local distance = math.abs(entity.Position.X - entity["returnX"]) +math.abs(entity.Position.Y - entity["returnY"]);
local time = distance / 100;
entity:ControlNamed(
sequence(
range("finish", time, interp("position", v2(entity.Position.X, entity.Position.Y), v2(entity["returnX"], entity["returnY"]), "linear")),
action(function() entity:SetState("standard") end)), "motion");
end
------------------------------------
return t;
local t = { };
------------------------------------
t["init"] = function(e)
--Global initialization. Set the original position of the spike as two custom state variables
local entity = e.Entity
entity["returnX"] = entity.Position.X
entity["returnY"] = entity.Position.Y
end
------------------------------------
t["standard_init"] = function(e)
e.Entity:SetTexture("spike_sleep");
end
------------------------------------
t["standard_update"] = function(e)
local section = e.Section;
local entity = e.Entity;
local position = entity.Position;
local entity = e.Entity;
--Create two large rectangles used to test for collision against the player
local hpoly = polySquare(v2(position.X, position.Y), v2(1000, 1));
local vpoly = polySquare(v2(position.X, position.Y), v2(1, 1000));
--Actually run the collision, colliding against tag 'player'
local h = e.Section:PolyEntitiesTag(hpoly, "player");
--Check the horizontal (and vertical) collision results and set the state to waking if either hit
if (h.Count > 0) then
entity:SetState("waking");
else
local v = e.Section:PolyEntitiesTag(vpoly, "player");
if (v.Count > 0) then
entity:SetState("waking");
end
end
end
------------------------------------
t["waking_init"] = function(e)
local entity = e.Entity
--Set up function that will be run in .5 seconds
local stillAttack = function()
--Perform a similar collision detection as in the 'standard' state
local position = entity.Position;
local hpoly = polySquare(v2(position.X, position.Y), v2(1000, 1));
local vpoly = polySquare(v2(position.X, position.Y), v2(1, 1000));
local h = e.Section:PolyEntitiesTag(hpoly, "player");
local chase = false;
if (h.Count > 0) then
chase = true;
else
local v = e.Section:PolyEntitiesTag(vpoly, "player");
if (v.Count > 0) then
chase = true;
end
end
if chase then entity:SetState("chasing") else entity:SetState("standard") end
end
--Open the eye and then run the above function in .5 seconds
entity:SetTexture("spike_awake");
entity:ControlNamed(
sequence(
delay(.5),
action(stillAttack)), "wakecheck");
end
------------------------------------
t["chasing_init"] = function(e)
--We've entered the chasing state, let's figure out which way to actually go
local entity = e.Entity;
local player = e.Section:FindEntity("player")
if player == null then return end
--Do some angle calculations to figure out whether to go up, down, left, or right
local playerPosition = player.Position;
local position = entity.Position;
local rawAngle = math.atan2(playerPosition.Y - position.Y, playerPosition.X - position.X)
local angle = closestCardinalAngle(rawAngle)
local speed = 300;
--Move in that direction
entity:ControlNamed(motion(v2(math.cos(angle) * speed, math.sin(angle) * speed)), "motion")
end
------------------------------------
t["chasing_collide"] = function(e)
--More interesting things will happen here someday
e.Entity:SetState("returning");
end
------------------------------------
t["returning_init"] = function(e)
--Go back to start
local entity = e.Entity;
local distance = math.abs(entity.Position.X - entity["returnX"]) +math.abs(entity.Position.Y - entity["returnY"]);
local time = distance / 100;
entity:ControlNamed(
sequence(
range("finish", time, interp("position", v2(entity.Position.X, entity.Position.Y), v2(entity["returnX"], entity["returnY"]), "linear")),
action(function() entity:SetState("standard") end)), "motion");
end
------------------------------------
return t;