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.
class Item { constructor(id, name, kind, price, mods) { this.id = id; this.name = name; this.kind = kind; this.price = price; this.mods = mods; this.stackable = kind === "potion" || kind === "misc"; }}
// Una fila de la tabla es un objeto: id|nombre|tipo|precio|modificadores.function parseRows(text, columns) { const rows = []; for (const raw of text.split("\n")) { const line = raw.trim(); if (line.length === 0 || line[0] === "#") continue; const cells = line.split("|"); if (cells.length !== columns) { throw new Error("se esperaban " + columns + " celdas en la fila: " + line); } rows.push(cells); } return rows;}
// «-» significa «sin modificadores»; si no, pares «atributo:valor» separados por comas.function parseMods(text) { const mods = []; if (text === "-") return mods; for (const part of text.split(",")) { const pieces = part.split(":"); mods.push({ stat: pieces[0], value: parseInt(pieces[1], 10) }); } return mods;}
function loadItems(text) { const items = new Map(); for (const c of parseRows(text, 5)) { items.set(c[0], new Item(c[0], c[1], c[2], parseInt(c[3], 10), parseMods(c[4]))); } return items;}const items = loadItems(ITEMS_TEXT);for (const item of items.values()) { console.log(item.name + " (" + item.kind + ") " + item.price + " oro: " + modsText(item.mods));}console.log("total: " + items.size);try { loadItems(BAD_ROW);} catch (e) { console.log(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.
Map conserva el orden de inserción. La tabla es una cadena entre comillas invertidas, y trim() quita el salto de línea del principio y del final.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:
function parseRows(text, columns, tableName) { const rows = []; for (const rawLine of text.split('\n')) { const line = trimSpaces(rawLine); if (line.length === 0 || line.charAt(0) === '#') continue; const cells = line.split('|'); if (cells.length !== columns) { throw new Error(tableName + ': expected ' + columns + ' cells in: ' + line); } rows.push(cells); } return rows;}
function parseMods(text) { const mods = []; for (const part of splitList(text)) { const pieces = part.split(':'); mods.push({ 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».const WEAPONS_DATA = `# id|nombre|tipo de daño|mín|máx|precio|modificadoresfists|puños|blunt|1|2|0|-hand_axe|hacha de mano|slash|4|7|20|-spear|lanza|pierce|3|6|20|accuracy:5staff|bastón|blunt|2|4|10|maxMana:4war_axe|hacha de guerra|slash|6|10|80|accuracy:-5longbow|arco largo|pierce|5|8|80|accuracy:10mace|maza|blunt|5|8|60|-rune_staff|bastón rúnico|blunt|3|6|75|maxMana:10silver_sword|espada de plata|slash|7|11|120|accuracy:5`;
const ARMORS_DATA = `# id|nombre|precio|modificadoresrobe|túnica|5|maxMana:2leather|armadura de cuero|30|defense:2chain|cota de malla|90|defense:4,evasion:-5scale|armadura de escamas|110|defense:5`;
const RINGS_DATA = `# id|nombre|precio|modificadoresring_strength|anillo de fuerza|70|strength:2ring_agility|anillo de destreza|70|agility:2ring_life|anillo de vida|70|maxHp:10ring_mana|anillo de maná|70|maxMana:8`;
const POTIONS_DATA = `# id|nombre|precio|acción|fuerzapotion_heal|poción de vida|15|heal|20potion_mana|poción de maná|15|mana|15antidote|antídoto|10|cure|0`;
const MISC_DATA = `# id|nombre|precioore|mineral de plata|8gold|oro|0`;
// Botín: filas con peso para cada tabla. «none» es «nada»; la cantidad va de min a max.const LOOT_DATA = `# tabla|objeto|peso|mín|máxvermin|none|60|0|0vermin|potion_heal|25|1|1vermin|antidote|15|1|1goblin|none|40|0|0goblin|potion_heal|30|1|1goblin|potion_mana|10|1|1goblin|spear|10|1|1goblin|leather|10|1|1beast|none|70|0|0beast|potion_heal|30|1|1leader|ring_agility|100|1|1bones|none|50|0|0bones|mace|20|1|1bones|potion_heal|30|1|1cult|potion_mana|40|1|1cult|potion_heal|40|1|1cult|rune_staff|20|1|1golem|ore|100|1|2warden|none|100|0|0`;
const SHOP_DATA = `# qué vende la tienda, en este ordenpotion_healpotion_manaantidoteleatherchainwar_axelongbowmacerune_staffring_lifering_mana`;
// La experiencia necesaria para cada nivel: XP_TABLE[nivel] es la experiencia total con la que se llega a ese nivel.const 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.
const SLOTS = ["weapon", "armor", "ring"];
class Hero { constructor(name, items) { this.name = name; this.items = items; this.baseStats = new Map(); this.equipment = new Map(); this.hp = 0; for (const slot of SLOTS) this.equipment.set(slot, ""); }
// La base más el bonus de todo lo que está equipado. gearBonus(stat) { let total = 0; for (const slot of SLOTS) { const itemId = this.equipment.get(slot); if (itemId.length === 0) continue; for (const mod of this.items.get(itemId).mods) { if (mod.stat === stat) total += mod.value; } } return total; }
stat(stat) { return (this.baseStats.get(stat) ?? 0) + this.gearBonus(stat); }
// La vida no puede quedar por encima del nuevo máximo. clampHp() { if (this.hp > this.stat("maxHp")) this.hp = this.stat("maxHp"); }
equip(itemId) { const item = this.items.get(itemId); const old = this.equipment.get(item.kind); this.equipment.set(item.kind, itemId); this.clampHp(); return old; }
unequip(slot) { this.equipment.set(slot, ""); this.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.
const items = loadItems(ITEMS_TEXT);const hero = new Hero("Iva", items);hero.baseStats.set("maxHp", 30);hero.baseStats.set("defense", 1);hero.baseStats.set("accuracy", 70);hero.hp = hero.stat("maxHp");console.log(hero.name + ": " + stats(hero));
for (const itemId of ["spear", "leather", "ring_life"]) { hero.equip(itemId); console.log("+ " + items.get(itemId).name + ": " + stats(hero));}hero.hp = hero.stat("maxHp");console.log("curación: " + stats(hero));
hero.equip("war_axe");console.log("+ " + items.get("war_axe").name + ": " + stats(hero));hero.unequip("ring");console.log("- " + items.get("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 { constructor(capacity, items) { this.capacity = capacity; this.items = items; this.slots = []; }
count(itemId) { let total = 0; for (const slot of this.slots) { if (slot.id === itemId) total += slot.count; } return total; }
canAdd(itemId, count) { const item = this.items.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; const needed = item.stackable ? 1 : count; return this.slots.length + needed <= this.capacity; }
add(itemId, count) { if (!this.canAdd(itemId, count)) return false; const item = this.items.get(itemId); if (item.stackable) { for (const slot of this.slots) { if (slot.id === itemId) { slot.count += count; return true; } } this.slots.push(new Slot(itemId, count)); return true; } for (let i = 0; i < count; i++) this.slots.push(new Slot(itemId, 1)); return true; }
remove(itemId, count) { let left = count; const kept = []; for (const slot of this.slots) { if (slot.id === itemId && left > 0) { const taken = Math.min(left, slot.count); slot.count -= taken; left -= taken; } if (slot.count > 0) kept.push(slot); } this.slots = kept; }
removeAt(index) { const slot = this.slots[index]; slot.count -= 1; if (slot.count <= 0) this.slots.splice(index, 1); }}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».
function label(name, count) { return count > 1 ? name + " x" + count : name;}
function fillText(bag) { return "(" + bag.slots.length + "/" + bag.capacity + ")";}
function printBag(bag) { console.log("La bolsa:"); for (let i = 0; i < bag.slots.length; i++) { const slot = bag.slots[i]; console.log((i + 1) + ") " + label(bag.items.get(slot.id).name, slot.count)); }}
function addAndShow(bag, itemId, count) { const added = bag.add(itemId, count); console.log("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "hecho" : "sin espacio") + " " + fillText(bag));}const items = loadItems(ITEMS_TEXT);const 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.log("- " + label(items.get("potion_heal").name, 3) + ": " + fillText(bag));bag.remove("spear", 1);console.log("- " + items.get("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.
function loadLoot(text) { const tables = new Map(); for (const c of parseRows(text, 5)) { if (!tables.has(c[0])) tables.set(c[0], []); tables.get(c[0]).push({ itemId: c[1], weight: parseInt(c[2], 10), min: parseInt(c[3], 10), max: parseInt(c[4], 10) }); } return tables;}
function totalWeight(entries) { let total = 0; for (const entry of 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.function rollLoot(rng, entries) { let roll = rng.nextInt(totalWeight(entries)); for (const entry of entries) { if (roll < entry.weight) { if (entry.itemId === "none") return null; return { id: entry.itemId, count: 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.
function rollLoot(rng, entries) {
let roll = rng.nextInt(totalWeight(entries));
for (const entry of entries) {
if (roll < entry.weight) {
if (entry.itemId === "none") return null;
return { id: entry.itemId, count: 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:
const items = loadItems(ITEMS_TEXT);const tables = loadLoot(LOOT_TEXT);const rng = new Random(6);const bag = new Inventory(3, items);for (let i = 0; i < KILLS.length; i++) { const drop = rollLoot(rng, tables.get(KILLS[i])); const head = "enemigo " + (i + 1) + " (" + KILLS[i] + "): "; if (drop === null) { console.log(head + "nada"); continue; } const added = bag.add(drop.id, drop.count); console.log(head + label(items.get(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.
const entries = loadLoot(LOOT_TEXT).get("goblin");const rng = new Random(2026);const counts = new Map();for (let i = 0; i < 1000; i++) { const drop = rollLoot(rng, entries); const key = drop === null ? "none" : drop.id; counts.set(key, (counts.get(key) ?? 0) + 1);}const total = totalWeight(entries);for (const entry of entries) { console.log(entry.itemId + ": " + (counts.get(entry.itemId) ?? 0) + " (esperado " + Math.floor((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.function rollLoot(rng, entries) { let roll = rng.nextInt(totalWeight(entries)); for (const entry of entries) { if (roll < entry.weight) { if (entry.itemId === "none") return null; return { id: entry.itemId, count: rng.range(entry.min, entry.max) }; } roll -= entry.weight; } return null;}
canAdd(itemId, count) { const item = this.items.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; const needed = item.stackable ? 1 : count; return this.slots.length + needed <= this.capacity;}
add(itemId, count) { if (!this.canAdd(itemId, count)) return false; const item = this.items.get(itemId); if (item.stackable) { for (const slot of this.slots) { if (slot.id === itemId) { slot.count += count; return true; } } this.slots.push(new Slot(itemId, count)); return true; } for (let i = 0; i < count; i++) this.slots.push(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.function sellPrice(item) { return Math.floor(item.price / 2);}
function buy(player, items, itemId) { const item = items.get(itemId); let 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;}
function sell(player, items, slotNumber) { const slot = player.bag.slots[slotNumber - 1]; const item = items.get(slot.id); const price = sellPrice(item); player.bag.removeAt(slotNumber - 1); player.gold += price; return "vende " + item.name + " (" + price + "): hecho, oro " + player.gold;}const items = loadItems(ITEMS_TEXT);const player = new Player(100, new Inventory(3, items));console.log(buy(player, items, "potion_heal"));console.log(buy(player, items, "potion_heal"));console.log(buy(player, items, "chain"));console.log(buy(player, items, "leather"));console.log(buy(player, items, "war_axe"));console.log(sell(player, items, 2));console.log(buy(player, items, "potion_mana"));console.log(buy(player, items, "antidote"));console.log(buy(player, items, "leather"));console.log(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:
function sellPrice(item) { return Math.floor(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:
// El inventario tiene un número limitado de espacios. Las pociones iguales y el mineral se apilan en un espacio.class Inventory { constructor(capacity) { this.capacity = capacity; this.slots = []; }
count(itemId) { let total = 0; for (const slot of this.slots) { if (slot.id === itemId) total += slot.count; } return total; }
canAdd(itemId, count) { const item = ITEMS.get(itemId); if (item.stackable && this.count(itemId) > 0) return true; const needed = item.stackable ? 1 : count; return this.slots.length + needed <= this.capacity; }
add(itemId, count) { if (!this.canAdd(itemId, count)) return false; const item = ITEMS.get(itemId); if (item.stackable) { for (const slot of this.slots) { if (slot.id === itemId) { slot.count += count; return true; } } this.slots.push({ id: itemId, count: count }); return true; } for (let i = 0; i < count; i++) this.slots.push({ id: itemId, count: 1 }); return true; }
remove(itemId, count) { let left = count; const kept = []; for (const slot of this.slots) { if (slot.id === itemId && left > 0) { const taken = Math.min(left, slot.count); slot.count -= taken; left -= taken; } if (slot.count > 0) kept.push(slot); } this.slots = kept; }
removeAt(index) { const slot = this.slots[index]; slot.count -= 1; if (slot.count <= 0) this.slots.splice(index, 1); }}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):
gearBonus(stat) { let total = 0; for (const slot of this.equipment.keys()) { const itemId = this.equipment.get(slot); if (itemId === '') continue; for (const mod of ITEMS.get(itemId).mods) { if (mod.stat === stat) total += mod.value; } } return total;}
weapon() { const id = this.equipment.get('weapon'); return ITEMS.get(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.function equipFromInventory(hero, index) { const slot = hero.inventory.slots[index]; const item = ITEMS.get(slot.id); if (item.kind !== 'weapon' && item.kind !== 'armor' && item.kind !== 'ring') return ''; const old = hero.equipment.get(item.kind); hero.inventory.removeAt(index); hero.equipment.set(item.kind, item.id); if (old !== '') hero.inventory.slots.splice(index, 0, { 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.function rollLoot(rng, tableId) { const entries = LOOT_TABLES.get(tableId); let total = 0; for (const entry of entries) total += entry.weight; let roll = rng.nextInt(total); for (const entry of entries) { if (roll < entry.weight) { if (entry.itemId === 'none') return null; return { 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?
function pick(ids, weights, roll) { for (let i = 0; i < ids.length; i++) { if (roll < weights[i]) return ids[i]; roll -= weights[i]; } return ids[ids.length - 1];}
const ids = ["none", "potion_heal", "spear"];const weights = [50, 30, 20];const rolls = [0, 49, 50, 79, 80, 99];for (const roll of rolls) { console.log(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:
const items = loadItems(ITEMS_TEXT);const 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:
function addAndShow(bag, itemId, count) { const added = bag.add(itemId, count); const parts = []; for (const slot of bag.slots) parts.push(label(bag.items.get(slot.id).name, slot.count)); console.log("+ " + label(bag.items.get(itemId).name, count) + ": " + (added ? "hecho" : "sin espacio") + " " + fillText(bag) + " [" + parts.join(", ") + "]");}
const items = loadItems(ITEMS_TEXT);const 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 · Arrays: push, splice y sort · Map y Set · Datos, switch y desestructuración
- Ejercicios sobre el tema: Las crónicas de Arcanum