Ir al contenido
wannacode

Niveles, diálogos y misiones

En resumen: La experiencia se cuenta con una tabla y los niveles y habilidades nuevos se dan de uno en uno; el combate y el mapa solo publican eventos en un bus, mientras las misiones se suscriben y son máquinas de estados; un diálogo es un árbol en el que las condiciones ocultan las respuestas que no vienen al caso.

Problema

El lobo que no sabe que hay una misión

Hasta ahora el héroe mataba enemigos y nada más. Ahora el juego quiere que pase algo más: matar al lobo jefe debe cerrar una misión, matar al Guardián debe dar la victoria, entrar al segundo nivel debe avanzar otra misión y la experiencia debe subir el nivel. Lo más simple es escribirlo donde muere el enemigo:

void OnEnemyKilled(Enemy enemy)
{
hero.Xp += enemy.Xp;
// El combate conoce cada parte del juego a la que le importa que muera un enemigo.
quests.EnemyKilled(enemy);
achievements.EnemyKilled(enemy);
// Un oyente nuevo: una línea más aquí y un cambio más en el combate.
stats.Kills++;
}

Aquí el combate conoce las misiones, los logros y las estadísticas. Una misión nueva es una línea nueva en el combate, y el combate ya tiene muchas razones para cambiar. Peor aún: cada oyente nuevo se añade en código ajeno, y cada enlace olvidado es una misión que nunca termina.

La solución es invertir la dirección del conocimiento. El combate dice en un solo lugar «el enemigo murió» y no se preocupa de quién escucha. Quien quiere saberlo se apunta (se suscribe). Eso es un bus de eventos. La lección construye cinco cosas, en el orden en que las usa el juego: experiencia y niveles (una curva como tabla), un árbol de habilidades, un bus de eventos, una misión como máquina de estados y un diálogo como árbol con condiciones.

Así se ve el mismo lugar cuando hay un bus. El combate publica una línea y nada más:

void OnEnemyKilled(Enemy enemy)
{
hero.Xp += enemy.Xp;
bus.Publish(new GameEvent("EnemyKilled", enemy.Kind));
}

La idea

Experiencia y niveles: la curva es una tabla

¿Cuánta experiencia pide cada nivel? Una fórmula (por ejemplo 100 × nivel²) es suave, pero incómoda para afinar un juego: quieres decir «el nivel 3 llega tras unos seis enemigos», no calcular. Por eso la curva es una tabla: XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n. Pasado el final de la tabla la curva sigue con 150 más por nivel, para que el juego no falle en el nivel 11.

El héroe tiene xp y level. En cada muerte xp crece, y pendingLevels dice cuántos niveles esperan: cuántos de los siguientes umbrales ya alcanzó la experiencia. El nivel nuevo es un paso aparte (raiseLevel): más vida. El programa mata a siete enemigos del juego, cada uno con su experiencia (rata 5, goblin 9, ... el Guardián 150), y escribe cada nivel nuevo:

class Hero
{
public int Level = 1;
public int Xp = 0;
public int MaxHp = 48;
}
record Kill(string Name, int Xp);
// XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level)
{
if (level < xpTable.Length) return xpTable[level];
// Pasado el final de la tabla la curva sigue con 150 por nivel.
return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}
// Cuántos niveles esperan: ¿llega la experiencia a los siguientes umbrales?
int PendingLevels(int xp, int level)
{
int levels = 0;
while (xp >= XpForLevel(level + levels + 1)) levels++;
return levels;
}
void RaiseLevel(Hero hero)
{
hero.Level += 1;
hero.MaxHp += 9;
}
Kill[] kills = [new Kill("rata", 5), new Kill("goblin", 9), new Kill("chamán", 12), new Kill("lobo", 14), new Kill("cultista", 20), new Kill("gólem", 32), new Kill("Guardián", 150)];
var hero = new Hero();
foreach (var kill in kills)
{
hero.Xp += kill.Xp;
Console.WriteLine(kill.Name + " +" + kill.Xp + ": exp " + hero.Xp);
int raise = PendingLevels(hero.Xp, hero.Level);
for (int i = 0; i < raise; i++)
{
int before = hero.MaxHp;
RaiseLevel(hero);
Console.WriteLine(" nivel " + hero.Level + ": vida " + before + " -> " + hero.MaxHp);
}
}
for (int level = 10; level <= 12; level++)
{
Console.WriteLine("nivel " + level + ": exp " + XpForLevel(level));
}
Salida
rata +5: exp 5
goblin +9: exp 14
chamán +12: exp 26
nivel 2: vida 48 -> 57
lobo +14: exp 40
cultista +20: exp 60
nivel 3: vida 57 -> 66
gólem +32: exp 92
Guardián +150: exp 242
nivel 4: vida 66 -> 75
nivel 5: vida 75 -> 84
nivel 10: exp 720
nivel 11: exp 870
nivel 12: exp 1020

El Guardián (+150) da dos niveles de golpe, el 4 y el 5. Por eso pendingLevels es un bucle y no una comparación: un golpe grande puede saltarse un umbral. Las últimas tres líneas muestran la cola de la tabla: el nivel 10 está en ella (720), mientras que el 11 y el 12 salen del + 150 por nivel (870, 1020).

// XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];

int XpForLevel(int level)
{
    if (level < xpTable.Length) return xpTable[level];
    // Pasado el final de la tabla la curva sigue con 150 por nivel.
    return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}

// Cuántos niveles esperan: ¿llega la experiencia a los siguientes umbrales?
int PendingLevels(int xp, int level)
{
    int levels = 0;
    while (xp  XpForLevel(level + levels + 1)) levels++;
    return levels;
}
Más precisamente, para curiosos

¿Por qué una tabla y no una fórmula? La tabla son datos: un ritmo más lento, un nivel nuevo u otro modo es otra tabla, no otro código. El canon la guarda como números separados por | (0|0|20|55|105|170|250|345|455|580|720) y la lee al arrancar junto con los objetos y los enemigos. Los dos primeros ceros son para los niveles 0 y 1: el héroe empieza en el nivel 1.

