Decisiones Intermedio 17 min de lectura

Emotional models para NPCs: OCC, PAD y cómo aplicarlos

Modelos psicológicos clásicos en código: cómo OCC y PAD te dan estados emocionales en NPCs que duran, decaen y modulan decisiones sin caer en valores binarios.

Publicado: · Por Juanjo "Banyo" López

0. Introducción

Tu NPC tiene bool isAngry. El jugador le robó una manzana hace 30 segundos. Sigue enojado. El jugador le salvó la vida hace 2 minutos. Sigue enojado igual. Mientras isAngry == true, ataca; mientras esté false, saluda. No hay matices, no hay decay, no hay diferencia entre “molesto” y “furioso”. Las emociones humanas no funcionan así, y tus NPCs tampoco deberían.

Este tutorial te muestra cómo dos modelos psicológicos clásicos — OCC (Ortony, Clore & Collins, 1988) y PAD (Mehrabian, 1996) — se traducen a código y te dan un estado afectivo continuo, con intensidad, decay y personalidad. No es académico: The Sims modela motives con esta lógica, Façade (Mateas & Stern, 2003) calcula tensión dramática así, y Black & White usa stats afectivos para que la criatura aprenda con tu refuerzo.

¿Qué vas a ver?

  • Qué es OCC y cómo categoriza 22 emociones según el tipo de evento que las dispara.
  • Qué es PAD y por qué representar la emoción en 3 ejes continuos te da blending gratis.
  • Cómo combinar ambos modelos con decay temporal y baseline de personalidad.
  • Cómo modular tu Utility AI, BT o FSM con el estado emocional sin reescribirlos.

1. Demo

Emotional state en vivo Placeholder: NPC con estado PAD en tiempo real. Eventos disparan appraisals, el estado decae hacia su baseline de personalidad.

2. Concepto

OCC y PAD son dos modelos psicológicos clásicos para representar emociones en código. OCC categoriza 22 tipos según el evento que las dispara; PAD las posiciona en un espacio de 3 ejes continuos. Los usas para que las decisiones del NPC reflejen su estado afectivo actual.

A partir de aquí, todo es cómo combinarlos.

2.1 ¿Qué es OCC y qué problema resuelve?

OCC viene del libro The Cognitive Structure of Emotions (Ortony, Clore & Collins, 1988). La idea central es cognitivista: una emoción no aparece de la nada, es la valoración (appraisal) que un agente hace sobre algo que sucedió.

Cada emoción nace de una de tres ramas:

  • Event-based — valoras un evento por su deseabilidad. Si te conviene, sientes joy; si no, distress. Si le pasa a otro y te alegra, gloating; si te apena, pity.
  • Agent-based — valoras la acción de un agente por si es loable o reprochable. Si alguien hizo algo bueno, admiration; si fue una bajeza, reproach. Sobre ti mismo, pride o shame.
  • Object-based — valoras un objeto por si te atrae o repele. De ahí salen love y hate.

Las combinaciones dan 22 emociones nombradas. La tabla resumida:

RamaVariablePositivaNegativa
Event (propio, ya)deseabilidadjoydistress
Event (propio, futuro)prospectohopefear
Event (propio, confirmado)confirmaciónsatisfactionfears-confirmed
Event (otro)empatíahappy-forpity
Event (otro)rivalidadgloatingresentment
Agent (otro)aprobaciónadmirationreproach
Agent (propio)aprobaciónprideshame
Compuestas (event + agent)gratitude, gratificationanger, remorse
Objectatracciónlovehate

No necesitas memorizar la tabla. Lo importante es la estructura: para disparar una emoción, necesitas identificar qué tipo de cosa pasó y cómo el agente la valora.

2.2 ¿Cómo se mapea OCC en código?

Cada evento del juego se convierte en un appraisal: una tupla (agente, evento, valencia, intensidad). La función de appraisal devuelve una emoción concreta con su magnitud.

Ejemplo:

event = { type: "saved-my-life", source: player, target: self }
appraisal:
    rama:        event-based + agent-based  → compuesta
    valencia:    positiva (el evento me beneficia)
    intensidad:  0.8 (la vida es importante)
    emoción:     gratitude  hacia player, intensidad 0.8

