Inteligencia Artificial para Enemigos y NPCs
Inteligencia Artificial para Enemigos y NPCs
La inteligencia artificial en juegos crea la ilusión de vida y comportamiento inteligente en personajes no jugadores. En esta lección aprenderás a implementar sistemas de IA sofisticados que harán que tus enemigos y NPCs se sientan reales e impredecibles.
🧠 Fundamentos de Game AI
AI Controller Base
-- ai_controller.lua
local AIController = {}
AIController.__index = AIController
function AIController.new(entity)
local self = setmetatable({}, AIController)
self.entity = entity
self.state = "idle"
self.target = nil
self.last_known_position = nil
self.blackboard = {} -- Memoria compartida para comportamientos
self.sensors = {}
self.decision_timer = 0
self.decision_interval = 0.1 -- Decisiones cada 100ms
return self
end
function AIController:add_sensor(sensor)
table.insert(self.sensors, sensor)
sensor.controller = self
end
function AIController:update(dt)
-- Actualizar sensores
for _, sensor in ipairs(self.sensors) do
sensor:update(dt)
end
-- Tomar decisiones a intervalos
self.decision_timer = self.decision_timer + dt
if self.decision_timer >= self.decision_interval then
self:make_decision()
self.decision_timer = 0
end
-- Ejecutar comportamiento actual
self:execute_behavior(dt)
end
function AIController:make_decision()
-- Override en subclases
end
function AIController:execute_behavior(dt)
-- Override en subclases
end
function AIController:set_target(target)
self.target = target
if target then
self.last_known_position = go.get_position(target)
end
end
function AIController:get_distance_to_target()
if not self.target then return math.huge end
local my_pos = go.get_position(self.entity)
local target_pos = go.get_position(self.target)
return vmath.length(target_pos - my_pos)
end
return AIController
Sensor System
-- sensors.lua
local Sensors = {}
-- Sensor de visión
local VisionSensor = {}
VisionSensor.__index = VisionSensor
function VisionSensor.new(range, angle, target_tags)
local self = setmetatable({}, VisionSensor)
self.range = range or 200
self.fov_angle = angle or math.rad(90) -- 90 grados
self.target_tags = target_tags or {hash("player")}
self.visible_targets = {}
self.controller = nil
return self
end
function VisionSensor:update(dt)
if not self.controller then return end
self.visible_targets = {}
local my_pos = go.get_position(self.controller.entity)
local my_rotation = go.get_rotation(self.controller.entity)
local forward = vmath.rotate(my_rotation, vmath.vector3(1, 0, 0))
-- Buscar objetivos en rango
for _, tag in ipairs(self.target_tags) do
local targets = self:get_objects_with_tag(tag)
for _, target in ipairs(targets) do
local target_pos = go.get_position(target)
local distance = vmath.length(target_pos - my_pos)
if distance <= self.range then
-- Verificar ángulo de visión
local to_target = vmath.normalize(target_pos - my_pos)
local angle_to_target = math.acos(vmath.dot(forward, to_target))
if angle_to_target <= self.fov_angle / 2 then
-- Verificar línea de vista (raycast)
if self:has_line_of_sight(my_pos, target_pos) then
table.insert(self.visible_targets, {
target = target,
distance = distance,
position = target_pos
})
end
end
end
end
end
-- Informar al controlador sobre objetivos visibles
if #self.visible_targets > 0 then
self.controller:on_targets_spotted(self.visible_targets)
end
end
function VisionSensor:has_line_of_sight(from, to)
-- Realizar raycast para verificar obstáculos
local direction = to - from
local distance = vmath.length(direction)
direction = vmath.normalize(direction)
local result = physics.raycast(from, from + direction * distance, {hash("wall"), hash("obstacle")})
return result == nil -- Sin obstáculos = línea de vista clara
end
function VisionSensor:get_objects_with_tag(tag)
-- Esta función necesitaría ser implementada específicamente
-- Podría usar un sistema de registro de objetos por tags
return game_objects.get_by_tag(tag) or {}
end
-- Sensor de audio
local AudioSensor = {}
AudioSensor.__index = AudioSensor
function AudioSensor.new(range, sensitivity)
local self = setmetatable({}, AudioSensor)
self.range = range or 150
self.sensitivity = sensitivity or 1.0
self.sounds_heard = {}
self.controller = nil
return self
end
function AudioSensor:update(dt)
if not self.controller then return end
self.sounds_heard = {}
local my_pos = go.get_position(self.controller.entity)
-- Obtener eventos de sonido recientes
local recent_sounds = audio_events.get_recent_sounds() or {}
for _, sound_event in ipairs(recent_sounds) do
local distance = vmath.length(sound_event.position - my_pos)
local volume_at_distance = sound_event.volume / (distance + 1)
if volume_at_distance >= self.sensitivity then
table.insert(self.sounds_heard, {
position = sound_event.position,
volume = volume_at_distance,
type = sound_event.type,
source = sound_event.source
})
end
end
if #self.sounds_heard > 0 then
self.controller:on_sounds_heard(self.sounds_heard)
end
end
-- Sensor de proximidad
local ProximitySensor = {}
ProximitySensor.__index = ProximitySensor
function ProximitySensor.new(range, target_tags)
local self = setmetatable({}, ProximitySensor)
self.range = range or 50
self.target_tags = target_tags or {hash("player")}
self.nearby_targets = {}
self.controller = nil
return self
end
function ProximitySensor:update(dt)
if not self.controller then return end
self.nearby_targets = {}
local my_pos = go.get_position(self.controller.entity)
for _, tag in ipairs(self.target_tags) do
local targets = self:get_objects_with_tag(tag)
for _, target in ipairs(targets) do
local target_pos = go.get_position(target)
local distance = vmath.length(target_pos - my_pos)
if distance <= self.range then
table.insert(self.nearby_targets, {
target = target,
distance = distance,
position = target_pos
})
end
end
end
if #self.nearby_targets > 0 then
self.controller:on_proximity_detected(self.nearby_targets)
end
end
Sensors.VisionSensor = VisionSensor
Sensors.AudioSensor = AudioSensor
Sensors.ProximitySensor = ProximitySensor
return Sensors
🌳 Behavior Trees
Behavior Tree Implementation
-- behavior_tree.lua
local BehaviorTree = {}
-- Estados de nodos
local NODE_STATES = {
SUCCESS = "success",
FAILURE = "failure",
RUNNING = "running"
}
-- Nodo base
local BehaviorNode = {}
BehaviorNode.__index = BehaviorNode
function BehaviorNode.new()
local self = setmetatable({}, BehaviorNode)
self.parent = nil
self.children = {}
self.blackboard = nil
return self
end
function BehaviorNode:add_child(child)
table.insert(self.children, child)
child.parent = self
child.blackboard = self.blackboard
end
function BehaviorNode:execute()
error("execute() must be implemented by subclass")
end
-- Composite Nodes
-- Selector (OR) - Ejecuta hijos hasta que uno tenga éxito
local Selector = setmetatable({}, {__index = BehaviorNode})
Selector.__index = Selector
function Selector.new()
return setmetatable(BehaviorNode.new(), Selector)
end
function Selector:execute()
for _, child in ipairs(self.children) do
local result = child:execute()
if result == NODE_STATES.SUCCESS or result == NODE_STATES.RUNNING then
return result
end
end
return NODE_STATES.FAILURE
end
-- Sequence (AND) - Ejecuta hijos mientras tengan éxito
local Sequence = setmetatable({}, {__index = BehaviorNode})
Sequence.__index = Sequence
function Sequence.new()
return setmetatable(BehaviorNode.new(), Sequence)
end
function Sequence:execute()
for _, child in ipairs(self.children) do
local result = child:execute()
if result == NODE_STATES.FAILURE or result == NODE_STATES.RUNNING then
return result
end
end
return NODE_STATES.SUCCESS
end
-- Parallel - Ejecuta todos los hijos simultáneamente
local Parallel = setmetatable({}, {__index = BehaviorNode})
Parallel.__index = Parallel
function Parallel.new(success_policy, failure_policy)
local self = setmetatable(BehaviorNode.new(), Parallel)
self.success_policy = success_policy or "all" -- "all" o "one"
self.failure_policy = failure_policy or "one" -- "all" o "one"
return self
end
function Parallel:execute()
local success_count = 0
local failure_count = 0
local running_count = 0
for _, child in ipairs(self.children) do
local result = child:execute()
if result == NODE_STATES.SUCCESS then
success_count = success_count + 1
elseif result == NODE_STATES.FAILURE then
failure_count = failure_count + 1
else
running_count = running_count + 1
end
end
-- Evaluar política de éxito
if self.success_policy == "all" and success_count == #self.children then
return NODE_STATES.SUCCESS
elseif self.success_policy == "one" and success_count > 0 then
return NODE_STATES.SUCCESS
end
-- Evaluar política de fallo
if self.failure_policy == "all" and failure_count == #self.children then
return NODE_STATES.FAILURE
elseif self.failure_policy == "one" and failure_count > 0 then
return NODE_STATES.FAILURE
end
return NODE_STATES.RUNNING
end
-- Decorator Nodes
-- Inverter - Invierte el resultado del hijo
local Inverter = setmetatable({}, {__index = BehaviorNode})
Inverter.__index = Inverter
function Inverter.new()
return setmetatable(BehaviorNode.new(), Inverter)
end
function Inverter:execute()
if #self.children ~= 1 then
error("Inverter must have exactly one child")
end
local result = self.children[1]:execute()
if result == NODE_STATES.SUCCESS then
return NODE_STATES.FAILURE
elseif result == NODE_STATES.FAILURE then
return NODE_STATES.SUCCESS
else
return NODE_STATES.RUNNING
end
end
-- Repeater - Repite el hijo hasta fallo o N veces
local Repeater = setmetatable({}, {__index = BehaviorNode})
Repeater.__index = Repeater
function Repeater.new(max_repeats)
local self = setmetatable(BehaviorNode.new(), Repeater)
self.max_repeats = max_repeats or -1 -- -1 = infinito
self.current_repeats = 0
return self
end
function Repeater:execute()
if #self.children ~= 1 then
error("Repeater must have exactly one child")
end
if self.max_repeats > 0 and self.current_repeats >= self.max_repeats then
return NODE_STATES.SUCCESS
end
local result = self.children[1]:execute()
if result == NODE_STATES.SUCCESS or result == NODE_STATES.FAILURE then
self.current_repeats = self.current_repeats + 1
end
if result == NODE_STATES.FAILURE then
return NODE_STATES.FAILURE
end
return NODE_STATES.RUNNING
end
-- Leaf Nodes (Actions and Conditions)
-- Action Node base
local ActionNode = setmetatable({}, {__index = BehaviorNode})
ActionNode.__index = ActionNode
function ActionNode.new(action_function)
local self = setmetatable(BehaviorNode.new(), ActionNode)
self.action_function = action_function
return self
end
function ActionNode:execute()
if self.action_function then
return self.action_function(self.blackboard)
end
return NODE_STATES.FAILURE
end
-- Condition Node base
local ConditionNode = setmetatable({}, {__index = BehaviorNode})
ConditionNode.__index = ConditionNode
function ConditionNode.new(condition_function)
local self = setmetatable(BehaviorNode.new(), ConditionNode)
self.condition_function = condition_function
return self
end
function ConditionNode:execute()
if self.condition_function then
return self.condition_function(self.blackboard) and NODE_STATES.SUCCESS or NODE_STATES.FAILURE
end
return NODE_STATES.FAILURE
end
-- Behavior Tree main class
function BehaviorTree.new(root_node, blackboard)
local self = {
root = root_node,
blackboard = blackboard or {}
}
-- Configurar blackboard en todos los nodos
local function set_blackboard(node)
node.blackboard = self.blackboard
for _, child in ipairs(node.children) do
set_blackboard(child)
end
end
set_blackboard(self.root)
return self
end
function BehaviorTree:execute()
return self.root:execute()
end
BehaviorTree.NODE_STATES = NODE_STATES
BehaviorTree.Selector = Selector
BehaviorTree.Sequence = Sequence
BehaviorTree.Parallel = Parallel
BehaviorTree.Inverter = Inverter
BehaviorTree.Repeater = Repeater
BehaviorTree.ActionNode = ActionNode
BehaviorTree.ConditionNode = ConditionNode
return BehaviorTree
Enemy AI with Behavior Trees
-- enemy_ai.script
local BehaviorTree = require "main.behavior_tree"
local Sensors = require "main.sensors"
function init(self)
self.blackboard = {
entity = go.get_id(),
target = nil,
last_known_position = nil,
patrol_points = {
vmath.vector3(100, 100, 0),
vmath.vector3(300, 100, 0),
vmath.vector3(300, 300, 0),
vmath.vector3(100, 300, 0)
},
current_patrol_index = 1,
alert_level = 0, -- 0=calm, 1=suspicious, 2=hostile
investigation_position = nil,
last_seen_time = 0,
attack_cooldown = 0,
movement_speed = 100
}
-- Configurar sensores
self.vision_sensor = Sensors.VisionSensor.new(150, math.rad(60), {hash("player")})
self.audio_sensor = Sensors.AudioSensor.new(100, 0.5)
self.proximity_sensor = Sensors.ProximitySensor.new(30, {hash("player")})
-- Crear behavior tree
self.behavior_tree = self:create_behavior_tree()
end
local function create_behavior_tree(self)
local blackboard = self.blackboard
-- === CONDITIONS ===
local function has_target()
return blackboard.target ~= nil
end
local function target_in_attack_range()
if not blackboard.target then return false end
local my_pos = go.get_position(blackboard.entity)
local target_pos = go.get_position(blackboard.target)
return vmath.length(target_pos - my_pos) <= 50
end
local function can_attack()
return blackboard.attack_cooldown <= 0
end
local function is_alert()
return blackboard.alert_level > 0
end
local function has_investigation_point()
return blackboard.investigation_position ~= nil
end
-- === ACTIONS ===
local function patrol_action()
local patrol_points = blackboard.patrol_points
local target_point = patrol_points[blackboard.current_patrol_index]
local my_pos = go.get_position(blackboard.entity)
-- Moverse hacia punto de patrulla
local direction = vmath.normalize(target_point - my_pos)
local distance = vmath.length(target_point - my_pos)
if distance > 10 then
local new_pos = my_pos + direction * blackboard.movement_speed * 1/60
go.set_position(new_pos, blackboard.entity)
return BehaviorTree.NODE_STATES.RUNNING
else
-- Llegamos al punto, ir al siguiente
blackboard.current_patrol_index = (blackboard.current_patrol_index % #patrol_points) + 1
return BehaviorTree.NODE_STATES.SUCCESS
end
end
local function pursue_target_action()
if not blackboard.target then
return BehaviorTree.NODE_STATES.FAILURE
end
local my_pos = go.get_position(blackboard.entity)
local target_pos = go.get_position(blackboard.target)
local direction = vmath.normalize(target_pos - my_pos)
local distance = vmath.length(target_pos - my_pos)
if distance > 25 then
local new_pos = my_pos + direction * blackboard.movement_speed * 1.5 * 1/60
go.set_position(new_pos, blackboard.entity)
return BehaviorTree.NODE_STATES.RUNNING
else
return BehaviorTree.NODE_STATES.SUCCESS
end
end
local function attack_action()
if not blackboard.target then
return BehaviorTree.NODE_STATES.FAILURE
end
-- Reproducir animación de ataque
msg.post("#sprite", "play_animation", {id = hash("attack")})
-- Aplicar daño
msg.post(blackboard.target, "take_damage", {damage = 25, source = blackboard.entity})
-- Configurar cooldown
blackboard.attack_cooldown = 1.0 -- 1 segundo
return BehaviorTree.NODE_STATES.SUCCESS
end
local function investigate_action()
if not blackboard.investigation_position then
return BehaviorTree.NODE_STATES.FAILURE
end
local my_pos = go.get_position(blackboard.entity)
local direction = vmath.normalize(blackboard.investigation_position - my_pos)
local distance = vmath.length(blackboard.investigation_position - my_pos)
if distance > 15 then
local new_pos = my_pos + direction * blackboard.movement_speed * 1/60
go.set_position(new_pos, blackboard.entity)
return BehaviorTree.NODE_STATES.RUNNING
else
-- Llegamos al punto, investigar por un momento
blackboard.investigation_position = nil
blackboard.alert_level = math.max(0, blackboard.alert_level - 1)
return BehaviorTree.NODE_STATES.SUCCESS
end
end
local function search_action()
if not blackboard.last_known_position then
return BehaviorTree.NODE_STATES.FAILURE
end
local my_pos = go.get_position(blackboard.entity)
local search_pos = blackboard.last_known_position
local direction = vmath.normalize(search_pos - my_pos)
local distance = vmath.length(search_pos - my_pos)
if distance > 20 then
local new_pos = my_pos + direction * blackboard.movement_speed * 1.2 * 1/60
go.set_position(new_pos, blackboard.entity)
return BehaviorTree.NODE_STATES.RUNNING
else
-- Buscar en área
blackboard.last_known_position = nil
blackboard.alert_level = math.max(0, blackboard.alert_level - 1)
return BehaviorTree.NODE_STATES.SUCCESS
end
end
-- === TREE CONSTRUCTION ===
local root = BehaviorTree.Selector.new()
-- Branch 1: Combat (highest priority)
local combat_sequence = BehaviorTree.Sequence.new()
combat_sequence:add_child(BehaviorTree.ConditionNode.new(has_target))
combat_sequence:add_child(BehaviorTree.ConditionNode.new(target_in_attack_range))
combat_sequence:add_child(BehaviorTree.ConditionNode.new(can_attack))
combat_sequence:add_child(BehaviorTree.ActionNode.new(attack_action))
-- Branch 2: Pursue
local pursue_sequence = BehaviorTree.Sequence.new()
pursue_sequence:add_child(BehaviorTree.ConditionNode.new(has_target))
pursue_sequence:add_child(BehaviorTree.ActionNode.new(pursue_target_action))
-- Branch 3: Investigation
local investigate_sequence = BehaviorTree.Sequence.new()
investigate_sequence:add_child(BehaviorTree.ConditionNode.new(has_investigation_point))
investigate_sequence:add_child(BehaviorTree.ActionNode.new(investigate_action))
-- Branch 4: Search
local search_sequence = BehaviorTree.Sequence.new()
search_sequence:add_child(BehaviorTree.ConditionNode.new(is_alert))
search_sequence:add_child(BehaviorTree.ActionNode.new(search_action))
-- Branch 5: Patrol (default behavior)
local patrol_action_node = BehaviorTree.ActionNode.new(patrol_action)
-- Construir árbol
root:add_child(combat_sequence)
root:add_child(pursue_sequence)
root:add_child(investigate_sequence)
root:add_child(search_sequence)
root:add_child(patrol_action_node)
return BehaviorTree.new(root, blackboard)
end
function update(self, dt)
-- Actualizar cooldowns
if self.blackboard.attack_cooldown > 0 then
self.blackboard.attack_cooldown = self.blackboard.attack_cooldown - dt
end
-- Actualizar sensores
self.vision_sensor:update(dt)
self.audio_sensor:update(dt)
self.proximity_sensor:update(dt)
-- Ejecutar behavior tree
self.behavior_tree:execute()
end
-- Callbacks de sensores
function on_targets_spotted(self, targets)
if #targets > 0 then
local closest_target = targets[1]
for _, target_data in ipairs(targets) do
if target_data.distance < closest_target.distance then
closest_target = target_data
end
end
self.blackboard.target = closest_target.target
self.blackboard.last_known_position = closest_target.position
self.blackboard.last_seen_time = socket.gettime()
self.blackboard.alert_level = 2 -- Hostil
end
end
function on_sounds_heard(self, sounds)
if #sounds > 0 then
local loudest_sound = sounds[1]
for _, sound in ipairs(sounds) do
if sound.volume > loudest_sound.volume then
loudest_sound = sound
end
end
self.blackboard.investigation_position = loudest_sound.position
self.blackboard.alert_level = math.max(1, self.blackboard.alert_level)
end
end
function on_proximity_detected(self, targets)
-- Reaccionar a proximidad inmediata
if #targets > 0 then
self.blackboard.target = targets[1].target
self.blackboard.alert_level = 2
end
end
🗺️ Pathfinding con A*
A* Implementation
-- pathfinding.lua
local Pathfinding = {}
-- Nodo para A*
local Node = {}
Node.__index = Node
function Node.new(x, y)
local self = setmetatable({}, Node)
self.x = x
self.y = y
self.g_cost = 0 -- Costo desde el inicio
self.h_cost = 0 -- Heurística al objetivo
self.f_cost = 0 -- Costo total
self.parent = nil
self.walkable = true
return self
end
function Node:calculate_f_cost()
self.f_cost = self.g_cost + self.h_cost
end
-- Grid para pathfinding
local Grid = {}
Grid.__index = Grid
function Grid.new(width, height, cell_size)
local self = setmetatable({}, Grid)
self.width = width
self.height = height
self.cell_size = cell_size or 32
self.nodes = {}
-- Crear grid de nodos
for x = 0, width - 1 do
self.nodes[x] = {}
for y = 0, height - 1 do
self.nodes[x][y] = Node.new(x, y)
end
end
return self
end
function Grid:get_node(x, y)
if x >= 0 and x < self.width and y >= 0 and y < self.height then
return self.nodes[x][y]
end
return nil
end
function Grid:set_walkable(x, y, walkable)
local node = self:get_node(x, y)
if node then
node.walkable = walkable
end
end
function Grid:world_to_grid(world_pos)
local x = math.floor(world_pos.x / self.cell_size)
local y = math.floor(world_pos.y / self.cell_size)
return x, y
end
function Grid:grid_to_world(grid_x, grid_y)
local x = grid_x * self.cell_size + self.cell_size / 2
local y = grid_y * self.cell_size + self.cell_size / 2
return vmath.vector3(x, y, 0)
end
function Grid:get_neighbors(node)
local neighbors = {}
local directions = {
{-1, -1}, {0, -1}, {1, -1},
{-1, 0}, {1, 0},
{-1, 1}, {0, 1}, {1, 1}
}
for _, dir in ipairs(directions) do
local check_x = node.x + dir[1]
local check_y = node.y + dir[2]
local neighbor = self:get_node(check_x, check_y)
if neighbor and neighbor.walkable then
table.insert(neighbors, neighbor)
end
end
return neighbors
end
-- A* Algorithm
local AStar = {}
function AStar.find_path(grid, start_pos, target_pos)
local start_x, start_y = grid:world_to_grid(start_pos)
local target_x, target_y = grid:world_to_grid(target_pos)
local start_node = grid:get_node(start_x, start_y)
local target_node = grid:get_node(target_x, target_y)
if not start_node or not target_node or not target_node.walkable then
return nil -- Sin camino posible
end
local open_set = {start_node}
local closed_set = {}
start_node.g_cost = 0
start_node.h_cost = AStar.heuristic(start_node, target_node)
start_node:calculate_f_cost()
while #open_set > 0 do
-- Encontrar nodo con menor f_cost
local current_node = open_set[1]
local current_index = 1
for i, node in ipairs(open_set) do
if node.f_cost < current_node.f_cost or
(node.f_cost == current_node.f_cost and node.h_cost < current_node.h_cost) then
current_node = node
current_index = i
end
end
-- Mover de open a closed
table.remove(open_set, current_index)
table.insert(closed_set, current_node)
-- ¿Llegamos al objetivo?
if current_node == target_node then
return AStar.retrace_path(start_node, target_node)
end
-- Explorar vecinos
local neighbors = grid:get_neighbors(current_node)
for _, neighbor in ipairs(neighbors) do
if not AStar.in_closed_set(neighbor, closed_set) then
local movement_cost = AStar.get_distance(current_node, neighbor)
local new_g_cost = current_node.g_cost + movement_cost
if new_g_cost < neighbor.g_cost or not AStar.in_open_set(neighbor, open_set) then
neighbor.g_cost = new_g_cost
neighbor.h_cost = AStar.heuristic(neighbor, target_node)
neighbor:calculate_f_cost()
neighbor.parent = current_node
if not AStar.in_open_set(neighbor, open_set) then
table.insert(open_set, neighbor)
end
end
end
end
end
return nil -- Sin camino encontrado
end
function AStar.heuristic(node_a, node_b)
local dx = math.abs(node_a.x - node_b.x)
local dy = math.abs(node_a.y - node_b.y)
-- Distancia diagonal (Chebyshev)
if dx > dy then
return 14 * dy + 10 * (dx - dy)
else
return 14 * dx + 10 * (dy - dx)
end
end
function AStar.get_distance(node_a, node_b)
local dx = math.abs(node_a.x - node_b.x)
local dy = math.abs(node_a.y - node_b.y)
if dx == 1 and dy == 1 then
return 14 -- Diagonal
else
return 10 -- Straight
end
end
function AStar.in_open_set(node, open_set)
for _, open_node in ipairs(open_set) do
if open_node == node then
return true
end
end
return false
end
function AStar.in_closed_set(node, closed_set)
for _, closed_node in ipairs(closed_set) do
if closed_node == node then
return true
end
end
return false
end
function AStar.retrace_path(start_node, end_node)
local path = {}
local current_node = end_node
while current_node ~= start_node do
table.insert(path, 1, current_node) -- Insertar al inicio
current_node = current_node.parent
end
return path
end
Pathfinding.Grid = Grid
Pathfinding.AStar = AStar
return Pathfinding
Path Following
-- path_follower.script
local Pathfinding = require "main.pathfinding"
function init(self)
self.grid = Pathfinding.Grid.new(50, 50, 32) -- 50x50 grid con cells de 32px
self.current_path = nil
self.path_index = 1
self.movement_speed = 100
self.arrival_threshold = 16
self.recalculate_timer = 0
self.recalculate_interval = 1.0 -- Recalcular cada segundo
-- Configurar obstáculos del nivel
self:setup_obstacles()
end
local function setup_obstacles(self)
-- Marcar paredes como no transitables
local walls = collectionfactory.create("#wall_collection")
for _, wall in ipairs(walls) do
local wall_pos = go.get_position(wall)
local grid_x, grid_y = self.grid:world_to_grid(wall_pos)
self.grid:set_walkable(grid_x, grid_y, false)
end
end
local function find_path_to_target(self, target_pos)
local my_pos = go.get_position()
local path = Pathfinding.AStar.find_path(self.grid, my_pos, target_pos)
if path then
self.current_path = path
self.path_index = 1
return true
end
return false
end
local function follow_path(self, dt)
if not self.current_path or #self.current_path == 0 then
return false
end
if self.path_index > #self.current_path then
-- Camino completado
self.current_path = nil
return true
end
local target_node = self.current_path[self.path_index]
local target_world_pos = self.grid:grid_to_world(target_node.x, target_node.y)
local my_pos = go.get_position()
local direction = target_world_pos - my_pos
local distance = vmath.length(direction)
if distance <= self.arrival_threshold then
-- Llegamos al nodo actual, ir al siguiente
self.path_index = self.path_index + 1
else
-- Moverse hacia el nodo actual
direction = vmath.normalize(direction)
local new_pos = my_pos + direction * self.movement_speed * dt
go.set_position(new_pos)
-- Orientar sprite hacia la dirección de movimiento
if direction.x ~= 0 then
msg.post("#sprite", "set_hflip", {flip = direction.x < 0})
end
end
return false -- Aún siguiendo el camino
end
-- Smoothing del path
local function smooth_path(self, path)
if not path or #path < 3 then
return path
end
local smoothed_path = {path[1]}
for i = 2, #path - 1 do
local prev_node = path[i - 1]
local curr_node = path[i]
local next_node = path[i + 1]
-- Verificar si podemos hacer línea directa de prev a next
if not self:line_of_sight(prev_node, next_node) then
table.insert(smoothed_path, curr_node)
end
end
table.insert(smoothed_path, path[#path])
return smoothed_path
end
local function line_of_sight(self, node_a, node_b)
local dx = math.abs(node_b.x - node_a.x)
local dy = math.abs(node_b.y - node_a.y)
local x = node_a.x
local y = node_a.y
local x_inc = node_b.x > node_a.x and 1 or -1
local y_inc = node_b.y > node_a.y and 1 or -1
local error = dx - dy
dx = dx * 2
dy = dy * 2
while x ~= node_b.x or y ~= node_b.y do
local node = self.grid:get_node(x, y)
if not node or not node.walkable then
return false
end
if error > 0 then
x = x + x_inc
error = error - dy
else
y = y + y_inc
error = error + dx
end
end
return true
end
function update(self, dt)
-- Recalcular path periódicamente si tenemos objetivo
self.recalculate_timer = self.recalculate_timer + dt
if self.recalculate_timer >= self.recalculate_interval and self.target then
if find_path_to_target(self, self.target) then
self.current_path = smooth_path(self, self.current_path)
end
self.recalculate_timer = 0
end
-- Seguir path actual
follow_path(self, dt)
end
function on_message(self, message_id, message, sender)
if message_id == hash("move_to") then
self.target = message.position
if find_path_to_target(self, self.target) then
self.current_path = smooth_path(self, self.current_path)
end
elseif message_id == hash("stop_movement") then
self.current_path = nil
self.target = nil
end
end
🎲 Decision Making Systems
Utility AI
-- utility_ai.lua
local UtilityAI = {}
-- Acción con utilidad
local UtilityAction = {}
UtilityAction.__index = UtilityAction
function UtilityAction.new(name, execute_function, considerations)
local self = setmetatable({}, UtilityAction)
self.name = name
self.execute_function = execute_function
self.considerations = considerations or {}
self.utility_score = 0
return self
end
function UtilityAction:calculate_utility(context)
if #self.considerations == 0 then
self.utility_score = 0
return 0
end
local total_score = 1.0
for _, consideration in ipairs(self.considerations) do
local score = consideration:evaluate(context)
total_score = total_score * score
end
-- Modificar por número de consideraciones (compensation factor)
local compensation = 1.0 - (1.0 / #self.considerations)
local final_score = total_score + (compensation * (1.0 - total_score))
self.utility_score = math.max(0, math.min(1, final_score))
return self.utility_score
end
function UtilityAction:execute(context)
if self.execute_function then
return self.execute_function(context)
end
return false
end
-- Consideración para utility
local Consideration = {}
Consideration.__index = Consideration
function Consideration.new(name, input_function, response_curve)
local self = setmetatable({}, Consideration)
self.name = name
self.input_function = input_function
self.response_curve = response_curve or function(x) return x end
return self
end
function Consideration:evaluate(context)
local input_value = self.input_function(context)
return self.response_curve(input_value)
end
-- Response curves comunes
local ResponseCurves = {}
function ResponseCurves.linear(x)
return math.max(0, math.min(1, x))
end
function ResponseCurves.inverse_linear(x)
return 1.0 - ResponseCurves.linear(x)
end
function ResponseCurves.exponential(exponent)
return function(x)
return math.pow(math.max(0, math.min(1, x)), exponent)
end
end
function ResponseCurves.boolean_gate(threshold)
return function(x)
return x >= threshold and 1.0 or 0.0
end
end
function ResponseCurves.sigmoid(midpoint, steepness)
midpoint = midpoint or 0.5
steepness = steepness or 5
return function(x)
return 1.0 / (1.0 + math.exp(-steepness * (x - midpoint)))
end
end
-- AI Brain principal
local UtilityBrain = {}
UtilityBrain.__index = UtilityBrain
function UtilityBrain.new()
local self = setmetatable({}, UtilityBrain)
self.actions = {}
self.context = {}
self.current_action = nil
self.decision_interval = 0.2 -- Decisiones cada 200ms
self.decision_timer = 0
return self
end
function UtilityBrain:add_action(action)
table.insert(self.actions, action)
end
function UtilityBrain:update_context(new_context)
for key, value in pairs(new_context) do
self.context[key] = value
end
end
function UtilityBrain:make_decision()
local best_action = nil
local best_score = -1
-- Evaluar todas las acciones
for _, action in ipairs(self.actions) do
local score = action:calculate_utility(self.context)
if score > best_score then
best_score = score
best_action = action
end
end
-- Cambiar acción si es diferente
if best_action ~= self.current_action then
self.current_action = best_action
return true -- Cambio de acción
end
return false -- Sin cambio
end
function UtilityBrain:execute_current_action()
if self.current_action then
return self.current_action:execute(self.context)
end
return false
end
function UtilityBrain:update(dt)
self.decision_timer = self.decision_timer + dt
if self.decision_timer >= self.decision_interval then
self:make_decision()
self.decision_timer = 0
end
self:execute_current_action()
end
UtilityAI.UtilityAction = UtilityAction
UtilityAI.Consideration = Consideration
UtilityAI.ResponseCurves = ResponseCurves
UtilityAI.UtilityBrain = UtilityBrain
return UtilityAI
Advanced Enemy AI
-- advanced_enemy_ai.script
local UtilityAI = require "main.utility_ai"
function init(self)
self.ai_brain = UtilityAI.UtilityBrain.new()
-- Configurar contexto inicial
self.context = {
entity = go.get_id(),
health = 100,
max_health = 100,
target = nil,
distance_to_target = math.huge,
ammo = 30,
max_ammo = 30,
alert_level = 0,
last_damaged_time = 0,
nearby_allies = 0,
cover_available = false,
weapon_ready = true
}
self:setup_ai_actions()
self.ai_brain:update_context(self.context)
end
local function setup_ai_actions(self)
local ResponseCurves = UtilityAI.ResponseCurves
-- === ACCIÓN: ATACAR ===
local attack_considerations = {
-- ¿Hay objetivo?
UtilityAI.Consideration.new("has_target", function(ctx)
return ctx.target and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5)),
-- ¿Está en rango de ataque?
UtilityAI.Consideration.new("in_attack_range", function(ctx)
return math.max(0, 1.0 - ctx.distance_to_target / 100)
end, ResponseCurves.exponential(2)),
-- ¿Tengo munición?
UtilityAI.Consideration.new("has_ammo", function(ctx)
return ctx.ammo / ctx.max_ammo
end, ResponseCurves.exponential(3)),
-- ¿Mi arma está lista?
UtilityAI.Consideration.new("weapon_ready", function(ctx)
return ctx.weapon_ready and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5))
}
local attack_action = UtilityAI.UtilityAction.new("attack", function(ctx)
if ctx.target then
msg.post(ctx.target, "take_damage", {damage = 25, source = ctx.entity})
ctx.ammo = math.max(0, ctx.ammo - 1)
ctx.weapon_ready = false
-- Cooldown de arma
timer.delay(0.5, false, function()
self.context.weapon_ready = true
end)
return true
end
return false
end, attack_considerations)
-- === ACCIÓN: BUSCAR COBERTURA ===
local seek_cover_considerations = {
-- ¿Estoy dañado?
UtilityAI.Consideration.new("health_low", function(ctx)
return 1.0 - (ctx.health / ctx.max_health)
end, ResponseCurves.exponential(2)),
-- ¿Hay cobertura disponible?
UtilityAI.Consideration.new("cover_available", function(ctx)
return ctx.cover_available and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5)),
-- ¿Me han atacado recientemente?
UtilityAI.Consideration.new("recently_damaged", function(ctx)
local time_since_damage = socket.gettime() - ctx.last_damaged_time
return math.max(0, 1.0 - time_since_damage / 5.0)
end, ResponseCurves.exponential(2))
}
local seek_cover_action = UtilityAI.UtilityAction.new("seek_cover", function(ctx)
local cover_position = self:find_nearest_cover()
if cover_position then
msg.post(".", "move_to", {position = cover_position})
return true
end
return false
end, seek_cover_considerations)
-- === ACCIÓN: RECARGAR ===
local reload_considerations = {
-- ¿Necesito munición?
UtilityAI.Consideration.new("ammo_low", function(ctx)
return 1.0 - (ctx.ammo / ctx.max_ammo)
end, ResponseCurves.exponential(3)),
-- ¿Estoy seguro? (no hay enemigos cerca)
UtilityAI.Consideration.new("safe_to_reload", function(ctx)
return ctx.distance_to_target > 150 and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5))
}
local reload_action = UtilityAI.UtilityAction.new("reload", function(ctx)
ctx.ammo = ctx.max_ammo
ctx.weapon_ready = false
timer.delay(2.0, false, function()
self.context.weapon_ready = true
end)
msg.post("#sprite", "play_animation", {id = hash("reload")})
return true
end, reload_considerations)
-- === ACCIÓN: PATRULLAR ===
local patrol_considerations = {
-- ¿No hay amenazas?
UtilityAI.Consideration.new("no_threats", function(ctx)
return ctx.alert_level == 0 and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5)),
-- ¿No hay objetivo?
UtilityAI.Consideration.new("no_target", function(ctx)
return ctx.target == nil and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5))
}
local patrol_action = UtilityAI.UtilityAction.new("patrol", function(ctx)
-- Lógica de patrulla
if not self.patrol_target then
self.patrol_target = self:get_next_patrol_point()
end
local my_pos = go.get_position(ctx.entity)
local distance = vmath.length(self.patrol_target - my_pos)
if distance < 20 then
self.patrol_target = self:get_next_patrol_point()
end
msg.post(".", "move_to", {position = self.patrol_target})
return true
end, patrol_considerations)
-- === ACCIÓN: PEDIR AYUDA ===
local call_help_considerations = {
-- ¿Mi salud está muy baja?
UtilityAI.Consideration.new("critical_health", function(ctx)
return ctx.health < 30 and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5)),
-- ¿Hay aliados cerca?
UtilityAI.Consideration.new("allies_nearby", function(ctx)
return ctx.nearby_allies > 0 and 1.0 or 0.0
end, ResponseCurves.boolean_gate(0.5))
}
local call_help_action = UtilityAI.UtilityAction.new("call_help", function(ctx)
msg.post("ai_manager", "request_backup", {
position = go.get_position(ctx.entity),
urgency = "high"
})
return true
end, call_help_considerations)
-- Añadir todas las acciones al brain
self.ai_brain:add_action(attack_action)
self.ai_brain:add_action(seek_cover_action)
self.ai_brain:add_action(reload_action)
self.ai_brain:add_action(patrol_action)
self.ai_brain:add_action(call_help_action)
end
function update(self, dt)
-- Actualizar contexto
self:update_ai_context()
-- Actualizar brain AI
self.ai_brain:update_context(self.context)
self.ai_brain:update(dt)
end
local function update_ai_context(self)
-- Buscar objetivo más cercano
local player = game.get_player()
if player then
self.context.target = player
local my_pos = go.get_position(self.context.entity)
local target_pos = go.get_position(player)
self.context.distance_to_target = vmath.length(target_pos - my_pos)
else
self.context.target = nil
self.context.distance_to_target = math.huge
end
-- Verificar cobertura disponible
self.context.cover_available = self:check_cover_available()
-- Contar aliados cercanos
self.context.nearby_allies = self:count_nearby_allies()
-- Reducir nivel de alerta con el tiempo
if self.context.alert_level > 0 then
self.context.alert_level = math.max(0, self.context.alert_level - 0.1 * dt)
end
end
function on_message(self, message_id, message, sender)
if message_id == hash("take_damage") then
self.context.health = math.max(0, self.context.health - message.damage)
self.context.last_damaged_time = socket.gettime()
self.context.alert_level = 2.0
-- Si la salud es muy baja, priorizar cobertura
if self.context.health < 25 then
self.context.alert_level = 3.0
end
elseif message_id == hash("enemy_spotted") then
self.context.target = message.enemy
self.context.alert_level = 2.0
elseif message_id == hash("backup_arrived") then
self.context.nearby_allies = self.context.nearby_allies + 1
end
end
📚 Recursos y Referencias
Algoritmos de IA
- A Pathfinding* - Búsqueda de caminos eficiente
- Behavior Trees - Arquitectura modular de comportamientos
- Utility AI - Toma de decisiones basada en utilidad
- Goal-Oriented Action Planning - Planificación dinámica
Libros Recomendados
- AI for Games - Ian Millington
- Behavioral Mathematics for Game AI - Dave Mark
- Game AI Pro series - Multiple authors
Links Útiles
🎯 Ejercicios Propuestos
-
Squad AI: Crea un sistema de IA para equipos que coordinen ataques y movimientos.
-
Dynamic Difficulty: Implementa un sistema que ajuste la IA según el rendimiento del jugador.
-
Procedural Behavior: Diseña comportamientos que emerjan de reglas simples.
-
Learning AI: Crea una IA que aprenda de las acciones del jugador y se adapte.
-
Conversation AI: Desarrolla un sistema de diálogos con NPCs que respondan contextualmente.
La IA en juegos no se trata de crear superinteligencias, sino de crear la ilusión de inteligencia que mejore la experiencia del jugador. Con estos sistemas podrás crear enemigos y NPCs que se sientan vivos y respondan de manera creíble al mundo del juego.