La curva se afina con la simulación de la última lección: se juegan miles de partidas y se mira qué nivel alcanza el héroe frente a qué enemigo.

La idea

El árbol de habilidades: dos caminos de tres pasos

En cada nivel nuevo el jugador elige: un atributo (+8 vida, +1 fuerza, +1 destreza, +1 defensa) o una habilidad nueva. Las habilidades de una clase forman un árbol con dos ramas de tres pasos. La regla es corta: una habilidad está disponible si aún no está aprendida y es el paso 1, o si en la misma rama está aprendido el paso inmediatamente inferior.

record Skill(string Id, int Branch, int Tier);
Skill[] allSkills = [
new Skill("rage", 1, 1),
new Skill("whirlwind", 1, 2),
new Skill("bloodlust", 1, 3),
new Skill("war_cry", 2, 1),
new Skill("iron_skin", 2, 2),
new Skill("second_wind", 2, 3)];
string[] order = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"];
List<string> Learnable(Skill[] skills, List<string> known)
{
var result = new List<string>();
foreach (var skill in skills)
{
if (known.Contains(skill.Id)) continue;
bool ready = skill.Tier == 1;
foreach (var other in skills)
{
if (known.Contains(other.Id) && other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true;
}
if (ready) result.Add(skill.Id);
}
return result;
}
string Show(List<string> list)
{
return list.Count == 0 ? "-" : string.Join(", ", list);
}

El programa aprende las habilidades del bárbaro en un orden dado y tras cada una escribe qué está disponible. Una lista vacía es -.

var known = new List<string>();
Console.WriteLine("inicio -> " + Show(Learnable(allSkills, known)));
foreach (var id in order)
{
known.Add(id);
Console.WriteLine(id + " -> " + Show(Learnable(allSkills, known)));
}
Salida
inicio -> rage, war_cry
rage -> whirlwind, war_cry
war_cry -> whirlwind, iron_skin
whirlwind -> bloodlust, iron_skin
iron_skin -> bloodlust, second_wind
bloodlust -> second_wind
second_wind -> -

Al principio solo están disponibles los dos comienzos: rage y war_cry. Aprender rage abre whirlwind, pero bloodlust aún espera (pide whirlwind), y la rama de war_cry no cambia. Al final, second_wind -> -: el árbol se agotó y en la elección de nivel nuevo solo quedan los atributos.

La idea

Un bus de eventos: quien publica no sabe quién escucha

El bus es lo más simple: un diccionario de tipo de evento a lista de funciones. subscribe(type, handler) pone una función en la lista de ese tipo. publish(event) llama a todas las funciones de la lista del tipo del evento. Nada más.

Un evento es un registro pequeño: un tipo y datos. Aquí son type, id (el enemigo, el objeto o el diálogo) y depth (el nivel). Ejemplos: EnemyKilled (un enemigo murió), TileEntered (el héroe entró a un nivel), DialogChoice (se eligió una respuesta) e ItemPicked (se recogió un objeto).

record GameEvent(string Type, string Id = "", int Depth = 0)
{
public string Label => Id == "" ? Depth.ToString() : Id;
}
class EventBus
{
readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>();
handlers[type].Add(handler);
}
public void Publish(GameEvent e)
{
if (!handlers.ContainsKey(e.Type)) return;
// Una copia de la lista: un oyente puede suscribirse o darse de baja mientras se publica.
foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e);
}
}

El primer programa pone dos oyentes en EnemyKilled (un registro y el contador de bajas) y uno en TileEntered. En ItemPicked no escucha nadie y el bus simplemente no hace nada.

var bus = new EventBus();
var stats = new Stats();
bus.Subscribe("EnemyKilled", e => Log(e));
bus.Subscribe("TileEntered", e => Log(e));
bus.Subscribe("EnemyKilled", e =>
{
stats.Kills++;
Console.WriteLine("bajas: " + stats.Kills);
});
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("ItemPicked", "ore"));
Salida
> EnemyKilled rat
registro: EnemyKilled rat
bajas: 1
> TileEntered 2
registro: TileEntered 2
> EnemyKilled wolf
registro: EnemyKilled wolf
bajas: 2
> ItemPicked ore

Cada línea con > es una publicación, y las líneas de debajo son los oyentes que respondieron, en el orden en que se suscribieron. Tras EnemyKilled rat responden los dos, tras TileEntered 2 solo el registro, y ItemPicked ore no despierta a nadie. El código que publica no cambió ni una línea cuando aparecieron los oyentes.

Más precisamente, para curiosos

Este patrón se llama Observer (observador) o publish/subscribe. En el mundo de la interfaz es addEventListener, en los servidores son las colas de mensajes. El juego usa la forma más simple: todo en un hilo, los oyentes se llaman de inmediato, uno tras otro. Por eso un oyente que hace algo pesado frena todo el turno. Los oyentes se llaman en el orden en que se suscribieron, pero no hay que confiar en él (en el canon ningún oyente depende de otro).

publish recorre una copia de la lista. Una misión que inicia otra misión puede suscribirse al mismo tipo durante la publicación; sin la copia esto lanza una excepción en Java y C#, y en JavaScript el oyente nuevo se llamaría en la misma publicación.

La idea

Una misión es una máquina de estados

Una misión tiene un estado: inactive (aún no dada), active (con etapa 1, 2, 3), done (cumplida) y rewarded (la recompensa está recogida). Las transiciones no las hace el jugador, sino los eventos. Por eso la misión se suscribe sola a los que la mueven y guarda toda su lógica en un solo lugar.

De A Cuándo
inactive active, etapa 1 el anciano da la misión (la acción start del diálogo)
active, etapa 1 etapa 2 TileEntered con depth 2
active, etapa menor que 3 etapa 3 DialogChoice miner:warden
active done EnemyKilled warden
done rewarded el diálogo ejecuta la acción reward