Otro:

event = { type: "stole-my-food", source: player, target: self }
appraisal:
    rama:        event-based + agent-based  → compuesta
    valencia:    negativa
    intensidad:  0.3 (era solo comida)
    emoción:     anger  hacia player, intensidad 0.3

Las emociones acumulan hacia objetivos: el NPC no está enojado “en general”, está enojado con el jugador. Eso te da memoria afectiva por agente sin estructuras de datos exóticas.

2.3 PAD: el espacio de 3 ejes

OCC te da etiquetas. Útiles para narrativa, pero discretas: estás en gratitude o no lo estás. PAD (Mehrabian, 1996) ataca el problema desde el lado opuesto: representa cualquier estado emocional como un punto en un cubo de 3 ejes continuos.

Cubo PAD: Pleasure / Arousal / Dominance
+Pleasure−Pleasure+Dominance−Dominance+Arousal−ArousalJoy (+P+A+D)AngerFearSadnessPrideShameorigen (neutro)

Tres ejes continuos en [−1, +1]. Cada emoción ocupa una región del cubo; el blending entre estados es interpolación dentro del volumen.

  • Pleasure (−1..+1): qué tan agradable es lo que sientes. + = alegría, satisfacción. = tristeza, asco.
  • Arousal (−1..+1): qué tan activado/excitado estás. + = exaltado, alerta. = relajado, dormido.
  • Dominance (−1..+1): qué tanto controlas la situación. + = poderoso, en control. = sometido, indefenso.

Cada emoción ocupa una región:

EmociónPAD
Joy+0.8+0.5+0.4
Anger−0.5+0.6+0.3
Fear−0.6+0.7−0.5
Sadness−0.6−0.3−0.4
Boredom−0.3−0.6−0.2
Pride+0.6+0.3+0.7
Shame−0.5+0.1−0.6

La ventaja: como es un espacio continuo, el blending sale gratis. Si tu NPC está mitad triste, mitad enojado, su estado es un punto entre (-0.6, -0.3, -0.4) y (-0.5, +0.6, +0.3). Una transición se interpola por el cubo en vez de saltar entre etiquetas.

2.4 ¿Cuándo OCC y cuándo PAD?

No son rivales. Sirven a propósitos distintos:

  • OCC brilla cuando necesitas la etiqueta: elegir diálogo (if (emotion == gratitude) say("gracias")), disparar animaciones específicas, escribir lógica narrativa.
  • PAD brilla cuando necesitas modular algo continuo: velocidad de movimiento (más arousal → camina más rápido), pitch de voz, peso de una acción en tu Utility AI, intensidad de un blend de animaciones.

La combinación habitual en producción: OCC dispara emociones ante eventos (entrada), PAD mantiene el estado afectivo que persiste y decae (almacenamiento), y se consulta indistintamente como etiqueta (busca la emoción más cercana en el cubo) o como vector continuo.

2.5 ¿Por qué necesitas decay temporal?

Si sumas deltas a state sin que nada los reduzca, tu NPC se vuelve un acumulador de rencores eternos. Las emociones humanas decaen — algunas rápido (sorpresa, en segundos), otras lento (duelo, en meses). En código, eso es un parámetro por dimensión o por emoción.

Decay exponencial hacia el baseline
1.00.0baseintensidadtiempo →baselineeventodecay exponencial → baseline

Tras un evento, la intensidad cae exponencialmente hacia la línea base de personalidad (no hacia cero).

El truco importante: el decay no va hacia 0, va hacia el baseline de personalidad. Un NPC optimista no se calma hasta neutralidad, se calma hasta “ligeramente contento”. Volvemos a esto en 2.6.

Constantes típicas (segundos para volver al 37% del valor inicial, asumiendo decay exponencial):

Emociónτ aproximado
Surprise2 s
Joy30 s
Anger120 s
Fear60 s
Sadness300 s
Love / hateminutos a horas

No copies los números a ciegas: ajústalos al ritmo de tu juego. Un survival con jornadas largas necesita decays más lentos que un brawler arcade.

