Objetos, inventario, botín
En resumen: Los objetos son una tabla en texto leída a objetos; el equipo suma modificadores a los atributos, el inventario tiene casillas y apila pociones, el botín se tira por pesos y la tienda compra y vende.
Problema
Una espada nueva, sin tocar el código
En la mina el héroe encuentra una lanza. El comerciante vende una cota de malla. El goblin suelta una poción. Si cada objeto es una línea de código, new Item("spear", "lanza", ...), cada espada nueva es una versión nueva del programa, y un diseñador que quiere cambiar el precio de la cota de malla de 90 a 80 espera a una compilación. El juego tiene veintidós objetos, y habrá más.
Por eso los datos están separados del código. Los objetos son una tabla en texto: una fila es un objeto, las celdas se separan con | y las filas que empiezan con # son notas.
# id|name|kind|price|modifiersspear|lanza|weapon|20|accuracy:5war_axe|hacha de guerra|weapon|80|accuracy:-5leather|armadura de cuero|armor|30|defense:2chain|cota de malla|armor|90|defense:4,evasion:-5ring_life|anillo de vida|ring|70|maxHp:10ring_agility|anillo de destreza|ring|70|agility:2potion_heal|poción de vida|potion|15|-potion_mana|poción de maná|potion|15|-antidote|antídoto|potion|10|-ore|mineral de plata|misc|8|-La lección trata de cinco cosas alrededor de esta tabla: cómo el texto se convierte en objetos; cómo el equipo (arma, armadura, anillo) se suma a los atributos; cómo el inventario con casillas limitadas apila las pociones iguales; cómo el botín se tira de una tabla con pesos; y cómo la tienda compra y vende por oro. La tabla pequeña de arriba tiene diez objetos; el canon tiene veintidós, pero el código es el mismo.
La idea
Una tabla en texto, leída a objetos
El lector son tres funciones pequeñas. parseRows divide el texto en líneas, salta las vacías y las notas, y divide las celdas por |; si el número de celdas no es el esperado, se detiene al instante y dice qué fila está rota. parseMods lee los modificadores (defense:4,evasion:-5, y - significa «ninguno»). loadItems convierte cada fila en un objeto.
record Mod(string Stat, int Value);
class Item{ public readonly string Id; public readonly string Name; public readonly string Kind; public readonly int Price; public readonly List<Mod> Mods; public readonly bool Stackable;
public Item(string id, string name, string kind, int price, List<Mod> mods) { Id = id; Name = name; Kind = kind; Price = price; Mods = mods; Stackable = kind == "potion" || kind == "misc"; }}
// Una fila de la tabla es un objeto: id|nombre|tipo|precio|modificadores.static List<string[]> ParseRows(string text, int columns){ var rows = new List<string[]>(); foreach (string raw in text.Split('\n')) { string line = raw.Trim(); if (line.Length == 0 || line[0] == '#') continue; string[] cells = line.Split('|'); if (cells.Length != columns) { throw new Exception("se esperaban " + columns + " celdas en la fila: " + line); } rows.Add(cells); } return rows;}
// «-» significa «sin modificadores»; si no, pares «atributo:valor» separados por comas.static List<Mod> ParseMods(string text){ var mods = new List<Mod>(); if (text == "-") return mods; foreach (string part in text.Split(',')) { string[] pieces = part.Split(':'); mods.Add(new Mod(pieces[0], int.Parse(pieces[1]))); } return mods;}
static OrderedDictionary<string, Item> LoadItems(string text){ var items = new OrderedDictionary<string, Item>(); foreach (string[] c in ParseRows(text, 5)) { items[c[0]] = new Item(c[0], c[1], c[2], int.Parse(c[3]), ParseMods(c[4])); } return items;}var items = LoadItems(ITEMS_TEXT);foreach (Item item in items.Values){ Console.WriteLine(item.Name + " (" + item.Kind + ") " + item.Price + " oro: " + ModsText(item.Mods));}Console.WriteLine("total: " + items.Count);try{ LoadItems(BAD_ROW);}catch (Exception e){ Console.WriteLine(e.Message);}lanza (weapon) 20 oro: accuracy +5hacha de guerra (weapon) 80 oro: accuracy -5armadura de cuero (armor) 30 oro: defense +2cota de malla (armor) 90 oro: defense +4, evasion -5anillo de vida (ring) 70 oro: maxHp +10anillo de destreza (ring) 70 oro: agility +2poción de vida (potion) 15 oro: -poción de maná (potion) 15 oro: -antídoto (potion) 10 oro: -mineral de plata (misc) 8 oro: -total: 10se esperaban 5 celdas en la fila: ring_life|anillo de vida|ring|70El programa imprime los objetos en el orden de la tabla y al final le pasa una fila con una celda que falta (el precio): el lector la detiene con el mensaje exacto. Una tabla rota debe parar el juego al arrancar, no en mitad de un combate, cuando el jugador pisa el objeto por primera vez.
OrderedDictionary<string, Item> es un diccionario que conserva el orden de inserción (existe desde .NET 9). Un Dictionary normal en la práctica también lo conserva mientras no se borre nada, pero no lo promete. La tabla es una cadena cruda (""").Aquí está el lector en el canon. Allí cada tabla tiene sus propias columnas (las armas son 10 celdas, las armaduras 7), por eso parseRows recibe su número y el nombre de la tabla para el mensaje:
public static List<string[]> ParseRows(string text, int columns, string tableName){ List<string[]> rows = new List<string[]>(); foreach (string rawLine in text.Split('\n')) { string line = TrimSpaces(rawLine); if (line.Length == 0 || line[0] == '#') continue; string[] cells = line.Split('|'); if (cells.Length != columns) { throw new Exception(tableName + ": expected " + columns + " cells in: " + line); } rows.Add(cells); } return rows;}
public static List<Mod> ParseMods(string text){ List<Mod> mods = new List<Mod>(); foreach (string part in SplitList(text)) { string[] pieces = part.Split(':'); mods.Add(new Mod { Stat = pieces[0], Value = ToInt(pieces[1]) }); } return mods;}Más precisamente, para curiosos
Las tablas se diferencian en las columnas, por eso el canon tiene un lector corto por tipo (loadItems lee las cinco tablas una tras otra), pero hay algo en común: parseRows. Un tipo nuevo de datos (enemigos, habilidades, diálogos) es una tabla nueva y un lector corto nuevo, no una forma nueva de leer.
Y aquí están los datos mismos del juego: los nombres en cuatro idiomas, luego el precio y los modificadores.
// Los datos son tablas en texto: una línea es un registro, las celdas se separan con «|». Las líneas con «#»// delante son notas. El código las lee al arrancar en objetos; para una espada nueva no hace falta código, solo una fila.// Los modificadores son «atributo:valor», separados por comas; «-» significa «nada».static partial class Arcanum{ public static readonly string WEAPONS_DATA = """ # id|nombre|tipo de daño|mín|máx|precio|modificadores fists|puños|blunt|1|2|0|- hand_axe|hacha de mano|slash|4|7|20|- spear|lanza|pierce|3|6|20|accuracy:5 staff|bastón|blunt|2|4|10|maxMana:4 war_axe|hacha de guerra|slash|6|10|80|accuracy:-5 longbow|arco largo|pierce|5|8|80|accuracy:10 mace|maza|blunt|5|8|60|- rune_staff|bastón rúnico|blunt|3|6|75|maxMana:10 silver_sword|espada de plata|slash|7|11|120|accuracy:5 """;
public static readonly string ARMORS_DATA = """ # id|nombre|precio|modificadores robe|túnica|5|maxMana:2 leather|armadura de cuero|30|defense:2 chain|cota de malla|90|defense:4,evasion:-5 scale|armadura de escamas|110|defense:5 """;
public static readonly string RINGS_DATA = """ # id|nombre|precio|modificadores ring_strength|anillo de fuerza|70|strength:2 ring_agility|anillo de destreza|70|agility:2 ring_life|anillo de vida|70|maxHp:10 ring_mana|anillo de maná|70|maxMana:8 """;
public static readonly string POTIONS_DATA = """ # id|nombre|precio|acción|fuerza potion_heal|poción de vida|15|heal|20 potion_mana|poción de maná|15|mana|15 antidote|antídoto|10|cure|0 """;
public static readonly string MISC_DATA = """ # id|nombre|precio ore|mineral de plata|8 gold|oro|0 """;
// Botín: filas con peso para cada tabla. «none» es «nada»; la cantidad va de min a max. public static readonly string LOOT_DATA = """ # tabla|objeto|peso|mín|máx vermin|none|60|0|0 vermin|potion_heal|25|1|1 vermin|antidote|15|1|1 goblin|none|40|0|0 goblin|potion_heal|30|1|1 goblin|potion_mana|10|1|1 goblin|spear|10|1|1 goblin|leather|10|1|1 beast|none|70|0|0 beast|potion_heal|30|1|1 leader|ring_agility|100|1|1 bones|none|50|0|0 bones|mace|20|1|1 bones|potion_heal|30|1|1 cult|potion_mana|40|1|1 cult|potion_heal|40|1|1 cult|rune_staff|20|1|1 golem|ore|100|1|2 warden|none|100|0|0 """;
public static readonly string SHOP_DATA = """ # qué vende la tienda, en este orden potion_heal potion_mana antidote leather chain war_axe longbow mace rune_staff ring_life ring_mana """;
// La experiencia necesaria para cada nivel: XP_TABLE[nivel] es la experiencia total con la que se llega a ese nivel. public static readonly string XP_DATA = "0|0|20|55|105|170|250|345|455|580|720";}La idea
Equipo: la base más los objetos
El héroe tiene tres casillas: weapon, armor, ring. Una casilla guarda el id de un objeto o una cadena vacía. Un atributo no se guarda ya hecho: se calcula cada vez como la base (el número del héroe) más la suma de los modificadores de todo lo que está equipado. Así, quitar un anillo no necesita «devuelve el número antiguo»: simplemente se calcula de nuevo.
class Hero{ public static readonly string[] Slots = ["weapon", "armor", "ring"];
public readonly string Name; public readonly OrderedDictionary<string, Item> Items; public readonly Dictionary<string, int> BaseStats = new(); public readonly Dictionary<string, string> Equipment = new(); public int Hp;
public Hero(string name, OrderedDictionary<string, Item> items) { Name = name; Items = items; foreach (string slot in Slots) Equipment[slot] = ""; }
// La base más el bonus de todo lo que está equipado. public int GearBonus(string stat) { int total = 0; foreach (string slot in Slots) { string itemId = Equipment[slot]; if (itemId.Length == 0) continue; foreach (Mod mod in Items[itemId].Mods) { if (mod.Stat == stat) total += mod.Value; } } return total; }
public int Stat(string stat) { return BaseStats.GetValueOrDefault(stat, 0) + GearBonus(stat); }
// La vida no puede quedar por encima del nuevo máximo. public void ClampHp() { if (Hp > Stat("maxHp")) Hp = Stat("maxHp"); }
public string Equip(string itemId) { Item item = Items[itemId]; string old = Equipment[item.Kind]; Equipment[item.Kind] = itemId; ClampHp(); return old; }
public void Unequip(string slot) { Equipment[slot] = ""; ClampHp(); }}equip pone el objeto en la casilla de su tipo (item.kind) y devuelve el antiguo, para que pueda volver a la bolsa. Hay un momento inesperado: si el anillo daba +10 de vida máxima y te lo quitas, la vida actual puede quedar por encima del nuevo máximo. clampHp la devuelve hacia abajo.
var items = LoadItems(ITEMS_TEXT);var hero = new Hero("Iva", items);hero.BaseStats["maxHp"] = 30;hero.BaseStats["defense"] = 1;hero.BaseStats["accuracy"] = 70;hero.Hp = hero.Stat("maxHp");Console.WriteLine(hero.Name + ": " + Stats(hero));
foreach (string itemId in new[] { "spear", "leather", "ring_life" }){ hero.Equip(itemId); Console.WriteLine("+ " + items[itemId].Name + ": " + Stats(hero));}hero.Hp = hero.Stat("maxHp");Console.WriteLine("curación: " + Stats(hero));
hero.Equip("war_axe");Console.WriteLine("+ " + items["war_axe"].Name + ": " + Stats(hero));hero.Unequip("ring");Console.WriteLine("- " + items["ring_life"].Name + ": " + Stats(hero));Iva: hp 30/30, defense 1, accuracy 70+ lanza: hp 30/30, defense 1, accuracy 75+ armadura de cuero: hp 30/30, defense 3, accuracy 75+ anillo de vida: hp 30/40, defense 3, accuracy 75curación: hp 40/40, defense 3, accuracy 75+ hacha de guerra: hp 40/40, defense 3, accuracy 65- anillo de vida: hp 30/30, defense 3, accuracy 65Las líneas siguen al héroe: la lanza suma 5 a la puntería, la armadura de cuero 2 a la defensa, el anillo 10 a la vida máxima (pero la vida actual sigue en 30/40: el máximo crece, la vida no). Tras la curación, el hacha de guerra ocupa el lugar de la lanza y la puntería baja de 75 a 65 (−5 en lugar de +5). Y al quitar el anillo la vida máxima vuelve a 30, y clampHp baja también la vida actual a 30.
La idea
Inventario: casillas y pilas
La bolsa tiene un número limitado de casillas (10 en el juego). Un objeto ocupa una casilla. Pero diez pociones de vida no son diez casillas: las pociones iguales y el mineral se apilan en una. En la tabla son los tipos potion y misc (stackable). Las armas y las armaduras no se apilan: dos espadas son dos casillas.
class Inventory{ public readonly int Capacity; public readonly OrderedDictionary<string, Item> Items; public readonly List<Slot> Slots = new();
public Inventory(int capacity, OrderedDictionary<string, Item> items) { Capacity = capacity; Items = items; }
public int Count(string itemId) { int total = 0; foreach (Slot slot in Slots) { if (slot.Id == itemId) total += slot.Count; } return total; }
public bool CanAdd(string itemId, int count) { Item item = Items[itemId]; if (item.Stackable && Count(itemId) > 0) return true; int needed = item.Stackable ? 1 : count; return Slots.Count + needed <= Capacity; }
public bool Add(string itemId, int count) { if (!CanAdd(itemId, count)) return false; Item item = Items[itemId]; if (item.Stackable) { foreach (Slot slot in Slots) { if (slot.Id == itemId) { slot.Count += count; return true; } } Slots.Add(new Slot(itemId, count)); return true; } for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1)); return true; }
public void Remove(string itemId, int count) { int left = count; var kept = new List<Slot>(); foreach (Slot slot in Slots) { if (slot.Id == itemId && left > 0) { int taken = Math.Min(left, slot.Count); slot.Count -= taken; left -= taken; } if (slot.Count > 0) kept.Add(slot); } Slots.Clear(); Slots.AddRange(kept); }
public void RemoveAt(int index) { Slot slot = Slots[index]; slot.Count -= 1; if (slot.Count <= 0) Slots.RemoveAt(index); }}Todo se decide en canAdd: si el objeto se apila y ya hay uno, no hace falta casilla; si no, hacen falta tantas casillas nuevas como objetos. add pregunta primero a canAdd, y si no se puede no hace nada (no deja media pila). El resultado es true o false, así quien llama sabe si debe decir «sin espacio».
static string Label(string name, int count){ return count > 1 ? name + " x" + count : name;}
static string FillText(Inventory bag){ return "(" + bag.Slots.Count + "/" + bag.Capacity + ")";}
static void PrintBag(Inventory bag){ Console.WriteLine("La bolsa:"); for (int i = 0; i < bag.Slots.Count; i++) { Slot slot = bag.Slots[i]; Console.WriteLine((i + 1) + ") " + Label(bag.Items[slot.Id].Name, slot.Count)); }}
static void AddAndShow(Inventory bag, string itemId, int count){ bool added = bag.Add(itemId, count); Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "hecho" : "sin espacio") + " " + FillText(bag));}var items = LoadItems(ITEMS_TEXT);var bag = new Inventory(4, items);AddAndShow(bag, "potion_heal", 2);AddAndShow(bag, "spear", 1);AddAndShow(bag, "potion_heal", 1);AddAndShow(bag, "potion_mana", 1);AddAndShow(bag, "leather", 1);AddAndShow(bag, "chain", 1);AddAndShow(bag, "potion_heal", 1);AddAndShow(bag, "ore", 1);PrintBag(bag);
bag.Remove("potion_heal", 3);Console.WriteLine("- " + Label(items["potion_heal"].Name, 3) + ": " + FillText(bag));bag.Remove("spear", 1);Console.WriteLine("- " + items["spear"].Name + ": " + FillText(bag));AddAndShow(bag, "chain", 1);PrintBag(bag);+ poción de vida x2: hecho (1/4)+ lanza: hecho (2/4)+ poción de vida: hecho (2/4)+ poción de maná: hecho (3/4)+ armadura de cuero: hecho (4/4)+ cota de malla: sin espacio (4/4)+ poción de vida: hecho (4/4)+ mineral de plata: sin espacio (4/4)La bolsa:1) poción de vida x42) lanza3) poción de maná4) armadura de cuero- poción de vida x3: (4/4)- lanza: (3/4)+ cota de malla: hecho (4/4)La bolsa:1) poción de vida2) poción de maná3) armadura de cuero4) cota de mallaLa cota de malla no entra en una bolsa llena (4 de 4), pero una poción de vida más sí: su pila ya está empezada. El mineral no tiene dónde. Después remove quita tres pociones de la pila y la lanza, y la cota de malla entra en la casilla liberada. removeAt(i) quita un objeto de una casilla (la tienda lo usa para vender) y libera la casilla cuando la cantidad llega a cero.
Más precisamente, para curiosos
Una pila guarda un id y un count, no el objeto mismo. La definición de un objeto (nombre, precio, modificadores) es una para todo el mundo, en la tabla; la bolsa solo guarda referencias con cantidades. Veinte lanzas no copian el precio veinte veces.
La idea
Botín con pesos
¿Qué suelta el goblin? No siempre lo mismo, y no con la misma probabilidad: a menudo nada, a veces una poción, raramente una lanza. Para eso cada enemigo tiene una tabla de botín: filas con un peso. El peso es relativo: una fila con peso 30 en una tabla con total 100 sale en el 30 % de los casos. La fila none significa «nada», y la cantidad es aleatoria entre min y max.
# table|item|weight|min|maxgoblin|none|40|0|0goblin|potion_heal|30|1|1goblin|potion_mana|10|1|1goblin|spear|10|1|1goblin|leather|10|1|1golem|ore|100|1|2La tirada es corta. Los pesos se suman (100 en total), se tira un número de 0 a 99, después se recorren las filas en orden y al número se le resta el peso de cada una, hasta que el número sea menor que el peso de la fila actual. Esa fila cae. Así el ancho de una fila es exactamente su probabilidad, y no hace falta ni ordenar por pesos ni porcentajes que sumen 100.
record Entry(string ItemId, int Weight, int Min, int Max);
record Drop(string Id, int Count);
static Dictionary<string, List<Entry>> LoadLoot(string text){ var tables = new Dictionary<string, List<Entry>>(); foreach (string[] c in ParseRows(text, 5)) { if (!tables.ContainsKey(c[0])) tables[c[0]] = new List<Entry>(); tables[c[0]].Add(new Entry(c[1], int.Parse(c[2]), int.Parse(c[3]), int.Parse(c[4]))); } return tables;}
static int TotalWeight(List<Entry> entries){ int total = 0; foreach (Entry entry in entries) total += entry.Weight; return total;}
// Se tira un número de 0 al peso total menos 1; los pesos se restan uno a uno hasta que el número cae dentro de una fila.static Drop? RollLoot(Random rng, List<Entry> entries){ int roll = rng.NextInt(TotalWeight(entries)); foreach (Entry entry in entries) { if (roll < entry.Weight) { if (entry.ItemId == "none") return null; return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max)); } roll -= entry.Weight; } return null;}// Se tira un número de 0 al peso total menos 1; los pesos se restan uno a uno hasta que el número cae dentro de una fila.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll entry.Weight;
}
return null;
}Los pesos se restan: así, en cada fila siguiente el número se mide desde el comienzo de esa fila. Sin la resta cada fila se compararía con el número sin desplazar y las primeras filas caerían demasiado a menudo. El operador es -=.
El programa derrota a seis enemigos con la semilla 6 y mete el botín directamente en una bolsa de 3 casillas. El generador es el Random del canon (el mismo mulberry32 que en el combate), por eso la salida es la misma en los cuatro lenguajes:
var items = LoadItems(ITEMS_TEXT);var tables = LoadLoot(LOOT_TEXT);var rng = new Random(6);var bag = new Inventory(3, items);for (int i = 0; i < kills.Length; i++){ Drop? drop = RollLoot(rng, tables[kills[i]]); string head = "enemigo " + (i + 1) + " (" + kills[i] + "): "; if (drop == null) { Console.WriteLine(head + "nada"); continue; } bool added = bag.Add(drop.Id, drop.Count); Console.WriteLine(head + Label(items[drop.Id].Name, drop.Count) + " -> " + (added ? "hecho" : "sin espacio") + " " + FillText(bag));}PrintBag(bag);enemigo 1 (goblin): nadaenemigo 2 (goblin): poción de vida -> hecho (1/3)enemigo 3 (goblin): lanza -> hecho (2/3)enemigo 4 (golem): mineral de plata x2 -> hecho (3/3)enemigo 5 (goblin): poción de maná -> sin espacio (3/3)enemigo 6 (goblin): poción de vida -> hecho (3/3)La bolsa:1) poción de vida x22) lanza3) mineral de plata x2El primer goblin no suelta nada. El segundo suelta una poción, el tercero una lanza, el golem dos trozos de mineral (la bolsa ya está llena, 3 de 3). El quinto goblin suelta una poción de maná, pero no hay sitio para una pila nueva: en el juego se quedaría en el suelo, aquí simplemente no entra. El sexto suelta una poción de vida: ya hay una en la bolsa y se apila, aunque la bolsa esté llena.
Lo aleatorio es solo el número; los pesos deciden con qué frecuencia. Comprobémoslo: la misma tabla, pero con 1000 goblins derrotados, contando cuántas veces cayó cada fila.
var entries = LoadLoot(LOOT_TEXT)["goblin"];var rng = new Random(2026);var counts = new Dictionary<string, int>();for (int i = 0; i < 1000; i++){ Drop? drop = RollLoot(rng, entries); string key = drop == null ? "none" : drop.Id; counts[key] = counts.GetValueOrDefault(key, 0) + 1;}int total = TotalWeight(entries);foreach (Entry entry in entries){ Console.WriteLine(entry.ItemId + ": " + counts.GetValueOrDefault(entry.ItemId, 0) + " (esperado " + entry.Weight * 1000 / total + ")");}none: 396 (esperado 400)potion_heal: 300 (esperado 300)potion_mana: 117 (esperado 100)spear: 91 (esperado 100)leather: 96 (esperado 100)Las cantidades esperadas son los pesos × 10, y los resultados se acercan, pero rara vez son exactamente iguales. Es probabilidad, no un reparto por calendario. Con menos enemigos derrotados la diferencia es mayor (seis enemigos son muy pocos para nada), por eso un buen equilibrio se comprueba con miles de enemigos derrotados: la última lección hace justo eso con el juego entero.
Míralo
La tirada en acción
Aquí están los mismos seis enemigos derrotados turno a turno. La barra es la tabla: cuanto más ancha es una fila, más a menudo sale. El puntero va al número tirado, la resta de los pesos se ve debajo, y el objeto sale de su fila y entra en la bolsa: en una casilla nueva, en la casilla antigua (se apila) o no entra si la bolsa está llena.
// Se tira un número de 0 al peso total menos 1; los pesos se restan uno a uno hasta que el número cae dentro de una fila.static Drop? RollLoot(Random rng, List<Entry> entries){ int roll = rng.NextInt(TotalWeight(entries)); foreach (Entry entry in entries) { if (roll < entry.Weight) { if (entry.ItemId == "none") return null; return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max)); } roll -= entry.Weight; } return null;}
public bool CanAdd(string itemId, int count){ Item item = Items[itemId]; if (item.Stackable && Count(itemId) > 0) return true; int needed = item.Stackable ? 1 : count; return Slots.Count + needed <= Capacity;}
public bool Add(string itemId, int count){ if (!CanAdd(itemId, count)) return false; Item item = Items[itemId]; if (item.Stackable) { foreach (Slot slot in Slots) { if (slot.Id == itemId) { slot.Count += count; return true; } } Slots.Add(new Slot(itemId, count)); return true; } for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1)); return true;}Paso a paso, en palabras
- La tabla del goblin es texto: 5 filas, cada una con un peso, 100 en total. El peso es el ancho de la fila en la barra: peso 30 de 100 es un 30 % de probabilidad. La bolsa tiene 3 casillas.
- Enemigo 1 (goblin): se tira un número de 0 a 99 y sale 1. Los pesos se restan uno a uno: 1 < 40. El número cae en la fila «nada» (peso 40 de 100).
- «nada» es una fila como cualquier otra (peso 40): no hay botín y la bolsa no se toca.
- Enemigo 2 (goblin): se tira un número de 0 a 99 y sale 56. Los pesos se restan uno a uno: 56 ≥ 40 → 16 → 16 < 30. El número cae en la fila «poción de vida» (peso 30 de 100).
- Poción de vida entra en la bolsa en una casilla nueva (1/3).
- Enemigo 3 (goblin): se tira un número de 0 a 99 y sale 89. Los pesos se restan uno a uno: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. El número cae en la fila «lanza» (peso 10 de 100).
- Lanza entra en la bolsa en una casilla nueva (2/3).
- Enemigo 4 (golem): se tira un número de 0 a 99 y sale 1. Los pesos se restan uno a uno: 1 < 100. El número cae en la fila «mineral de plata» (peso 100 de 100).
- Mineral de plata x2 entra en la bolsa en una casilla nueva (3/3).
- Enemigo 5 (goblin): se tira un número de 0 a 99 y sale 76. Los pesos se restan uno a uno: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. El número cae en la fila «poción de maná» (peso 10 de 100).
- La bolsa está llena (3/3) y poción de maná no tiene con qué apilarse: canAdd devuelve false y add no hace nada (en el juego el objeto se queda en el suelo).
- Enemigo 6 (goblin): se tira un número de 0 a 99 y sale 56. Los pesos se restan uno a uno: 56 ≥ 40 → 16 → 16 < 30. El número cae en la fila «poción de vida» (peso 30 de 100).
- Ya hay poción de vida en la bolsa, así que se apila en la misma casilla (poción de vida x2); las casillas ocupadas siguen en 3/3.
- Tras 6 enemigos derrotados la bolsa está llena. Solo el número es aleatorio; los pesos dicen con qué frecuencia sale cada fila, y la bolsa decide qué cabe.
El código junto a la escena es rollLoot, canAdd y add del programa. En cada paso se iluminan las líneas que trabajan en ese momento.
La idea
La tienda: comprar y vender
La tienda es lo más corto de las cinco cosas, porque todo lo que necesita ya está construido. Comprar son tres comprobaciones en orden: ¿alcanza el oro?, ¿hay sitio en la bolsa?, y luego se paga y se añade el objeto. Vender quita un objeto de una casilla y paga la mitad del precio, redondeada hacia abajo.
// Una venta da la mitad del precio, redondeada hacia abajo.static int SellPrice(Item item){ return item.Price / 2;}
static string Buy(Player player, OrderedDictionary<string, Item> items, string itemId){ Item item = items[itemId]; string result = "hecho"; if (player.Gold < item.Price) result = "falta oro"; else if (!player.Bag.CanAdd(itemId, 1)) result = "sin espacio"; else { player.Gold -= item.Price; player.Bag.Add(itemId, 1); } return "compra " + item.Name + " (" + item.Price + "): " + result + ", oro " + player.Gold;}
static string Sell(Player player, OrderedDictionary<string, Item> items, int slotNumber){ Slot slot = player.Bag.Slots[slotNumber - 1]; Item item = items[slot.Id]; int price = SellPrice(item); player.Bag.RemoveAt(slotNumber - 1); player.Gold += price; return "vende " + item.Name + " (" + price + "): hecho, oro " + player.Gold;}var items = LoadItems(ITEMS_TEXT);var player = new Player(100, new Inventory(3, items));Console.WriteLine(Buy(player, items, "potion_heal"));Console.WriteLine(Buy(player, items, "potion_heal"));Console.WriteLine(Buy(player, items, "chain"));Console.WriteLine(Buy(player, items, "leather"));Console.WriteLine(Buy(player, items, "war_axe"));Console.WriteLine(Sell(player, items, 2));Console.WriteLine(Buy(player, items, "potion_mana"));Console.WriteLine(Buy(player, items, "antidote"));Console.WriteLine(Buy(player, items, "leather"));Console.WriteLine(Sell(player, items, 1));compra poción de vida (15): hecho, oro 85compra poción de vida (15): hecho, oro 70compra cota de malla (90): falta oro, oro 70compra armadura de cuero (30): hecho, oro 40compra hacha de guerra (80): falta oro, oro 40vende armadura de cuero (15): hecho, oro 55compra poción de maná (15): hecho, oro 40compra antídoto (10): hecho, oro 30compra armadura de cuero (30): sin espacio, oro 30vende poción de vida (7): hecho, oro 37El orden de las comprobaciones importa. La cota de malla de 90 no se compra con 70 de oro, ni el hacha de guerra de 80 con 40. En la novena línea el héroe tiene oro para una armadura de cuero (30), pero la bolsa está llena: la comprobación del oro pasa, canAdd rechaza y no se cobra oro. Vender la armadura de cuero da 15 (30 / 2), y una poción de vida 7 (15 / 2, redondeado hacia abajo): comprar y vender enseguida lo mismo nunca da ganancia.
Aquí está el precio de venta en el canon. Comprar allí son las mismas comprobaciones, solo que la pantalla lee el número del teclado:
public static int SellPrice(Item item){ return item.Price / 2;}La idea
En el juego
Lo que hemos construido es lo mismo que en el canon, solo que más pequeño. Aquí está el inventario real:
class Inventory{ public int Capacity; public List<ItemStack> Slots;
public Inventory(int capacity) { Capacity = capacity; Slots = new List<ItemStack>(); }
public int Count(string itemId) { int total = 0; foreach (ItemStack slot in Slots) { if (slot.Id == itemId) total += slot.Count; } return total; }
public bool CanAdd(string itemId, int count) { Item item = ITEMS[itemId]; if (item.Stackable && Count(itemId) > 0) return true; int needed = item.Stackable ? 1 : count; return Slots.Count + needed <= Capacity; }
public bool Add(string itemId, int count) { if (!CanAdd(itemId, count)) return false; Item item = ITEMS[itemId]; if (item.Stackable) { foreach (ItemStack slot in Slots) { if (slot.Id == itemId) { slot.Count += count; return true; } } Slots.Add(new ItemStack { Id = itemId, Count = count }); return true; } for (int i = 0; i < count; i++) Slots.Add(new ItemStack { Id = itemId, Count = 1 }); return true; }
public void Remove(string itemId, int count) { int left = count; List<ItemStack> kept = new List<ItemStack>(); foreach (ItemStack slot in Slots) { if (slot.Id == itemId && left > 0) { int taken = Math.Min(left, slot.Count); slot.Count -= taken; left -= taken; } if (slot.Count > 0) kept.Add(slot); } Slots = kept; }
public void RemoveAt(int index) { ItemStack slot = Slots[index]; slot.Count -= 1; if (slot.Count <= 0) Slots.RemoveAt(index); }}El equipo en el canon forma parte de Creature: gearBonus suma los modificadores de las casillas y se coloca entre la base y los efectos (mira la lección sobre héroes y enemigos):
public override int GearBonus(string stat){ int total = 0; foreach (string slot in Equipment.Keys) { string itemId = Equipment.Get(slot); if (itemId == "") continue; foreach (Mod mod in ITEMS[itemId].Mods) { if (mod.Stat == stat) total += mod.Value; } } return total;}
public Item Weapon(){ string id = Equipment.Get("weapon"); return ITEMS[id == "" ? "fists" : id];}Equipar un objeto de la bolsa pone el antiguo en el mismo lugar, para que no se desordene el orden de los objetos:
// Equipa el objeto del inventario; el viejo ocupa su lugar en el inventario.static partial class Arcanum{ public static string EquipFromInventory(Hero hero, int index) { ItemStack slot = hero.Inventory.Slots[index]; Item item = ITEMS[slot.Id]; if (item.Kind != "weapon" && item.Kind != "armor" && item.Kind != "ring") return ""; string old = hero.Equipment.Get(item.Kind); hero.Inventory.RemoveAt(index); hero.Equipment.Set(item.Kind, item.Id); if (old != "") hero.Inventory.Slots.Insert(index, new ItemStack { Id = old, Count = 1 }); if (hero.Hp > hero.MaxHp()) hero.Hp = hero.MaxHp(); if (hero.Mana > hero.MaxMana()) hero.Mana = hero.MaxMana(); return item.Id; }}Y la tirada de botín, que ya conoces:
// Botín con peso: peso 30 sobre un total de 100 significa un 30 % de probabilidad.static partial class Arcanum{ public static ItemStack? RollLoot(Random rng, string tableId) { List<LootEntry> entries = LOOT_TABLES[tableId]; int total = 0; foreach (LootEntry entry in entries) total += entry.Weight; int roll = rng.NextInt(total); foreach (LootEntry entry in entries) { if (roll < entry.Weight) { if (entry.ItemId == "none") return null; return new ItemStack { Id = entry.ItemId, Count = rng.Range(entry.Min, entry.Max) }; } roll -= entry.Weight; } return null; }}Las diferencias con nuestro programa:
- Una tabla por tipo. Las armas, las armaduras, los anillos, las pociones y lo demás tienen cada uno sus columnas; nuestra tabla es una sola para que se lea fácil.
- Nombres en cuatro idiomas. Cada fila del canon lleva el nombre en
bg,en,deyes;nm(item)elige según el idioma del juego. Aquí el nombre es una celda. - Un
ITEMSglobal. El canon guarda los objetos en un diccionario global y los busca porid. Nosotros se los pasamos aInventoryyHero, para que se vean las dependencias y se pruebe fácil. - Una bolsa de 10 casillas. El héroe del canon tiene
Inventory(10); aquí las casillas son 3 y 4, para ver cuándo se llenan. - Oro y pociones. El oro no es un objeto de la bolsa sino un número del héroe (los enemigos sueltan el oro aparte del botín). Las pociones tienen una acción (
heal,mana,cure) ydrinkPotionlas bebe en el combate y en el mapa; aquí solo las apilamos. - El botín cae al suelo. Un enemigo derrotado deja el objeto en el suelo (
dropItem) y el héroe lo recoge cong; con la bolsa llena el objeto se queda en el suelo y el jugador ve «la bolsa está llena». En nuestro programa el botín va directo a la bolsa y una bolsa llena devuelvefalse.
Pruébalo
Predice la salida
Tres filas con pesos 50, 30 y 20 y seis números tirados. ¿Qué imprime el programa?
static string Pick(string[] ids, int[] weights, int roll){ for (int i = 0; i < ids.Length; i++) { if (roll < weights[i]) return ids[i]; roll -= weights[i]; } return ids[ids.Length - 1];}
string[] ids = ["none", "potion_heal", "spear"];int[] weights = [50, 30, 20];int[] rolls = [0, 49, 50, 79, 80, 99];foreach (int roll in rolls){ Console.WriteLine(roll + ": " + Pick(ids, weights, roll));}El número se compara con el peso de la fila y, si no es menor, se resta el peso. 49 es menor que 50: cae la primera fila; 50 no es menor que 50, queda 0 y cae la segunda; 79 pasa por 50 (quedan 29) y 29 es menor que 30: la segunda; 80 pasa por 50 y 30 (queda 0) y cae la tercera. La segunda respuesta compara con <= y se equivoca en los límites; la tercera divide los números en tres partes iguales y olvida los pesos.
Pruébalo
Encuentra el error
Un compañero reescribió la comprobación de canAdd, y ahora una bolsa de 2 casillas admite tres objetos, y una poción no entra en una bolsa llena:
var items = LoadItems(ITEMS_TEXT);var bag = new Inventory(2, items);AddAndShow(bag, "spear", 1);AddAndShow(bag, "potion_heal", 1);AddAndShow(bag, "potion_mana", 1);AddAndShow(bag, "potion_heal", 1);+ lanza: hecho (1/2)+ poción de vida: hecho (2/2)+ poción de maná: hecho (3/2)+ poción de vida: sin espacio (3/2)¿Qué línea está rota?
Elige la línea con el error.
La comprobación está invertida: count(itemId) == 0 deja pasar todo objeto apilable nuevo sin mirar si hay sitio (de ahí «3/2»), mientras que una pila ya empezada cae en la comprobación general de sitio y se rechaza. Lo correcto: > 0, es decir «ya hay un objeto así en la bolsa, no hace falta casilla».
Más precisamente
Con más precisión: probabilidades, proporciones y puntos débiles
Pesos, no porcentajes. Los pesos no tienen que sumar 100. Una tabla 3, 1, 1 es lo mismo que 60, 20, 20. El total es el divisor y se calcula de nuevo en cada tirada, por eso se puede añadir una fila nueva sin tocar el resto. Los porcentajes tendrían que sumar 100 tras cada cambio; los pesos no.
Restar o sumar. Se puede tirar un número y compararlo con sumas acumuladas (40, 70, 80, 90, 100): la fila i cae cuando el número está por debajo de la i-ésima suma. Restar es lo mismo sin un segundo array. En tablas grandes las sumas se guardan hechas y se busca con búsqueda binaria; para las tablas de un juego es innecesario.
«Nada» es una fila. none con peso 40 es una fila como cualquier otra: con un número una tabla da un 0 % o un 90 % de «nada». Y la tabla del líder (leader) en el canon tiene una sola fila con peso 100: el anillo cae siempre.
Todo o nada. Todo cambio de la bolsa pasa por add, que pregunta antes a canAdd. Así no pueden darse «media pila» ni «tres objetos en dos casillas». El ejercicio de abajo protege justo eso.
Redondeo. precio / 2 con división entera corta el resto, por eso una venta nunca paga más de la mitad. En Python y JavaScript la división entera es // y Math.floor; funcionan bien porque los precios no son negativos.
Azar y repetibilidad. La tirada no sabe qué saldrá; solo sabe responder a un número. El generador tiene semilla (mulberry32), por eso con la misma semilla y las mismas derrotas el botín es el mismo en los cuatro lenguajes y en un recorrido grabado. Lo necesitaremos también para guardar la partida y para la simulación.
Ejercicio
Pilas con un límite superior
Resuelve la tarea en tu editor. Parte del programa del inventario.
En el juego, diez pociones en una casilla son demasiadas. Añade un límite superior para la pila: como máximo 5 de un objeto por casilla. Qué cambia:
- una pila que llega a 5 está llena; lo que sobra del mismo objeto va a una casilla nueva;
adddebe ser todo o nada: si no caben todos (en pilas antiguas y en casillas nuevas), no se mete nada;- los objetos que no se apilan ocupan una casilla cada uno, como hasta ahora.
Hacen falta room (cuánto cabe aún en pilas antiguas), un canAdd corregido y un add que primero rellena las pilas antiguas y después crea nuevas. La bolsa tiene 3 casillas y el escenario es este:
static void AddAndShow(Inventory bag, string itemId, int count){ bool added = bag.Add(itemId, count); var parts = new List<string>(); foreach (Slot slot in bag.Slots) parts.Add(Label(bag.Items[slot.Id].Name, slot.Count)); Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "hecho" : "sin espacio") + " " + FillText(bag) + " [" + string.Join(", ", parts) + "]");}
var items = LoadItems(ITEMS_TEXT);var bag = new Inventory(3, items);AddAndShow(bag, "potion_heal", 4);AddAndShow(bag, "potion_heal", 3);AddAndShow(bag, "potion_mana", 2);AddAndShow(bag, "potion_heal", 3);AddAndShow(bag, "potion_heal", 1);AddAndShow(bag, "potion_mana", 4);La salida es:
+ poción de vida x4: hecho (1/3) [poción de vida x4]+ poción de vida x3: hecho (2/3) [poción de vida x5, poción de vida x2]+ poción de maná x2: hecho (3/3) [poción de vida x5, poción de vida x2, poción de maná x2]+ poción de vida x3: hecho (3/3) [poción de vida x5, poción de vida x5, poción de maná x2]+ poción de vida: sin espacio (3/3) [poción de vida x5, poción de vida x5, poción de maná x2]+ poción de maná x4: sin espacio (3/3) [poción de vida x5, poción de vida x5, poción de maná x2]Las líneas muestran también los límites: el segundo paso rellena la pila antigua hasta 5 y abre una casilla nueva para los 2 restantes; el cuarto rellena la pila ya empezada y no necesita casilla nueva; el último (4 pociones de maná más con una pila de 2) no mete nada, aunque 3 de ellas cabrían, porque la cuarta no tiene dónde ir.
Más tareas
- Vende la basura. Añade
sellJunk(player, items): vende todo lo del tipomiscy devuelve cuánto oro se obtuvo. - La mejor arma. Cuando el héroe recoja un arma, que se la ponga solo si la suma de sus modificadores es mayor que la de la que lleva ahora.
- Suerte. Dale a
rollLootun parámetroluck: cuanto mayor sea, más se reduce el peso de la filanone(pero no por debajo de 0), y las demás se quedan igual. ¿Qué pasa con las probabilidades de las demás?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Lanzar excepciones y crear las tuyas · Listas · Dictionary y HashSet · record, switch y patrones
- Ejercicios sobre el tema: Las crónicas de Arcanum