La base Quest es la parte común: un título, una lista de metas por etapa y tres métodos que cambian el estado y escriben al jugador. MinersQuest («Los mineros desaparecidos») es una subclase que solo se suscribe:

class Quest
{
public readonly string Id;
public readonly string Title;
public readonly string[] Goals;
public readonly string DoneGoal;
public string State = "inactive";
public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal)
{
Id = id;
Title = title;
Goals = goals;
DoneGoal = doneGoal;
}
public virtual string Goal()
{
if (State == "done") return DoneGoal;
return Goals[Stage - 1];
}
public void Start()
{
State = "active";
Stage = 1;
Console.WriteLine("Nueva misión: " + Title + ".");
}
public void Advance(int newStage)
{
Stage = newStage;
Console.WriteLine(Title + ": " + Goal());
}
public void Complete()
{
State = "done";
Console.WriteLine("¡Misión cumplida: " + Title + "!");
}
}
class MinersQuest : Quest
{
public MinersQuest() : base("miners", "Los mineros desaparecidos", ["Baja a la mina y busca a los mineros.", "Alguien está vivo en el segundo nivel. Encuéntralo.", "Derrota al Guardián abisal en el tercer nivel."], "El Guardián está derrotado. Vadogris está a salvo.")
{
}
public void Subscribe(EventBus bus)
{
bus.Subscribe("TileEntered", e =>
{
if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2);
});
bus.Subscribe("DialogChoice", e =>
{
if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3);
});
bus.Subscribe("EnemyKilled", e =>
{
if (State == "active" && e.Id == "warden") Complete();
});
}
}

Cada oyente empieza con state == "active": el bus no conoce el estado de la misión, así que la pregunta es de la misión. La segunda condición (stage == 1, stage < 3, id == ...) dice qué evento exacto mueve exactamente esta transición. La base no tiene subscribe a propósito; en el canon existe como método vacío, para que el juego pueda recorrer todas las misiones y llamar a subscribe en cada una.

El escenario tiene ocho pasos, y la misión ignora cuatro. El anciano da la misión en el tercero, y los pasos son justo los que muestra la escena de abajo.

var bus = new EventBus();
var quest = new MinersQuest();
quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Console.WriteLine("> el anciano da la misión");
quest.Start();
Fire(bus, new GameEvent("TileEntered", Depth: 1));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("DialogChoice", "miner:warden"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("EnemyKilled", "warden"));
Salida
> EnemyKilled rat
> TileEntered 2
> el anciano da la misión
Nueva misión: Los mineros desaparecidos.
> TileEntered 1
> TileEntered 2
Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.
> DialogChoice miner:warden
Los mineros desaparecidos: Derrota al Guardián abisal en el tercer nivel.
> TileEntered 2
> EnemyKilled warden
¡Misión cumplida: Los mineros desaparecidos!

Míralo

Los eventos viajan hacia la misión

Arriba está el camino de la misión con una bola que se posa sobre su estado. Cada evento es una ficha: sube al bus, va hasta la misión y la misión o avanza, o se sacude y dice «ignora». Se ve cuáles cuatro de los ocho eventos no mueven nada y por qué: la misión aún no empezó, el nivel es otro, y en uno la etapa ya pasó.

class EventBus
{
readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>();
handlers[type].Add(handler);
}
public void Publish(GameEvent e)
{
if (!handlers.ContainsKey(e.Type)) return;
// Una copia de la lista: un oyente puede suscribirse o darse de baja mientras se publica.
foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e);
}
}
class Quest
{
public readonly string Id;
public readonly string Title;
public readonly string[] Goals;
public readonly string DoneGoal;
public string State = "inactive";
public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal)
{
Id = id;
Title = title;
Goals = goals;
DoneGoal = doneGoal;
}
public virtual string Goal()
{
if (State == "done") return DoneGoal;
return Goals[Stage - 1];
}
public void Start()
{
State = "active";
Stage = 1;
Console.WriteLine("Nueva misión: " + Title + ".");
}
public void Advance(int newStage)
{
Stage = newStage;
Console.WriteLine(Title + ": " + Goal());
}
public void Complete()
{
State = "done";
Console.WriteLine("¡Misión cumplida: " + Title + "!");
}
}
class MinersQuest : Quest
{
public MinersQuest() : base("miners", "Los mineros desaparecidos", ["Baja a la mina y busca a los mineros.", "Alguien está vivo en el segundo nivel. Encuéntralo.", "Derrota al Guardián abisal en el tercer nivel."], "El Guardián está derrotado. Vadogris está a salvo.")
{
}
public void Subscribe(EventBus bus)
{
bus.Subscribe("TileEntered", e =>
{
if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2);
});
bus.Subscribe("DialogChoice", e =>
{
if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3);
});
bus.Subscribe("EnemyKilled", e =>
{
if (State == "active" && e.Id == "warden") Complete();
});
}
}
Paso a paso, en palabras
  1. El juego publica eventos en el bus, y el bus llama a todos los suscritos a ese tipo. Arriba está la misión «Los mineros desaparecidos»: aún no ha empezado. La bola está en «inactiva».
  2. Muere una rata (EnemyKilled rat). La misión está suscrita a EnemyKilled, el bus la llama, pero el oyente pregunta: ¿está activa la misión y el enemigo es el Guardián? No: no pasa nada. El combate no sabe que alguien escucha.
  3. El héroe baja al segundo nivel (TileEntered 2). El nivel es el correcto, pero la misión sigue inactiva (state no es "active"), así que otra vez nada. Los eventos llegan cuando lo dice el juego, no cuando la misión está lista.
  4. El anciano da la misión: start() la deja activa, etapa 1. La bola pasa a «etapa 1». No es un evento del bus, sino una acción del diálogo.
  5. El héroe entra en el nivel 1 (TileEntered 1). La misión está activa y en la etapa 1, pero quiere el nivel 2: depth es 1, la condición no se cumple. Un evento nuevo del mismo tipo no es avance.
  6. Segundo nivel (TileEntered 2): activa, etapa 1 y depth 2. El oyente llama a advance(2): la bola pasa a «etapa 2» y el juego escribe «Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.».
  7. El héroe habla con el minero y elige «¿Qué hay allá abajo?»: el diálogo publica DialogChoice miner:warden. La misión está en la etapa 2 (menos de 3) y el id coincide: advance(3). La bola está en «etapa 3».
  8. El héroe vuelve al nivel 2 (TileEntered 2). depth == 2 es cierto, pero la etapa ya es 3, no 1: la comprobación stage == 1 detiene el evento. Sin ella la misión retrocedería a la etapa 2.
  9. El Guardián muere (EnemyKilled warden). La misión está activa y el id es "warden": complete(). La bola está en «cumplida». El combate solo dijo «el enemigo murió»; quién escucha no es asunto suyo.