2.6 Personalidad como baseline

Si dos NPCs reciben el mismo evento, deberían reaccionar distinto. La forma fea es duplicar lógica. La forma elegante es darle a cada NPC un vector PAD baseline que representa su temperamento.

optimista:    baseline = ( +0.3,  +0.1,  +0.2 )   # alegre, algo activo, confiado
melancólico:  baseline = ( -0.2,  -0.2,  -0.1 )   # apagado, lento, inseguro
agresivo:     baseline = (  0.0,  +0.3,  +0.5 )   # neutro placer, tenso, dominante
sumiso:       baseline = (  0.0,   0.0,  -0.5 )

Las emociones se suman al baseline. El decay arrastra al estado hacia el baseline, no hacia el origen. Resultado: el optimista vuelve a sonreír después de un susto; el melancólico vuelve a su tristeza basal.

Bonus: la personalidad deja de ser un flag (bool isCheerful) y se vuelve un vector. Puedes interpolarla, randomizarla con ruido controlado para variar NPCs del mismo arquetipo, o moverla lentamente durante la partida (un NPC traumatizado por la guerra ve su baseline desplazarse hacia -P, -D).

2.7 ¿Cómo modular comportamientos con el estado emocional?

El estado afectivo no decide nada por sí mismo. Es un modulador de tu sistema de decisión existente:

  • Utility AI — multiplica el score de cada acción por un bonus emocional. Un NPC con anger > 0.5 y dominance > 0.3 recibe score(attack) *= 1.5. Un NPC con fear > 0.5 recibe score(flee) *= 1.7 y score(attack) *= 0.5.
  • Behavior Tree — añade condiciones extra en las hojas o decoradores que solo dejan pasar el tick si el estado cumple un umbral (Cooldown mientras arousal > 0.7, por ejemplo).
  • FSM / HFSM — añade transiciones condicionadas a regiones del cubo PAD: Patrullar → Atacar requiere pleasure < 0 AND dominance > 0.

La regla: el sistema de decisión sigue siendo tu BT/Utility/FSM. La capa emocional inclina las decisiones, no las reemplaza. Si el NPC se queda sin munición, no ataca por más furioso que esté.

3. Pseudocódigo

struct PADVector
    p: Float    # [-1, +1]
    a: Float    # [-1, +1]
    d: Float    # [-1, +1]

class EmotionalAgent
    personality: PADVector       # baseline, fijo por NPC
    state:       PADVector       # estado actual, varía cada frame
    decayRate:   Float = 0.5     # por segundo, hacia baseline

function appraise(agent: EmotionalAgent, event: Event) -> PADVector
    # mapeo OCC simplificado: tipo de evento → delta PAD
    switch event.type
        case PlayerHelpedMe:  return PADVector( +0.4, +0.2, -0.1 )   # gratitude
        case PlayerHurtMe:    return PADVector( -0.5, +0.6, -0.3 )   # anger + fear
        case AllyDied:        return PADVector( -0.6, +0.3, -0.4 )   # grief
        case ThreatNear:      return PADVector( -0.4, +0.7, -0.5 )   # fear
        case GoalAchieved:    return PADVector( +0.7, +0.4, +0.3 )   # joy + pride
        default:              return PADVector( 0, 0, 0 )

function update(agent: EmotionalAgent, dt: Float)
    # decay exponencial hacia el baseline, NO hacia cero
    delta = (agent.personality - agent.state) * agent.decayRate * dt
    agent.state = agent.state + delta

function onEvent(agent: EmotionalAgent, event: Event)
    delta = appraise(agent, event)
    agent.state = clampPAD( agent.state + delta )

function modulateUtility(agent: EmotionalAgent, action: Action, baseScore: Float) -> Float
    # ejemplo: emociones inclinan el scoring de Utility AI
    if action.tag == "attack"
        bonus = 1 + max(0, -agent.state.p) * 0.5 + max(0, agent.state.d) * 0.3
    else if action.tag == "flee"
        bonus = 1 + max(0, agent.state.a) * 0.4 + max(0, -agent.state.d) * 0.6
    else
        bonus = 1
    return baseScore * bonus

