AI Framework

Talk about creating Grimrock 1 levels and mods here. Warning: forum contains spoilers!
Ancylus
Posts: 50
Joined: Thu Oct 11, 2012 5:54 pm

AI Framework

Post by Ancylus »

AI Framework

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":
SpoilerShow

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
All monsters to be handled with the framework, as well as the party, must be given a number of hook functions. The framework script will add all of these automatically if JKos' LoG Framework is enabled in the dungeon. Otherwise they have to be created manually; see the beginning of the script for the necessary hooks and their implementation.

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.
Last edited by Ancylus on Sun Jan 20, 2013 8:40 pm, edited 1 time in total.
User avatar
Diarmuid
Posts: 807
Joined: Thu Nov 22, 2012 6:59 am
Location: Montreal, Canada
Contact:

Re: AI Framework

Post by Diarmuid »

This is great, thanks so much. There's a few people playing with that on the LotNR project and this is just what they have been asking for :)
User avatar
undeaddemon
Posts: 157
Joined: Fri Mar 02, 2012 3:38 pm

Re: AI Framework

Post by undeaddemon »

Yeah... I think I could use this functionality to help explain my story...
Nice!
and THANKS!
User avatar
JKos
Posts: 464
Joined: Wed Sep 12, 2012 10:03 pm
Location: Finland
Contact:

Re: AI Framework

Post by JKos »

Wow, sounds great! Have to try this asap. I don't even understand how you managed to implement those features.
- LoG Framework 2http://sites.google.com/site/jkoslog2 Define hooks in runtime by entity.name or entity.id + multiple hooks support.
- cloneObject viewtopic.php?f=22&t=8450
User avatar
JKos
Posts: 464
Joined: Wed Sep 12, 2012 10:03 pm
Location: Finland
Contact:

Re: AI Framework

Post by JKos »

Ok, I tried it and patrol works nicely, but there is some problems with retreat, because the monster tries to move/turn towards the party but isn't allowed, so it just stays on the same tile turning different directions if it can see the party. Maybe it could be fixed by respawning the same monster with 0 sight, so it won't see the party and thus won't try to attack either.
You could use a function like this to clone the monsters:

Code: Select all

function ai_fw_cloneMonster(monsterName)
   cloneObject{
      name=monsterName..'_retreating',
      baseObject = monsterName,
      sight=0
 }
end
I'm not sure if it works, but it could be worth to try it.

But impressive work anyway.

Btw. you can add this to script_ai_framework entity:

Code: Select all

-- Define hooks automatically if LoG Framework is loaded
function autoexec()
 if fw == nil return end

 fw.addHooks('monsters','ai_fw',{
      onMove = function(self, dir)
         return script_ai_framework.onMove(self, dir)
      end,
      onTurn = function(self, dir)
         return script_ai_framework.onTurn(self, dir)
      end,
      onAttack = function(self, attack)
         return script_ai_framework.onAttack(self, attack)
      end,
      onRangedAttack = function(self)
         return script_ai_framework.onRangedAttack(self)
      end,
      onDamage = function(self)
         script_ai_framework.onDamage(self, amount, type)
      end,
      onDie = function(self)
         script_ai_framework.onDie(self)
      end
   })
end
so the hooks are defined automatically if my framework is loaded (one copy paste less). Functions named autoexec are called automatically after the framework is loaded. (This is actually Xanathar's grimq-library feature which is included to newest version of the framework)
And the newest framework version doesn't need timers:setLevels() call any more.
- LoG Framework 2http://sites.google.com/site/jkoslog2 Define hooks in runtime by entity.name or entity.id + multiple hooks support.
- cloneObject viewtopic.php?f=22&t=8450
User avatar
JKos
Posts: 464
Joined: Wed Sep 12, 2012 10:03 pm
Location: Finland
Contact:

Re: AI Framework

Post by JKos »

I got a another idea: what if you use invisible doors instead of blockers, so the retreating monster could not see through them. You just need to set the doors open when party is attacking to the monster, this can be done with party:onAttack, onCastSpell hooks. Again not sure if it works. I wish that Petri had a bit more Glögg, the monster:setSight was on the request list but his Glögg ran out.
- LoG Framework 2http://sites.google.com/site/jkoslog2 Define hooks in runtime by entity.name or entity.id + multiple hooks support.
- cloneObject viewtopic.php?f=22&t=8450
User avatar
vidarfreyr
Posts: 71
Joined: Sat Oct 20, 2012 4:40 am

Re: AI Framework

Post by vidarfreyr »

Maybe spawn an invisible monster or copy of party to make the Ogre or Warden rush away in a hurry. :mrgreen:

Is it even possible to make a monster that other monsters will attack?
Ancylus
Posts: 50
Joined: Thu Oct 11, 2012 5:54 pm

Re: AI Framework

Post by Ancylus »

Thanks for the ideas, JKos. Retreating really is the biggest problem with the currently implemented functionality. Spawning doors might help in at least some cases, I'll have to experiment a bit. Some other issues can likely be alleviated with better pathfinding, once I get to implementing it. I'm reluctant to start destroying and spawning monsters, though. It could be difficult to do seamlessly, and rather too likely to conflict with other monster-related scripting.

And yeah, it's probably easiest to just add your setup script directly to the framework. Anyone who wants to do something more complicated with the hooks should have enough scripting skills to change things to their liking.

vidarfreyr: I don't think I can get monsters to attack each other. Never tried spawning another party, though...
Ancylus
Posts: 50
Joined: Thu Oct 11, 2012 5:54 pm

Re: AI Framework

Post by Ancylus »

A new version is now available. The framework has been modified to mostly use spawned doors to guide monsters' movement, and two new switches have been added to help control the monsters' behavior. The new features should especially help with the retreat command, though it still doesn't work quite perfectly.
User avatar
djoldgames
Posts: 107
Joined: Fri Mar 23, 2012 11:28 pm
Contact:

Invisible door model

Post by djoldgames »

Your framework is nice stuff, thank you.

I have small tweak-tip for you about invisible door model.
In my EOB mod I'm using "empty" models for this invisible things (illusion walls, etc.). Empty model is the object without the mesh, so I dont need to use any materials/textures and I think its the best option for engine optimization.
You can update your "door_invisible.model", simple do this in GMT:
1. Select "Node 3: Texture Group" and Remove it
2. Select "Node 2: gate" and Remove it
3. Select "Node 1: TextureLocators", change name to "gate" and update
4. Save the model
Post Reply