El código junto a la escena es el bus, la base de la misión y la subclase MinersQuest del programa. En cada paso se iluminan las líneas que trabajan en ese momento.

La escena muestra solo la misión «Los mineros desaparecidos». La experiencia y el diálogo están en los programas de arriba y de abajo, no en la escena.

La idea

Un diálogo es un árbol con condiciones

Una conversación es un árbol: un nodo (node) tiene una frase y varias respuestas (option), y cada respuesta lleva a un nodo siguiente (o a end, que lo cierra). Si eso fuera todo, el diálogo sería igual para todos los jugadores. Pero Vela no debe ofrecer la misión de la plata si ya está aceptada, ni quitar mineral que el héroe no tiene. Por eso una respuesta tiene una condición y acciones:

  • condición (condition): flag:nombre, item:objeto:cantidad, quest:misión:estado, con un ! delante para negar. Varias condiciones van separadas por comas y deben cumplirse todas (un «y» lógico). - significa «siempre».
  • acciones (actions): flag, start, complete, reward, give, take. Se ejecutan en orden cuando se elige la respuesta.

Las condiciones y las acciones son texto corto de la forma tipo:argumento:argumento, porque los diálogos son datos: pueden vivir en un archivo, no en el código. visibleOptions muestra solo las respuestas cuya condición es verdadera, y choose ejecuta las acciones de la elegida.

record Option(string Text, string Next, string Condition, string Actions);
record Node(string Text, List<Option> Options);
class Game
{
static readonly Dictionary<string, string> Names = new()
{
["ore"] = "mineral",
["silver_sword"] = "espada de plata",
["potion_heal"] = "poción de curación",
};
public readonly HashSet<string> Flags = new();
public readonly Dictionary<string, int> Items = new();
public readonly Dictionary<string, Quest> Quests = new();
public Game(params Quest[] list)
{
foreach (var quest in list) Quests[quest.Id] = quest;
}
public Quest GetQuest(string id)
{
return Quests[id];
}
public int Count(string id)
{
return Items.GetValueOrDefault(id, 0);
}
string Label(string id, int n)
{
return Names[id] + " x" + n;
}
public void Give(string id, int n)
{
Items[id] = Count(id) + n;
Console.WriteLine("Recibes: " + Label(id, n) + ".");
}
public void Take(string id, int n)
{
Items[id] = Count(id) - n;
Console.WriteLine("Entregas: " + Label(id, n) + ".");
}
}
string[] SplitList(string text)
{
return text == "-" ? [] : text.Split(',');
}
bool CheckCondition(Game game, string condition)
{
foreach (var part in SplitList(condition))
{
bool negate = part.StartsWith('!');
var p = (negate ? part.Substring(1) : part).Split(':');
bool ok;
if (p[0] == "flag") ok = game.Flags.Contains(p[1]);
else if (p[0] == "item") ok = game.Count(p[1]) >= int.Parse(p[2]);
else if (p[0] == "quest") ok = game.GetQuest(p[1]).State == p[2];
else throw new ArgumentException("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
void RunActions(Game game, string actions)
{
foreach (var part in SplitList(actions))
{
var p = part.Split(':');
if (p[0] == "flag")
{
game.Flags.Add(p[1]);
}
else if (p[0] == "start")
{
game.GetQuest(p[1]).Start();
}
else if (p[0] == "complete")
{
game.GetQuest(p[1]).Complete();
}
else if (p[0] == "reward")
{
var quest = game.GetQuest(p[1]);
quest.State = "rewarded";
Console.WriteLine("Recompensa por " + quest.Title + ".");
}
else if (p[0] == "give")
{
game.Give(p[1], int.Parse(p[2]));
}
else if (p[0] == "take")
{
game.Take(p[1], int.Parse(p[2]));
}
else
{
throw new ArgumentException("unknown action: " + part);
}
}
}
List<Option> VisibleOptions(Game game, Node node)
{
var result = new List<Option>();
foreach (var option in node.Options)
{
if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option);
}
return result;
}
void Show(string speaker, Game game, Node node)
{
Console.WriteLine(speaker + ": " + node.Text);
var options = VisibleOptions(game, node);
for (int i = 0; i < options.Count; i++) Console.WriteLine(" " + (i + 1) + ") " + options[i].Text);
}
string Choose(Game game, Node node, int number)
{
var option = VisibleOptions(game, node)[number - 1];
Console.WriteLine("> " + number);
RunActions(game, option.Actions);
return option.Next;
}

Los datos son los dos diálogos del juego: la herrera Vela (objetos y una misión) y el minero Dobri (un regalo único con una bandera).

var smith = new Dictionary<string, Node>
{
["start"] = new Node("Mi fragua está fría sin plata. Tráeme tres trozos de mineral y te forjaré una espada.", [
new Option("Buscaré el mineral.", "deal", "quest:silver:inactive", "start:silver"),
new Option("Aquí está el mineral.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"),
new Option("Adiós.", "end", "-", "-")]),
["deal"] = new Node("El mineral está en el segundo nivel. Los gólems de mena lo llevan dentro.", [
new Option("Bien.", "end", "-", "-")]),
["done"] = new Node("Una espada de plata. Su filo nunca se embota.", [
new Option("Gracias.", "end", "-", "-")]),
};
var miner = new Dictionary<string, Node>
{
["start"] = new Node("¡Un alma viva! Los otros corrieron hacia arriba... Yo quedé atrapado aquí abajo.", [
new Option("¿Qué hay allá abajo?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"),
new Option("Quédate aquí, volveré.", "end", "-", "-")]),
["warden"] = new Node("El Guardián abisal. Un guardián de piedra despertado por nuestros picos. Cuando se agrieta, llama a los muertos en su ayuda. Toma mi poción.", [
new Option("Lo detendré.", "end", "-", "-")]),
};
var game = new Game(new Quest("silver", "Plata para la herrera", ["Reúne tres trozos de mineral de plata.", "Lleva el mineral a la herrera Vela."], "La herrera tiene su plata."), new Quest("miners", "Los mineros desaparecidos", ["Baja a la mina y busca a los mineros.", "Alguien está vivo en el segundo nivel. Encuéntralo.", "Derrota al Guardián abisal en el tercer nivel."], "El Guardián está derrotado. Vadogris está a salvo."));
var start = smith["start"];
Show("Vela", game, start);
string next = Choose(game, start, 1);
Show("Vela", game, smith[next]);
game.Give("ore", 3);
Show("Vela", game, start);
next = Choose(game, start, 1);
Show("Vela", game, smith[next]);
game.GetQuest("miners").Start();
var minerStart = miner["start"];
Show("Dobri", game, minerStart);
next = Choose(game, minerStart, 1);
Show("Dobri", game, miner[next]);
Show("Dobri", game, minerStart);
Salida
Vela: Mi fragua está fría sin plata. Tráeme tres trozos de mineral y te forjaré una espada.
1) Buscaré el mineral.
2) Adiós.
> 1
Nueva misión: Plata para la herrera.
Vela: El mineral está en el segundo nivel. Los gólems de mena lo llevan dentro.
1) Bien.
Recibes: mineral x3.
Vela: Mi fragua está fría sin plata. Tráeme tres trozos de mineral y te forjaré una espada.
1) Aquí está el mineral.
2) Adiós.
> 1
Entregas: mineral x3.
¡Misión cumplida: Plata para la herrera!
Recompensa por Plata para la herrera.
Recibes: espada de plata x1.
Vela: Una espada de plata. Su filo nunca se embota.
1) Gracias.
Nueva misión: Los mineros desaparecidos.
Dobri: ¡Un alma viva! Los otros corrieron hacia arriba... Yo quedé atrapado aquí abajo.
1) ¿Qué hay allá abajo?
2) Quédate aquí, volveré.
> 1
Recibes: poción de curación x1.
Dobri: El Guardián abisal. Un guardián de piedra despertado por nuestros picos. Cuando se agrieta, llama a los muertos en su ayuda. Toma mi poción.
1) Lo detendré.
Dobri: ¡Un alma viva! Los otros corrieron hacia arriba... Yo quedé atrapado aquí abajo.
1) Quédate aquí, volveré.

