Here's the version 1.1 of the AI framework. All feedback and development ideas are welcome.
Setup
Download this asset package. Extract the ai_framework directory into your dungeon's mod_assets directory, and import the mod_assets/ai_framework/ai_framework.lua file in init.lua.
Place the following script into the dungeon with the id "ai_framework_script":
Code: Select all
-- Define hooks automatically if LoG Framework is loaded
function autoexec()
if fw == nil then
return
end
fw.addHooks('monsters', 'ai_fw', {
onMove = function(self, dir)
return ai_framework_script.onMove(self, dir)
end,
onTurn = function(self, dir)
return ai_framework_script.onTurn(self, dir)
end,
onAttack = function(self, attack)
return ai_framework_script.onAttack(self, attack)
end,
onRangedAttack = function(self)
return ai_framework_script.onRangedAttack(self)
end,
onDamage = function(self)
ai_framework_script.onDamage(self, amount, type)
end,
onDie = function(self)
ai_framework_script.onDie(self)
end
})
fw.addHooks('party', 'ai_fw', {
onMove = function(self, dir)
ai_framework_script.onPartyMove(self, dir)
end,
onAttack = function(champion, weapon)
ai_framework_script.onPartyAttack(champion, weapon)
end,
onCastSpell = function(champion, spell)
ai_framework_script.onPartyCastSpell(champion, spell)
end
})
end
mob_data = {}
opened_blockers = {}
do
local timer = spawn("timer", 1, 0, 0, 0, "ai_framework_timer_blockercloser")
timer:setTimerInterval(0.001)
timer:addConnector("activate", "ai_framework_script", "closeBlockers")
timer:deactivate()
end
function moveTo(monster_id, coords, facing, callback)
if #coords < 2 then
print("AI usage error: moveTo coordinates " .. coordsToStr(coords) .. " don't contain even a single waypoint.")
return
end
if #coords % 2 ~= 0 then
print("AI usage error: moveTo coordinates " .. coordsToStr(coords) .. " contain half a waypoint.")
return
end
local data = getCleanData(monster_id)
data.command = "moveTo"
data.coords = {}
for i, c in ipairs(coords) do
data.coords[i] = c
end
if facing ~= nil then
data.facing = facing % 4
end
data.completion_callback = callback
data.phase = 1
end
function patrol(monster_id, coords, reverse)
if #coords < 4 then
print("AI usage error: patrol coordinates " .. coordsToStr(coords) .. " contain fewer than two waypoints.")
return
end
if #coords % 2 ~= 0 then
print("AI usage error: moveTo coordinates " .. coordsToStr(coords) .. " contain half a waypoint.")
return
end
do
local different = false
for i = 4, #coords, 2 do
if coords[i-3] ~= coords[i-1] or coords[i-2] ~= coords[i] then
different = true
break
end
end
if not different then
print("AI usage error: patrol coordinates " .. coordsToStr(coords) .. " contain only identical waypoints.")
return
end
end
local data = getCleanData(monster_id)
data.command = "patrol"
data.coords = {}
for i, c in ipairs(coords) do
data.coords[i] = c
end
if reverse then
for i = #data.coords - 3, 3, -2 do
table.insert(data.coords, data.coords[i])
table.insert(data.coords, data.coords[i+1])
end
end
data.phase = 1
end
function advance(monster_id)
local data = getCleanData(monster_id)
data.command = "advance"
end
function retreat(monster_id)
local data = getCleanData(monster_id)
data.command = "retreat"
end
function setConstrainChoises(monster_id, constrain_choices)
local data = mob_data[monster_id]
if data == nil then
data = getCleanData(monster_id)
end
data.constrain_choices = constrain_choices
end
function setCombatCallback(monster_id, callback)
local data = mob_data[monster_id]
if data == nil then
data = getCleanData(monster_id)
end
data.combat_callback = callback
end
function setBlockRangedAttacks(monster_id, block_ranged)
local data = mob_data[monster_id]
if data == nil then
data = getCleanData(monster_id)
end
data.block_ranged = block_ranged
end
function setAttackOverride(monster_id, allow_attack)
local data = mob_data[monster_id]
if data == nil then
data = getCleanData(monster_id)
end
data.attack_override = allow_attack
end
function clear(monster_id)
clearData(monster_id)
end
function onMove(mob, dir)
local data = mob_data[mob.id]
if data == nil then
return true
end
checkCompletionCallback(mob, data)
local allowed_dirs = getAllowedDirs(mob.x, mob.y, data)
local allowed = allowed_dirs ~= nil and allowed_dirs[dir]
if allowed_dirs ~= nil and not allowed then
handleBlockers(mob, data, allowed_dirs)
else
clearBlockers(mob.id)
end
return allowed
end
function onTurn(mob, dir)
local data = mob_data[mob.id]
if data == nil then
return true
end
checkCompletionCallback(mob, data)
local allowed_dirs = getAllowedDirs(mob.x, mob.y, data)
local allowed
if allowed_dirs ~= nil then
if data.constrain_choices and allowed_dirs[mob.facing] then
if canMove(mob.level, mob.x, mob.y, mob.facing) then
allowed_dirs = {[0] = (mob.facing == 0); mob.facing == 1, mob.facing == 2, mob.facing == 3}
end
end
allowed = allowed_dirs[(mob.facing + dir) % 4] or not (allowed_dirs[mob.facing] or allowed_dirs[(mob.facing - dir) % 4])
elseif data.facing ~= nil then
allowed = data.facing == ((mob.facing + dir) % 4) or data.facing == ((mob.facing + 2) % 4)
else
allowed = true
end
if (allowed_dirs ~= nil or (data.facing ~= nil and mob.facing ~= data.facing)) and not allowed then
handleBlockers(mob, data, allowed_dirs)
else
local blocker = findEntity(getBlockerId(mob.id) .. "ranged")
if blocker ~= nil then
blocker:destroy()
end
end
return allowed
end
function onAttack(mob, attack)
local data = mob_data[mob.id]
if data == nil then
return true
end
checkCombatCallback(mob, data)
data = mob_data[mob.id]
if data == nil then
return true
end
if isAttackAllowed(data) then
return true
else
local allowed_dirs = getAllowedDirs(mob.x, mob.y, data)
handleBlockers(mob, data, allowed_dirs, attack_allowed)
if data.block_ranged and allowed_dirs ~= nil and allowed_dirs[mob.facing] then
local dx, dy = getForward(mob.facing)
local blocker = spawn("ai_framework_wallblocker", mob.level, mob.x + dx, mob.y + dy, mob.facing, getBlockerId(mob.id) .. "ranged")
blocker:setDoorState("open")
blocker:close()
end
return false
end
end
function onRangedAttack(mob)
return onAttack(mob, "ranged")
end
function onDamage(mob, amount, type)
local data = mob_data[mob.id]
if data == nil then
return true
end
checkCombatCallback(mob, data)
end
function onDie(mob)
clearData(mob.id)
end
function onPartyMove(party, dir)
for mob_id, data in pairs(mob_data) do
if data.command == "advance" or data.command == "retreat" then
clearBlockers(mob_id)
end
end
openBlockersForParty(dir)
end
function onPartyAttack(champion, weapon)
openBlockersForParty(party.facing)
end
function onPartyCastSpell(champion, spell)
openBlockersForParty(party.facing)
end
function checkCompletionCallback(mob, data)
local callback = data.completion_callback
if callback ~= nil then
if data.command == "moveTo" then
local coords = data.coords
if mob.x == coords[#coords-1] and mob.y == coords[#coords] and (data.facing == nil or mob.facing == data.facing) then
data.completion_callback = nil
callback(mob.id)
end
end
end
end
function checkCombatCallback(mob, data)
local callback = data.combat_callback
if callback ~= nil then
data.combat_callback = nil
callback(mob.id)
end
end
function getAllowedDirs(x, y, data)
local dest_x, dest_y
local invert = false
if data.command == "moveTo" or data.command == "patrol" then
dest_x, dest_y = data.coords[2*data.phase-1], data.coords[2*data.phase]
while dest_x == x and dest_y == y do
data.phase = data.phase + 1
dest_x, dest_y = data.coords[2*data.phase-1], data.coords[2*data.phase]
if dest_y == nil and data.command == "patrol" then
data.phase = 1
dest_x, dest_y = data.coords[2*data.phase-1], data.coords[2*data.phase]
end
end
elseif data.command == "advance" or data.command == "retreat" then
dest_x, dest_y = party.x, party.y
if data.command == "retreat" then
invert = true
end
else
print("Internal AI error: unknown command " .. data.command)
end
if dest_y ~= nil then
local result = {[0] = dest_y < y; dest_x > x, dest_y > y, dest_x < x}
if invert then
for i, b in pairs(result) do
result[i] = not b
end
end
return result
else
return nil
end
end
function canMove(level, x1, y1, dir)
local dx, dy = getForward(dir)
local x2, y2 = x1 + dx, y1 + dy
if isWall(level, x2, y2) then
return false
end
for e in entitiesAt(level, x2, y2) do
if e.class == "Altar" or
e.class == "Blockage" or
(e.class == "Blocker" and e.name ~= "ai_framework_floorblocker") or
e.class == "Crystal" or
((e.class == "Door" or (e.class == nil and e.setDoorState ~= nil)) and e.name ~= "ai_framework_wallblocker" and e.facing == ((dir + 2) % 4) and e:isClosed()) or
(e.class == "Pit" and e:isOpen()) or
e.class == "Stairs" or
(e.class == "Teleporter" and e:isActivated()) then
return false
end
end
for e in entitiesAt(level, x1, y1) do
if (e.class == "Door" or (e.class == nil and e.setDoorState ~= nil)) and e.name ~= "ai_framework_wallblocker" and e.facing == dir and e:isClosed() then
return false
end
end
return true
end
function isAttackAllowed(data)
if type(data.attack_override) == "boolean" then
return data.attack_override
end
if data.command == "advance" then
return true
elseif data.command == "moveTo" or data.command == "patrol" or data.command == "retreat" then
return false
else
print("Internal AI error: unknown command " .. data.command)
return true
end
end
function handleBlockers(mob, data, allowed_dirs, attack_allowed)
local blocker_id = getBlockerId(mob.id)
if attack_allowed == nil then
attack_allowed = isAttackAllowed(data)
end
for i = 0, 3 do
local blocker = findEntity(blocker_id .. i)
if allowed_dirs == nil or not allowed_dirs[i] then
if blocker ~= nil and ((blocker.name == "ai_framework_floorblocker" and (i ~= mob.facing or not attack_allowed)) or (blocker.name == "ai_framework_wallblocker" and i == mob.facing and attack_allowed)) then
blocker:destroy()
end
if blocker == nil then
if i == mob.facing and attack_allowed then
local dx, dy = getForward(i)
blocker = spawn("ai_framework_floorblocker", mob.level, mob.x + dx, mob.y + dy, 0, blocker_id .. i)
else
blocker = spawn("ai_framework_wallblocker", mob.level, mob.x, mob.y, i, blocker_id .. i)
blocker:setDoorState("open")
blocker:close()
end
end
elseif allowed_dirs ~= nil and allowed_dirs[i] and blocker ~= nil then
blocker:destroy()
end
end
local blocker = findEntity(blocker_id .. "ranged")
if blocker ~= nil then
blocker:destroy()
end
end
function clearBlockers(mob_id)
local blocker_id = getBlockerId(mob_id)
for i = 0, 3 do
local blocker = findEntity(blocker_id .. i)
if blocker ~= nil then
blocker:destroy()
end
end
local blocker = findEntity(blocker_id .. "ranged")
if blocker ~= nil then
blocker:destroy()
end
end
function getBlockerId(mob_id)
return "ai_framework_blocker_" .. mob_id .. "_"
end
function openBlockersForParty(dir)
local dx, dy = getForward(dir)
for e in entitiesAt(party.level, party.x + dx, party.y + dy) do
if e.name == "ai_framework_wallblocker" and e.facing == ((dir + 2) % 4) then
e:setDoorState("open")
table.insert(opened_blockers, e.id)
ai_framework_timer_blockercloser:activate()
end
end
for e in entitiesAt(party.level, party.x, party.y) do
if e.name == "ai_framework_wallblocker" and e.facing == dir then
e:setDoorState("open")
table.insert(opened_blockers, e.id)
ai_framework_timer_blockercloser:activate()
end
end
end
function closeBlockers(source)
for i, blocker_id in pairs(opened_blockers) do
local blocker = findEntity(blocker_id)
if blocker ~= nil then
blocker:close()
end
end
opened_blockers = {}
source:deactivate()
end
function getCleanData(mob_id)
clearData(mob_id)
data = {}
mob_data[mob_id] = data
return data
end
function clearData(mob_id)
local data = mob_data[mob_id]
if data ~= nil then
clearBlockers(mob_id)
mob_data[mob_id] = nil
end
end
function coordsToStr(coords)
if #coords == 0 then
return "{}"
end
local s = "" .. coords[1]
for i = 2, #coords do
s = s .. ", " .. coords[i]
end
return s
end
Usage
The framework currently allows giving two kinds of orders to monsters: commands and switches. A command gives the monster a task to perform, such as moving to a specific location. Giving a monster a new command will remove the old command and all switches. Switches are used to make smaller modifications to behavior, and several of them can be given in addition to or instead of a command.
The following parameters are used by multiple functions in the framework interface:
monster_id: The id of the monster the order is meant for.
coords: A table containing (x, y) coordinate pairs (waypoints), for example table {1, 1, 2, 3} contains waypoints (1, 1) and (2, 3). May not be empty.
callback: A function that will be called in specific circumstances, with the id of the monster given as parameter. Any given callback will only be invoked once.
Commands
moveTo(monster_id, coords, [facing], [callback])
The monster moves through the given waypoints, and stops at the last one. It will not attack the party. If facing is not nil, the monster will assume and retain the given facing once it reaches the final waypoint. If callback is not nil, it will be invoked once the command has been completely fulfilled.
patrol(monster_id, coords, [reverse])
The monster moves through the given waypoints repeatedly, without attacking the party. At least two different waypoints are required. If the reverse parameter is true, the monster will return through the waypoints in the reverse order once it reaches the last one. Otherwise the monster will head directly to the first waypoint after the last one.
advance(monster_id)
The monster moves towards the party, and may attack if able.
retreat(monster_id)
The monster moves away from the party, and will not attack.
Switches
setConstrainChoises(monster_id, constrain_choices)
If constrain_choices is true, the framework will reduce the choices available to the monster in order to make it waffle less (usually a problem for monsters who are ordered to move away from the party after detecting it). This makes the monster's movement more predictable and may increase its chances of getting stuck.
setCombatCallback(monster_id, callback)
Gives the monster a callback that will be invoked if it detects the party (currently meaning: if it attempts to attack or takes damage).
setBlockRangedAttacks(monster_id, block_ranged)
If block_ranged is true, the framework will spawn extra blockers to prevent the monster from getting stuck in attempts to use ranged attacks when they are forbidden. The blockers may interfere with other nearby monsters.
setAttackOverride(monster_id, allow_attack)
Overrides the normal behavior of commands, allowing the monster to attack if the allow_attack is true or preventing it from attacking if the parameter is false.
Other
clear(monster_id)
Removes all commands and switches from the monster.
Compatibility
All entities spawned by the framework will have ids beginning with "ai_framework_". Such ids should not be used elsewhere in the dungeon.
The framework's use of hook functions may interfere with other scripts that rely on them and vice versa. Most importantly, the onDie function in the framework clears all the orders related to the monster, and should not be called unless the monster is really allowed to die.
Known issues
Although the framework mostly works by returning appropriate values from the monsters' hook functions, it also spawns some blockers to constrain the AI's choices. In some situations these blockers may interfere with other nearby monsters.
The blockers used to constrain monsters' movement can stop ice shards short of its full range, though the spell should never explode into the caster's face.
Monsters tend to be reluctant to move away from the party once they have spotted it, especially if they keep getting attacked.
The current pathfinding is very primitive and doesn't take level layout into account at all. It works fairly well in straight lines and open spaces, but may easily get stuck in more complex areas. Be generous with waypoints. Better algorithms will be added in the future.
Monster groups are apparently controlled by a single member, whose orders will apply to the entire group. Unless you can identify which member is in charge, orders should be given to all of them to control the group. If the controlling member dies, control is given to another member who will start executing their orders from scratch. This may or may not be a problem, depending on their orders.
Ogres and wardens can charge regardless of their orders.