← Volver al listado de tecnologías

Inteligencia Artificial para Enemigos y NPCs

Por: Artiko
defoldinteligencia-artificialbehavior-treespathfindinggame-ai

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

Libros Recomendados

🎯 Ejercicios Propuestos

  1. Squad AI: Crea un sistema de IA para equipos que coordinen ataques y movimientos.

  2. Dynamic Difficulty: Implementa un sistema que ajuste la IA según el rendimiento del jugador.

  3. Procedural Behavior: Diseña comportamientos que emerjan de reglas simples.

  4. Learning AI: Crea una IA que aprenda de las acciones del jugador y se adapte.

  5. 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.