Fíjate en cómo cambian las respuestas de Vela para el mismo nodo start: en la primera visita son dos (deal y «Adiós»), porque done pide la misión y el mineral. Cuando el héroe trae tres de mineral, ya no ofrece deal (la misión está activa), sino done. Las acciones de done quitan el mineral, cierran la misión, dan la recompensa y la espada de plata. Con Dobri la respuesta «¿Qué hay allá abajo?» desaparece en cuanto se pone la bandera miner_told: el regalo es único.

La idea

En el juego: experiencia, habilidades y el bus

Aquí están los fragmentos reales del canon. Primero, los niveles y el árbol (xp-levelup). Además de nuestros xpForLevel y pendingLevels hay learnableSkills (solo las habilidades de la clase del héroe, según la lista del héroe), levelUpChoices (cuatro atributos y las habilidades disponibles) y applyLevelChoice:

// La curva de niveles es una tabla: XP_TABLE[n] es la experiencia con la que se llega al nivel n.
class LevelChoice
{
public string Kind = "";
public string Stat = "";
public int Amount;
}
static partial class Arcanum
{
public static int XpForLevel(int level)
{
if (level < XP_TABLE.Count) return XP_TABLE[level];
return XP_TABLE[XP_TABLE.Count - 1] + (level - XP_TABLE.Count + 1) * 150;
}
public static int PendingLevels(Hero hero)
{
int levels = 0;
while (hero.Xp >= XpForLevel(hero.Level + levels + 1)) levels++;
return levels;
}
// El nivel nuevo en sí: más vida y maná, salud completa.
public static void RaiseLevel(Hero hero)
{
HeroClass heroClass = hero.HeroClass();
hero.Level += 1;
hero.Base["maxHp"] = hero.Base["maxHp"] + heroClass.HpPerLevel;
hero.Base["maxMana"] = hero.Base["maxMana"] + heroClass.ManaPerLevel;
hero.Hp = hero.MaxHp();
hero.Mana = hero.MaxMana();
}
// El árbol de habilidades: dos ramas de tres pasos. Un paso se puede aprender si el anterior ya está aprendido.
public static List<string> LearnableSkills(Hero hero)
{
List<string> result = new List<string>();
foreach (Skill skill in SKILLS.Values())
{
if (skill.ClassId != hero.ClassId || hero.Skills.IndexOf(skill.Id) >= 0) continue;
bool ready = skill.Tier == 1;
foreach (string known in hero.Skills)
{
Skill other = SKILLS.Get(known);
if (other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true;
}
if (ready) result.Add(skill.Id);
}
return result;
}
// Las opciones en un nivel nuevo: un atributo o una habilidad nueva.
public static List<LevelChoice> LevelUpChoices(Hero hero)
{
List<LevelChoice> choices = new List<LevelChoice>
{
new LevelChoice { Kind = "stat", Stat = "maxHp", Amount = 8 },
new LevelChoice { Kind = "stat", Stat = "strength", Amount = 1 },
new LevelChoice { Kind = "stat", Stat = "agility", Amount = 1 },
new LevelChoice { Kind = "stat", Stat = "defense", Amount = 1 },
};
foreach (string skillId in LearnableSkills(hero)) choices.Add(new LevelChoice { Kind = "skill", Stat = skillId, Amount = 0 });
return choices;
}
public static void ApplyLevelChoice(Hero hero, LevelChoice choice)
{
if (choice.Kind == "skill")
{
hero.Skills.Add(choice.Stat);
}
else
{
hero.Base[choice.Stat] = hero.Base[choice.Stat] + choice.Amount;
if (choice.Stat == "maxHp") hero.Hp = hero.MaxHp();
}
}
}

Las diferencias con nuestro programa son pocas:

