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
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,prideoshame. - Object-based — valoras un objeto por si te atrae o repele. De ahí salen
loveyhate.
Las combinaciones dan 22 emociones nombradas. La tabla resumida:
| Rama | Variable | Positiva | Negativa |
|---|---|---|---|
| Event (propio, ya) | deseabilidad | joy | distress |
| Event (propio, futuro) | prospecto | hope | fear |
| Event (propio, confirmado) | confirmación | satisfaction | fears-confirmed |
| Event (otro) | empatía | happy-for | pity |
| Event (otro) | rivalidad | gloating | resentment |
| Agent (otro) | aprobación | admiration | reproach |
| Agent (propio) | aprobación | pride | shame |
| Compuestas (event + agent) | — | gratitude, gratification | anger, remorse |
| Object | atracción | love | hate |
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.
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ón | P | A | D |
|---|---|---|---|
| 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.
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 |
|---|---|
| Surprise | 2 s |
| Joy | 30 s |
| Anger | 120 s |
| Fear | 60 s |
| Sadness | 300 s |
| Love / hate | minutos 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.5ydominance > 0.3recibescore(attack) *= 1.5. Un NPC confear > 0.5recibescore(flee) *= 1.7yscore(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 (
Cooldownmientrasarousal > 0.7, por ejemplo). - FSM / HFSM — añade transiciones condicionadas a regiones del cubo PAD:
Patrullar → Atacarrequierepleasure < 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
PADVectorcomoResourceexportable y la personalidad como.tres. ElEmotionalAgentes unNodecon_process(delta)haciendo el decay. - Unreal:
UStructparaFPADVector,UDataAssetpara la personalidad. ElUEmotionalComponentse enchufa a cualquierAActorcon 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.
Tu NPC se queda enojado para siempre después de un evento negativo. ¿Qué le falta al modelo?
Quieres dos NPCs reaccionando distinto al mismo evento. ¿Qué editas?
¿Cuándo te conviene OCC sobre PAD?
Modulas Utility AI con PAD. Tu NPC muy 'feliz' decide no defenderse cuando lo atacan. ¿Cómo lo arreglas?