Tres entradas conceptuales: appraise traduce eventos → deltas PAD, update decae cada frame, onEvent aplica el delta. La modulación de Utility/BT vive fuera, en tu sistema de decisión, y consulta state como dato más.

4. Implementación en Unity / C#

using System;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public struct PADVector {
    public float p, a, d;
    public PADVector(float p, float a, float d) { this.p = p; this.a = a; this.d = d; }
    public static PADVector operator +(PADVector x, PADVector y) =>
        new PADVector(x.p + y.p, x.a + y.a, x.d + y.d);
    public static PADVector operator -(PADVector x, PADVector y) =>
        new PADVector(x.p - y.p, x.a - y.a, x.d - y.d);
    public static PADVector operator *(PADVector v, float s) =>
        new PADVector(v.p * s, v.a * s, v.d * s);
    public PADVector Clamped() =>
        new PADVector(Mathf.Clamp(p, -1, 1), Mathf.Clamp(a, -1, 1), Mathf.Clamp(d, -1, 1));
}

public class EmotionalAgent : MonoBehaviour {
    [Header("Personalidad (baseline)")]
    public PADVector personality = new PADVector(0, 0, 0);

    [Header("Estado actual")]
    public PADVector state;

    [Tooltip("Velocidad de retorno al baseline por segundo.")]
    public float decayRate = 0.5f;

    readonly Dictionary<string, PADVector> appraisals = new() {
        { "PlayerHelpedMe", new PADVector(+0.4f, +0.2f, -0.1f) },
        { "PlayerHurtMe",   new PADVector(-0.5f, +0.6f, -0.3f) },
        { "AllyDied",       new PADVector(-0.6f, +0.3f, -0.4f) },
        { "ThreatNear",     new PADVector(-0.4f, +0.7f, -0.5f) },
        { "GoalAchieved",   new PADVector(+0.7f, +0.4f, +0.3f) },
    };

    void Start() => state = personality;

    void Update() {
        // decay hacia baseline, no hacia cero
        var delta = (personality - state) * (decayRate * Time.deltaTime);
        state = (state + delta).Clamped();
    }

    public void OnEvent(string eventType) {
        if (!appraisals.TryGetValue(eventType, out var d)) return;
        state = (state + d).Clamped();
    }

    /// <summary>Aplica el sesgo emocional a un score de Utility AI.</summary>
    public float ModulateUtility(string actionTag, float baseScore) {
        float bonus = actionTag switch {
            "attack" => 1f + Mathf.Max(0f, -state.p) * 0.5f + Mathf.Max(0f, state.d) * 0.3f,
            "flee"   => 1f + Mathf.Max(0f,  state.a) * 0.4f + Mathf.Max(0f, -state.d) * 0.6f,
            "rest"   => 1f + Mathf.Max(0f, -state.a) * 0.4f,
            _        => 1f,
        };
        return baseScore * bonus;
    }
}

Uso típico en un controlador con Utility:

float score = action.BaseScore(agent);
score = emotionalAgent.ModulateUtility(action.tag, score);

5. En otros engines

  • Godot: define PADVector como Resource exportable y la personalidad como .tres. El EmotionalAgent es un Node con _process(delta) haciendo el decay.
  • Unreal: UStruct para FPADVector, UDataAsset para la personalidad. El UEmotionalComponent se enchufa a cualquier AActor con IA.
  • JavaScript / cualquier engine: la matemática es trivial — tres floats, una suma, una interpolación. La lógica se porta a cualquier game loop en una tarde.

6. Quiz

Pon a prueba lo que entendiste

Responde una por una. La explicación aparece al elegir, correcta o no.

  1. Tu NPC se queda enojado para siempre después de un evento negativo. ¿Qué le falta al modelo?

  2. Quieres dos NPCs reaccionando distinto al mismo evento. ¿Qué editas?

  3. ¿Cuándo te conviene OCC sobre PAD?

  4. Modulas Utility AI con PAD. Tu NPC muy 'feliz' decide no defenderse cuando lo atacan. ¿Cómo lo arreglas?