  • raiseLevel en el juego suma también maná y devuelve la salud completa; nosotros solo tocamos la vida máxima.
  • El nivel no se da en gainXp. enemyKilled (además del oro y el cadáver) sube hero.xp y publica un evento, y queueLevelUps pone una LevelUpScreen en la pila (la lección sobre las pantallas): el jugador elige un atributo o una habilidad y solo entonces raiseLevel y applyLevelChoice cambian al héroe. Hay una pantalla por nivel, no un diálogo para todos.
  • Los atributos cambian en base (la lección sobre las entidades), no en el héroe mismo.

Y el bus es el mismo que el nuestro:

// El bus de eventos: el que publica no sabe quién escucha. Las misiones se suscriben y siguen su propio
// progreso, y el sistema de combate solo dice «el enemigo ha muerto».
class GameEvent
{
public string Type = "";
public int Depth;
public int X;
public int Y;
public char Decor = ' ';
public string Marker = "";
public string ItemId = "";
public int Count;
public string KindId = "";
public string DialogId = "";
public string NodeId = "";
}
class EventBus
{
public Dictionary<string, List<Action<GameEvent>>> Handlers = new Dictionary<string, List<Action<GameEvent>>>();
public void Subscribe(string type, Action<GameEvent> handler)
{
if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<GameEvent>>();
Handlers[type].Add(handler);
}
public void Publish(GameEvent gameEvent)
{
if (!Handlers.ContainsKey(gameEvent.Type)) return;
foreach (Action<GameEvent> handler in new List<Action<GameEvent>>(Handlers[gameEvent.Type])) handler(gameEvent);
}
}

Los eventos del juego son objetos libres ({ type: 'EnemyKilled', kindId: … }), con campos distintos para cada tipo: kindId, itemId, dialogId con nodeId, depth, decor, marker. En Java, C# y Python cada forma es un registro aparte. Nosotros lo atamos todo a type, id y depth, para que un solo tipo sirva en los cuatro lenguajes.

La idea

En el juego: misiones y diálogos

Aquí están las misiones reales. La base Quest usa claves de los textos (t('quest.' + id + '.title')) para que el juego se pueda traducir; MissingMinersQuest es la nuestra, y las otras dos muestran qué más puede hacer una máquina de estados:

// Una misión es una pequeña máquina de estados: inactive → active (etapas) → done → rewarded. Se suscribe
// sola a los eventos que la hacen avanzar.
abstract class Quest
{
public string Id;
public string State;
public int Stage;
public Quest(string id)
{
Id = id;
State = "inactive";
Stage = 0;
}
public string Title()
{
return T("quest." + Id + ".title");
}
public string Goal()
{
if (State == "done") return T("quest." + Id + ".done");
if (State == "rewarded") return T("quest.rewarded");
return T("quest." + Id + ".stage" + Stage);
}
public virtual void Start(Game game)
{
State = "active";
Stage = 1;
game.Say(T("quest.started", ("title", Title())));
}
public void Advance(Game game, int stage)
{
Stage = stage;
game.Say(T("quest.updated", ("title", Title()), ("goal", Goal())));
}
public void Complete(Game game)
{
State = "done";
game.Say(T("quest.completed", ("title", Title())));
}
public virtual void Subscribe(EventBus bus, Game game) { }
}
class MissingMinersQuest : Quest
{
public MissingMinersQuest() : base("miners") { }
public override void Subscribe(EventBus bus, Game game)
{
bus.Subscribe("TileEntered", (gameEvent) =>
{
if (State == "active" && Stage == 1 && gameEvent.Depth == 2) Advance(game, 2);
});
bus.Subscribe("DialogChoice", (gameEvent) =>
{
if (State == "active" && Stage < 3 && gameEvent.DialogId == "miner" && gameEvent.NodeId == "warden") Advance(game, 3);
});
bus.Subscribe("EnemyKilled", (gameEvent) =>
{
if (State == "active" && gameEvent.KindId == "warden") Complete(game);
});
}
}
class BloodTrailQuest : Quest
{
public BloodTrailQuest() : base("blood") { }
public override void Start(Game game)
{
base.Start(game);
if (game.HasFlag("leader_dead")) Complete(game);
}
public override void Subscribe(EventBus bus, Game game)
{
bus.Subscribe("TileEntered", (gameEvent) =>
{
if (State != "active" || gameEvent.Depth != 1) return;
if (Stage == 1 && gameEvent.Decor == ',') Advance(game, 2);
if (Stage < 3 && gameEvent.Marker == "lair") Advance(game, 3);
});
bus.Subscribe("EnemyKilled", (gameEvent) =>
{
if (gameEvent.KindId != "wolf_leader") return;
game.SetFlag("leader_dead");
if (State == "active") Complete(game);
});
}
}
class SilverQuest : Quest
{
public SilverQuest() : base("silver") { }
public override void Start(Game game)
{
base.Start(game);
Check(game);
}
public void Check(Game game)
{
if (State == "active" && Stage == 1 && game.Hero.Inventory.Count("ore") >= 3) Advance(game, 2);
}
public override void Subscribe(EventBus bus, Game game)
{
bus.Subscribe("ItemPicked", (gameEvent) =>
{
if (gameEvent.ItemId == "ore") Check(game);
});
}
}
static partial class Arcanum
{
public static List<Quest> CreateQuests()
{
return new List<Quest> { new MissingMinersQuest(), new BloodTrailQuest(), new SilverQuest() };
}
}
  • Una misión que empieza después de su condición. BloodTrailQuest.start y SilverQuest.start comprueban de inmediato si la condición ya es cierta: el lobo jefe puede estar ya muerto, o el mineral ya en la mochila, antes de aceptar la misión. Así la misión no se queda para siempre en la etapa 1. Nuestra MinersQuest no lo hace, porque el Guardián no muere antes de hablar con el anciano.
  • Etapas con más de una fuente. BloodTrailQuest escucha TileEntered de dos maneras: para un rastro de sangre (decor) y para la guarida (marker). No hace falta que los eventos estén coordinados: el oyente pregunta por su estado y responde.
  • Llegar a la victoria. El Guardián no es solo EnemyKilled: el juego pone victory en enemyKilled, y la misión avanza con el mismo evento. La misión «Los mineros desaparecidos» no tiene recompensa: la victoria muestra la pantalla de victoria.

El diálogo del canon es lo que construimos, y algo más: las acciones incluyen también gold y xp (la recompensa del cazador), y las condiciones incluyen gold: (oro suficiente):

// Un diálogo es un árbol con condiciones: las opciones cuyas condiciones no se cumplen no se muestran.
static partial class Arcanum
{
public static bool CheckCondition(Game game, string condition)
{
foreach (string part in SplitList(condition))
{
bool negate = part[0] == '!';
string[] pieces = (negate ? part.Substring(1) : part).Split(':');
bool ok = false;
if (pieces[0] == "flag") ok = game.HasFlag(pieces[1]);
else if (pieces[0] == "item") ok = game.Hero.Inventory.Count(pieces[1]) >= ToInt(pieces[2]);
else if (pieces[0] == "gold") ok = game.Hero.Gold >= ToInt(pieces[1]);
else if (pieces[0] == "quest") ok = game.Quest(pieces[1]).State == pieces[2];
else throw new Exception("unknown condition: " + part);
if (negate) ok = !ok;
if (!ok) return false;
}
return true;
}
public static void RunActions(Game game, string actions)
{
foreach (string part in SplitList(actions))
{
string[] pieces = part.Split(':');
if (pieces[0] == "flag")
{
game.SetFlag(pieces[1]);
}
else if (pieces[0] == "start")
{
game.Quest(pieces[1]).Start(game);
}
else if (pieces[0] == "complete")
{
game.Quest(pieces[1]).Complete(game);
}
else if (pieces[0] == "reward")
{
game.Quest(pieces[1]).State = "rewarded";
game.Say(T("quest.rewardedSay", ("title", game.Quest(pieces[1]).Title())));
}
else if (pieces[0] == "give")
{
game.GiveItem(pieces[1], ToInt(pieces[2]));
}
else if (pieces[0] == "take")
{
game.Hero.Inventory.Remove(pieces[1], ToInt(pieces[2]));
game.Say(T("item.given", ("item", ItemLabel(pieces[1], ToInt(pieces[2])))));
}
else if (pieces[0] == "gold")
{
game.Hero.Gold += ToInt(pieces[1]);
game.Say(T("item.gold", ("amount", pieces[1])));
}
else if (pieces[0] == "xp")
{
game.GainXp(ToInt(pieces[1]));
}
else
{
throw new Exception("unknown action: " + part);
}
}
}
public static List<DialogOption> VisibleOptions(Game game, DialogNode node)
{
List<DialogOption> result = new List<DialogOption>();
foreach (DialogOption option in node.Options)
{
if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option);
}
return result;
}
}

DialogScreen (la pantalla de la pila) llama a visibleOptions, y cuando el jugador elige una respuesta ejecuta runActions y publica DialogChoice con el nodo siguiente. Así el diálogo y la misión no se conocen: la charla con el minero mueve la misión solo porque el diálogo publicó y la misión escuchó.

Pruébalo

Predice la salida

Tres cálculos de pendingLevels con la tabla de la lección. ¿Qué imprime el programa?

// XP_TABLE[n] es la experiencia total con la que se alcanza el nivel n.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level)
{
if (level < xpTable.Length) return xpTable[level];
// Pasado el final de la tabla la curva sigue con 150 por nivel.
return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}
// Cuántos niveles esperan: ¿llega la experiencia a los siguientes umbrales?
int PendingLevels(int xp, int level)
{
int levels = 0;
while (xp >= XpForLevel(level + levels + 1)) levels++;
return levels;
}
Console.WriteLine(PendingLevels(130, 1));
Console.WriteLine(PendingLevels(104, 3));
Console.WriteLine(PendingLevels(55, 1));
Respuesta

Pruébalo

Encuentra el error

Un colega quitó una comprobación en MinersQuest y ahora la misión retrocede. El héroe ya habló con el minero (etapa 3) y vuelve a bajar al segundo nivel:

var bus = new EventBus();
var quest = new MinersQuest();
quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Console.WriteLine("> el anciano da la misión");
quest.Start();
Fire(bus, new GameEvent("TileEntered", Depth: 1));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("DialogChoice", "miner:warden"));
Fire(bus, new GameEvent("TileEntered", Depth: 2));
Fire(bus, new GameEvent("EnemyKilled", "warden"));
Salida
> EnemyKilled rat
> TileEntered 2
> el anciano da la misión
Nueva misión: Los mineros desaparecidos.
> TileEntered 1
> TileEntered 2
Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.
> DialogChoice miner:warden
Los mineros desaparecidos: Derrota al Guardián abisal en el tercer nivel.
> TileEntered 2
Los mineros desaparecidos: Alguien está vivo en el segundo nivel. Encuéntralo.
> EnemyKilled warden
¡Misión cumplida: Los mineros desaparecidos!

Al final de la salida, tras el tercer TileEntered 2 (el héroe ya habló con el minero), la meta vuelve a ser «Alguien está vivo en el segundo nivel. Encuéntralo.», aunque el héroe ya sabe del Guardián. ¿Qué línea lo causa?

Elige la línea con el error.

Más precisamente

Con más precisión: orden de los oyentes, excepciones y determinismo

Síncrono. publish vuelve cuando terminaron todos los oyentes. Todo va en un hilo, así que un oyente que publica un evento nuevo lo dispara dentro del primero (publicación anidada). Si la misión A escucha EnemyKilled y publica QuestDone, y la misión B escucha QuestDone, B se llama antes de que termine el primer publish. Limita la profundidad: un bucle infinito de eventos es el error más difícil de los buses.

Orden de los oyentes. El orden de suscripción es previsible a propósito (una lista, no un conjunto): el juego debe dar el mismo resultado con la misma semilla en los cuatro lenguajes. Pero si los oyentes dependen del orden, el diseño está mal: dos oyentes que se necesitan deberían ser uno.

Una excepción en un oyente. Aquí interrumpe publish y los oyentes siguientes no se llaman. En un juego de un jugador es aceptable (mejor ver el error); en un servidor se envuelve en try.

Darse de baja. Nuestro bus no tiene unsubscribe. Una misión que está done sigue suscrita y pregunta state == "active" cada vez; es barato. Donde los oyentes son muchos o viven poco (elementos de una pantalla), sin darse de baja quedan oyentes «colgados» y memoria que no se devuelve.

La máquina crece. Cinco estados con dos transiciones cada uno son diez líneas de if. Cuando la misión se ramifica (elegir bando, fallar pasado un tiempo, comprobar tres metas en cualquier orden), el estado ya no es un solo valor. Entonces cada meta es su propia máquina pequeña, y la misión está «done» cuando lo están todas. El canon se detiene a propósito antes de eso.

El diálogo como datos. Nuestro formato es una cadena tipo:arg:arg. Una ventaja: el diálogo puede estar en un archivo y cambiarse sin código nuevo. Un coste: un error en el nombre (quets:silver:active) se descubre solo al ejecutar (unknown condition), no al compilar. Por eso checkCondition y runActions lanzan una excepción ante un tipo desconocido en lugar de saltarlo en silencio.

Ejercicio

Tres lobos

Resuelve la tarea en tu editor. Parte del programa de la misión.

La misión «Tres lobos». Escribe WolfQuest (una subclase de Quest) con una meta, «Mata a tres lobos»: la misión cuenta los lobos muertos y pasa a done con el tercero. Escucha solo EnemyKilled; cuenta solo lobos (id es "wolf") y solo mientras está active. Con cada lobo muerto que no sea el último, escribe la meta con el contador, por ejemplo «Mata a tres lobos (1/3)» (sobrescribe goal()), llamando a advance(1). Con el tercero llama a complete(). Los eventos antes de start() y después de done no cuentan.

La base Quest, EventBus y fire son las mismas que en el programa de la misión. Este es el escenario:

var bus = new EventBus();
var quest = new WolfQuest();
quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Console.WriteLine("> start");
quest.Start();
Fire(bus, new GameEvent("EnemyKilled", "rat"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));
Fire(bus, new GameEvent("EnemyKilled", "wolf"));

La salida es:

Salida
> EnemyKilled wolf
> start
Nueva misión: Tres lobos.
> EnemyKilled rat
> EnemyKilled wolf
Tres lobos: Mata a tres lobos (1/3)
> EnemyKilled wolf
Tres lobos: Mata a tres lobos (2/3)
> EnemyKilled wolf
¡Misión cumplida: Tres lobos!
> EnemyKilled wolf

Más tareas

  • Recompensa. Añade a Quest un método reward() que pase de done a rewarded y escriba la recompensa. Luego haz en el diálogo del cazador una respuesta con la condición quest:wolves:done y la acción reward:wolves.
  • Dos misiones a la vez. Suscribe MinersQuest y WolfQuest al mismo bus y escribe un escenario en el que un EnemyKilled mueva a las dos. ¿Por qué basta un solo publish?
  • Darse de baja. Añade unsubscribe(type, handler) al bus. ¿Qué tienes que hacer en publish para que un oyente pueda darse de baja durante la publicación?
  • El árbol de habilidades. Añade una tercera clase con dos ramas en las que el segundo paso pida dos pasos aprendidos (uno de cada rama). ¿Qué cambia en learnable?

Test

Ponte a prueba

1. El héroe está en el nivel 3 con 92 exp. Mata al Guardián (+150) y la exp pasa a 242. El umbral del nivel 4 es 105, el del nivel 5 es 170 y el del nivel 6 es 250. ¿Cuántos niveles esperan y cómo los recibe el jugador?
2. Un bárbaro ha aprendido «Ira» (rage, rama 1, paso 1) y «Grito bélico» (war_cry, rama 2, paso 1). ¿Qué habilidades puede ofrecer el siguiente nivel?
3. ¿Por qué publish en el bus recorre una copia de la lista de oyentes (slice, list(...), new ArrayList<>(...)) y no la lista misma?
4. La misión está en la etapa 3. Llega un TileEntered con depth 2 (el héroe vuelve a bajar al segundo nivel). ¿Qué hace el oyente que sube la etapa a 2 y por qué?
5. Una respuesta del diálogo tiene la condición quest:silver:active,item:ore:3. El héroe aceptó la misión, pero solo tiene 2 de mineral. ¿Qué ve el jugador?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·