
Construcción de mazos
Empiezas con un mazo pequeño y débil y, durante la partida, compras o ganas cartas nuevas para mejorarlo. El reto es elegir qué añadir y qué quitar para que cada carta que robas trabaje a tu favor.
También se llama: deckbuilder, construcción de barajas
Mecánicas que lo definen
- Un mazo inicial pequeño y de cartas básicas, igual para todos o según el personaje.
- El ciclo de las tres pilas: cada turno robas una mano, juegas cartas y las descartas, y cuando la pila de robo se agota, barajas el descarte y vuelve a ser pila de robo.
- Conseguir cartas durante la partida, comprándolas en un mercado común (en los juegos de mesa) o eligiéndolas como recompensa tras cada combate (en muchos videojuegos).
- Un recurso por turno, como monedas, energía o maná, que limita cuántas cartas puedes jugar.
- Quitar cartas débiles («adelgazar» el mazo) para robar más a menudo las buenas.
Bucle de juego
Robar una mano, gastar la energía en las mejores jugadas y descartar; entre combates, elegir qué carta añadir o quitar, sabiendo que eso cambia todas las manos que vendrán.
Demo propia
Controles. Solo ratón o dedo. Toca una carta para jugarla y «Terminar turno» para pasar. La tecla E también termina el turno, y espacio o Enter empiezan.
Abrir en pantalla completaCombates por turnos contra tres enemigos seguidos con un mazo inicial de diez cartas (atacar, defender, robar). Cada turno tienes tres de energía y robas cinco cartas, ves qué va a hacer el enemigo y, tras cada victoria, eliges una de tres cartas nuevas para tu mazo.
Hecha con Kaplay, en unas 196 líneas de JavaScript.
Cómo está hecha
Configuración
Se crea el juego y se fijan las reglas básicas, que son la vida, la energía por turno, el tamaño de la mano, el límite de cartas en la mano y la curación entre combates. Cambiar estos números cambia el género más que cualquier gráfico.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [28, 26, 40], // Played with the mouse or a finger; the keyboard only has shortcuts. buttons: { start: { keyboard: ["space", "enter"] }, endTurn: { keyboard: ["e"] } }, }); const MAX_HP = 50; const ENERGY_PER_TURN = 3; const HAND_SIZE = 5; const HAND_LIMIT = 8; // you cannot hold more than this const HEAL_BETWEEN_FIGHTS = 10;Las cartas son datos
Cada carta es un objeto con su coste y sus efectos (daño, golpes, bloqueo, robo, energía). Una sola función juega todas las cartas, así que añadir una nueva es añadir una línea. El mazo inicial tiene diez cartas débiles y previsibles, porque mejorarlo es justamente el juego.
// A card is just data: a cost in energy and some effects. The same function // plays every card, so adding a new one means adding a line here. const CARDS = { strike: { name: { es: "Golpe", en: "Strike" }, cost: 1, damage: 6 }, defend: { name: { es: "Defensa", en: "Defend" }, cost: 1, block: 5 }, think: { name: { es: "Pensar", en: "Think" }, cost: 1, draw: 2 }, heavy: { name: { es: "Golpe fuerte", en: "Heavy blow" }, cost: 2, damage: 14 }, twin: { name: { es: "Doble golpe", en: "Twin strike" }, cost: 1, damage: 4, hits: 2 }, wall: { name: { es: "Muro", en: "Shield wall" }, cost: 2, block: 13 }, guard: { name: { es: "Golpe y guardia", en: "Strike and guard" }, cost: 1, damage: 5, block: 4 }, insight: { name: { es: "Intuición", en: "Insight" }, cost: 0, draw: 2 }, surge: { name: { es: "Impulso", en: "Surge" }, cost: 0, energy: 1, draw: 1 }, }; // Starting deck: 10 weak, predictable cards. Improving it is the game. const STARTER = [..."sssssddddt"].map((c) => ({ s: "strike", d: "defend", t: "think" })[c]); const REWARD_POOL = ["heavy", "twin", "wall", "guard", "insight", "surge"]; function describe(card) { const parts = []; if (card.damage) parts.push(t({ es: "Daño ", en: "Damage " }) + card.damage + (card.hits ? `×${card.hits}` : "")); if (card.block) parts.push(t({ es: "Bloqueo ", en: "Block " }) + card.block); if (card.energy) parts.push(t({ es: `+${card.energy} energía`, en: `+${card.energy} energy` })); if (card.draw) parts.push(t({ es: "Roba ", en: "Draw " }) + card.draw); return parts.join("\n"); }Enemigos con intención visible
Cada enemigo repite un patrón de intenciones y la siguiente se muestra antes de tu turno. Si sabes que va a atacar con 16, gastar energía en bloqueo pasa a ser una decisión informada y no una apuesta. Esa información convierte el azar del robo en un problema que se puede resolver.
// Each enemy repeats a pattern of intents. The intent is shown BEFORE you // play: knowing it will hit for 16 is what makes choosing block a decision. const ENEMIES = [ { name: { es: "Mancha", en: "Blob" }, hp: 24, pattern: [{ attack: 6 }, { attack: 6 }, { block: 6 }] }, { name: { es: "Caballero", en: "Knight" }, hp: 36, pattern: [{ attack: 10 }, { block: 10 }, { attack: 7, hits: 2 }] }, { name: { es: "Guardián", en: "Guardian" }, hp: 52, pattern: [{ block: 12 }, { attack: 16 }, { attack: 6, hits: 3 }] }, ]; const intentText = (i) => i.attack ? t({ es: "Va a atacar: ", en: "Will attack: " }) + i.attack + (i.hits ? `×${i.hits}` : "") : t({ es: "Va a defenderse: ", en: "Will defend: " }) + i.block;Barajar con Fisher–Yates
Se recorre el arreglo desde el final y cada posición se intercambia con otra elegida al azar entre las que quedan (ella incluida). Es un algoritmo lineal y deja todas las órdenes posibles con la misma probabilidad. El truco habitual sort(() => Math.random() - 0.5) parece barajar pero produce órdenes sesgadas, y en un juego de cartas eso se nota.
// Fisher–Yates: walk the array from the end and swap each position with a // random one at or before it. Every order is equally likely, which naive // tricks such as sort(() => Math.random() - 0.5) do not guarantee. function shuffle(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }Robo, mano y descarte
Las cartas se mueven entre tres pilas. Robas de la pila de robo a la mano y, al jugarlas o al terminar el turno, pasan al descarte. Cuando la pila de robo se vacía, el descarte se baraja y vuelve a ser pila de robo. Por eso una carta nueva tarda en aparecer, y por eso un mazo más pequeño hace que las mejores cartas salgan más a menudo.
// Three piles: draw, hand and discard. When the draw pile runs out, the // discard pile is shuffled and becomes the new draw pile. function draw(n) { for (let i = 0; i < n && hand.length < HAND_LIMIT; i++) { if (drawPile.length === 0) { if (discard.length === 0) break; drawPile = shuffle(discard); discard = []; } hand.push(drawPile.pop()); } }Jugar una carta
Jugar una carta descuenta su coste de energía, la manda al descarte y aplica sus efectos. El bloqueo absorbe el daño antes que la vida, tanto el tuyo como el del enemigo.
const hit = (target, amount) => { const absorbed = Math.min(target.block, amount); // block soaks damage first target.block -= absorbed; target.hp -= amount - absorbed; }; function play(card) { if (phase !== "player" || card.cost > energy) return; energy -= card.cost; hand.splice(hand.indexOf(card), 1); discard.push(card); for (let h = 0; h < (card.hits ?? 1) && card.damage; h++) hit(enemy, card.damage); player.block += card.block ?? 0; energy += card.energy ?? 0; if (card.draw) draw(card.draw); if (enemy.hp <= 0) return win(); renderHand(); }Turnos
Al empezar tu turno se borra tu bloqueo, se recarga la energía y robas cinco cartas. Al terminarlo, lo que no jugaste se descarta y el enemigo cumple su intención. La energía limitada obliga a elegir entre atacar y protegerte, y esa elección en cada mano es la que da forma al género.
// Your turn: block resets, energy refills, you draw 5. When you end it, // the unplayed cards go to discard and the enemy carries out its intent. function startTurn() { phase = "player"; player.block = 0; energy = ENERGY_PER_TURN; draw(HAND_SIZE); renderHand(); } function endTurn() { if (phase !== "player") return; phase = "enemy"; discard.push(...hand); hand = []; renderHand(); wait(0.5, () => { const intent = enemy.pattern[turn % enemy.pattern.length]; enemy.block = 0; if (intent.attack) for (let h = 0; h < (intent.hits ?? 1); h++) hit(player, intent.attack); enemy.block += intent.block ?? 0; shake(intent.attack ? 4 : 0); turn++; if (player.hp <= 0) return go("end", false, fight); wait(0.4, startTurn); }); } function startFight() { fight++; enemy = { ...ENEMIES[fight], name: t(ENEMIES[fight].name), block: 0, maxHp: ENEMIES[fight].hp }; turn = 0; drawPile = shuffle([...deck]); // every fight starts with the whole deck [hand, discard] = [[], []]; startTurn(); }Elegir una carta entre tres
Tras cada victoria se ofrecen tres cartas al azar y puedes tomar una o saltarte la recompensa. Saltarla es válido, porque cada carta débil que añades hace que tus cartas buenas salgan menos. Construir el mazo consiste en decidir qué entra y qué no.
// After each victory: pick 1 of 3 random cards, or skip. Skipping is valid, // because every weak card you add makes your best cards come up less often. function win() { phase = "reward"; hand = []; renderHand(); if (fight === ENEMIES.length - 1) return wait(0.5, () => go("end", true, fight + 1, deck.length)); const offer = shuffle([...REWARD_POOL]).slice(0, 3); const layer = add([rect(width(), height()), color(0, 0, 0), opacity(0.75), z(50), "reward"]); layer.add([text(t({ es: `¡Venciste a ${enemy.name}! Elige una carta para tu mazo`, en: `You beat ${enemy.name}! Pick a card for your deck` }), { size: 20, font: FONT }), pos(320, 60), anchor("center")]); offer.forEach((id, i) => cardView({ id, ...CARDS[id] }, 130 + i * 130, 100, 110, 140, layer, pick)); const skip = layer.add([rect(140, 34, { radius: 6 }), pos(250, 280), color(70, 70, 90), area()]); skip.add([text(t({ es: "Saltar", en: "Skip" }), { size: 16, font: FONT }), pos(70, 17), anchor("center")]); skip.onClick(() => pick(null)); function pick(card) { if (card) deck.push(card); destroyAll("reward"); player.hp = Math.min(MAX_HP, player.hp + HEAL_BETWEEN_FIGHTS); startFight(); } }Interfaz
La mano se reconstruye cada vez que cambia y los textos se actualizan en cada fotograma. Las cartas que no puedes pagar se ven apagadas, y el contador de las pilas te deja calcular qué puede salir en los próximos turnos.
// The hand is rebuilt every time it changes; the texts update every frame. add([rect(50, 70, { radius: 6 }), pos(110, 130), anchor("center"), color(90, 160, 240)]); const enemyBody = add([rect(70, 80, { radius: 6 }), pos(480, 125), anchor("center"), color(210, 80, 90)]); const playerInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(110, 188), anchor("center")]); const enemyInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(480, 188), anchor("center")]); const intentInfo = add([text("", { size: 15, font: FONT }), pos(480, 55), anchor("center"), color(255, 200, 120)]); const energyInfo = add([text("", { size: 16, font: FONT }), pos(110, 40), anchor("center"), color(255, 230, 120)]); const piles = add([text("", { size: 13, font: FONT }), pos(10, 228), color(190, 190, 220)]); const endButton = add([rect(120, 32, { radius: 6 }), pos(510, 212), color(70, 110, 80), area()]); endButton.add([text(t({ es: "Terminar turno", en: "End turn" }), { size: 15, font: FONT }), pos(60, 16), anchor("center")]); endButton.onClick(endTurn); onButtonPress("endTurn", endTurn); onUpdate(() => { playerInfo.text = `${t({ es: "Tú", en: "You" })}\n${t({ es: "Vida", en: "HP" })} ${player.hp}/${MAX_HP}` + (player.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${player.block}` : ""); enemyInfo.text = `${enemy.name}\n${t({ es: "Vida", en: "HP" })} ${Math.max(0, enemy.hp)}/${enemy.maxHp}` + (enemy.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${enemy.block}` : ""); intentInfo.text = phase === "player" ? intentText(enemy.pattern[turn % enemy.pattern.length]) : ""; energyInfo.text = `${t({ es: "Energía", en: "Energy" })}: ${energy}/${ENERGY_PER_TURN}`; piles.text = `${t({ es: "Robo", en: "Draw" })}: ${drawPile.length} ${t({ es: "Descarte", en: "Discard" })}: ${discard.length} ${t({ es: "Mazo", en: "Deck" })}: ${deck.length} ${t({ es: "Combate", en: "Fight" })} ${fight + 1}/${ENEMIES.length}`; enemyBody.opacity = enemy.hp > 0 ? 1 : 0.3; endButton.opacity = phase === "player" ? 1 : 0.4; }); function cardView(card, x, y, w, h, parent, onPick) { const usable = phase === "reward" || card.cost <= energy; const c = parent.add([rect(w, h, { radius: 6 }), pos(x, y), color(card.damage ? rgb(120, 60, 60) : rgb(60, 80, 120)), outline(2, usable ? rgb(240, 230, 200) : rgb(80, 80, 90)), opacity(usable ? 1 : 0.55), area(), "card"]); c.add([circle(10), pos(12, 12), anchor("center"), color(255, 220, 90)]); c.add([text(String(card.cost), { size: 13, font: FONT }), pos(12, 12), anchor("center"), color(30, 30, 30)]); c.add([text(t(card.name), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, 34), anchor("center")]); c.add([text(describe(card), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, h * 0.68), anchor("center"), color(230, 230, 240)]); c.onClick(() => onPick(card)); } function renderHand() { destroyAll("card"); const w = Math.min(82, Math.floor(460 / Math.max(1, hand.length)) - 4); const x0 = 320 - (hand.length * (w + 4)) / 2; hand.forEach((card, i) => cardView(card, x0 + i * (w + 4), 250, w, 104, getTreeRoot(), play)); }Escenas
Hay tres escenas, title, game y end. go() pasa de una a otra, y volver a game reparte un mazo inicial nuevo.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Construcción de mazos", en: "Deckbuilder" }), t({ es: "Toca una carta para jugarla: cuesta energía (el número amarillo). Mira qué va a hacer el enemigo y decide si atacar o protegerte.\nVence a 3 enemigos; tras cada victoria eliges una carta nueva.\nHaz clic o pulsa espacio para empezar.", en: "Tap a card to play it: it costs energy (the yellow number). Check what the enemy is about to do and decide whether to attack or protect yourself.\nBeat 3 enemies; after each victory you pick a new card.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won, fights, deckSize) => screen( won ? t({ es: "¡Venciste a los tres!", en: "You beat all three!" }) : t({ es: "Has caído", en: "You fell" }), (won ? t({ es: `Mazo final: ${deckSize} cartas.`, en: `Final deck: ${deckSize} cards.` }) : t({ es: `Caíste en el combate ${fights + 1}.`, en: `You fell in fight ${fights + 1}.` })) + t({ es: " Haz clic para repetir.", en: " Click to play again." }), () => go("game"), ), ); go("title");
Qué simplificamos
- Las cartas son rectángulos con texto, sin ilustraciones ni animaciones.
- No hay mapa, tienda ni forma de quitar cartas del mazo; solo se puede añadir o saltarse la recompensa.
- El enemigo sigue un patrón fijo en vez de elegir su intención al azar, y no hay efectos que duren varios turnos (veneno, fuerza, debilidad).
- Las recompensas por combate, la energía y los enemigos sucesivos vienen de los deckbuilders roguelike como Slay the Spire. En el modelo de mesa, como Dominion, las cartas se compran en un mercado común.
Para modificarla
- Añade una opción en la recompensa para quitar una carta del mazo en vez de añadir una, y compara qué mazo funciona mejor.
- Crea un efecto que dure varios turnos, como veneno que hace daño al enemigo al comienzo de cada turno suyo.
- Cambia el patrón fijo de un enemigo por intenciones al azar con pesos y piensa cómo cambia la lectura del combate.
Código completo
// Deckbuilder demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [28, 26, 40],
// Played with the mouse or a finger; the keyboard only has shortcuts.
buttons: { start: { keyboard: ["space", "enter"] }, endTurn: { keyboard: ["e"] } },
});
const MAX_HP = 50;
const ENERGY_PER_TURN = 3;
const HAND_SIZE = 5;
const HAND_LIMIT = 8; // you cannot hold more than this
const HEAL_BETWEEN_FIGHTS = 10;
// #endregion
// #region cards
// A card is just data: a cost in energy and some effects. The same function
// plays every card, so adding a new one means adding a line here.
const CARDS = {
strike: { name: { es: "Golpe", en: "Strike" }, cost: 1, damage: 6 },
defend: { name: { es: "Defensa", en: "Defend" }, cost: 1, block: 5 },
think: { name: { es: "Pensar", en: "Think" }, cost: 1, draw: 2 },
heavy: { name: { es: "Golpe fuerte", en: "Heavy blow" }, cost: 2, damage: 14 },
twin: { name: { es: "Doble golpe", en: "Twin strike" }, cost: 1, damage: 4, hits: 2 },
wall: { name: { es: "Muro", en: "Shield wall" }, cost: 2, block: 13 },
guard: { name: { es: "Golpe y guardia", en: "Strike and guard" }, cost: 1, damage: 5, block: 4 },
insight: { name: { es: "Intuición", en: "Insight" }, cost: 0, draw: 2 },
surge: { name: { es: "Impulso", en: "Surge" }, cost: 0, energy: 1, draw: 1 },
};
// Starting deck: 10 weak, predictable cards. Improving it is the game.
const STARTER = [..."sssssddddt"].map((c) => ({ s: "strike", d: "defend", t: "think" })[c]);
const REWARD_POOL = ["heavy", "twin", "wall", "guard", "insight", "surge"];
function describe(card) {
const parts = [];
if (card.damage) parts.push(t({ es: "Daño ", en: "Damage " }) + card.damage + (card.hits ? `×${card.hits}` : ""));
if (card.block) parts.push(t({ es: "Bloqueo ", en: "Block " }) + card.block);
if (card.energy) parts.push(t({ es: `+${card.energy} energía`, en: `+${card.energy} energy` }));
if (card.draw) parts.push(t({ es: "Roba ", en: "Draw " }) + card.draw);
return parts.join("\n");
}
// #endregion
// #region enemies
// Each enemy repeats a pattern of intents. The intent is shown BEFORE you
// play: knowing it will hit for 16 is what makes choosing block a decision.
const ENEMIES = [
{ name: { es: "Mancha", en: "Blob" }, hp: 24, pattern: [{ attack: 6 }, { attack: 6 }, { block: 6 }] },
{ name: { es: "Caballero", en: "Knight" }, hp: 36, pattern: [{ attack: 10 }, { block: 10 }, { attack: 7, hits: 2 }] },
{ name: { es: "Guardián", en: "Guardian" }, hp: 52, pattern: [{ block: 12 }, { attack: 16 }, { attack: 6, hits: 3 }] },
];
const intentText = (i) =>
i.attack
? t({ es: "Va a atacar: ", en: "Will attack: " }) + i.attack + (i.hits ? `×${i.hits}` : "")
: t({ es: "Va a defenderse: ", en: "Will defend: " }) + i.block;
// #endregion
// #region shuffle
// Fisher–Yates: walk the array from the end and swap each position with a
// random one at or before it. Every order is equally likely, which naive
// tricks such as sort(() => Math.random() - 0.5) do not guarantee.
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// #endregion
scene("game", () => {
const deck = STARTER.map((id) => ({ id, ...CARDS[id] })); // every card you own
const player = { hp: MAX_HP, block: 0 };
let enemy, fight = -1, turn = 0, energy = 0, phase = "player";
let drawPile = [], hand = [], discard = [];
// #region piles
// Three piles: draw, hand and discard. When the draw pile runs out, the
// discard pile is shuffled and becomes the new draw pile.
function draw(n) {
for (let i = 0; i < n && hand.length < HAND_LIMIT; i++) {
if (drawPile.length === 0) {
if (discard.length === 0) break;
drawPile = shuffle(discard);
discard = [];
}
hand.push(drawPile.pop());
}
}
// #endregion
// #region combat
const hit = (target, amount) => {
const absorbed = Math.min(target.block, amount); // block soaks damage first
target.block -= absorbed;
target.hp -= amount - absorbed;
};
function play(card) {
if (phase !== "player" || card.cost > energy) return;
energy -= card.cost;
hand.splice(hand.indexOf(card), 1);
discard.push(card);
for (let h = 0; h < (card.hits ?? 1) && card.damage; h++) hit(enemy, card.damage);
player.block += card.block ?? 0;
energy += card.energy ?? 0;
if (card.draw) draw(card.draw);
if (enemy.hp <= 0) return win();
renderHand();
}
// #endregion
// #region turns
// Your turn: block resets, energy refills, you draw 5. When you end it,
// the unplayed cards go to discard and the enemy carries out its intent.
function startTurn() {
phase = "player";
player.block = 0;
energy = ENERGY_PER_TURN;
draw(HAND_SIZE);
renderHand();
}
function endTurn() {
if (phase !== "player") return;
phase = "enemy";
discard.push(...hand);
hand = [];
renderHand();
wait(0.5, () => {
const intent = enemy.pattern[turn % enemy.pattern.length];
enemy.block = 0;
if (intent.attack) for (let h = 0; h < (intent.hits ?? 1); h++) hit(player, intent.attack);
enemy.block += intent.block ?? 0;
shake(intent.attack ? 4 : 0);
turn++;
if (player.hp <= 0) return go("end", false, fight);
wait(0.4, startTurn);
});
}
function startFight() {
fight++;
enemy = { ...ENEMIES[fight], name: t(ENEMIES[fight].name), block: 0, maxHp: ENEMIES[fight].hp };
turn = 0;
drawPile = shuffle([...deck]); // every fight starts with the whole deck
[hand, discard] = [[], []];
startTurn();
}
// #endregion
// #region rewards
// After each victory: pick 1 of 3 random cards, or skip. Skipping is valid,
// because every weak card you add makes your best cards come up less often.
function win() {
phase = "reward";
hand = [];
renderHand();
if (fight === ENEMIES.length - 1) return wait(0.5, () => go("end", true, fight + 1, deck.length));
const offer = shuffle([...REWARD_POOL]).slice(0, 3);
const layer = add([rect(width(), height()), color(0, 0, 0), opacity(0.75), z(50), "reward"]);
layer.add([text(t({ es: `¡Venciste a ${enemy.name}! Elige una carta para tu mazo`, en: `You beat ${enemy.name}! Pick a card for your deck` }), { size: 20, font: FONT }), pos(320, 60), anchor("center")]);
offer.forEach((id, i) => cardView({ id, ...CARDS[id] }, 130 + i * 130, 100, 110, 140, layer, pick));
const skip = layer.add([rect(140, 34, { radius: 6 }), pos(250, 280), color(70, 70, 90), area()]);
skip.add([text(t({ es: "Saltar", en: "Skip" }), { size: 16, font: FONT }), pos(70, 17), anchor("center")]);
skip.onClick(() => pick(null));
function pick(card) {
if (card) deck.push(card);
destroyAll("reward");
player.hp = Math.min(MAX_HP, player.hp + HEAL_BETWEEN_FIGHTS);
startFight();
}
}
// #endregion
// #region interface
// The hand is rebuilt every time it changes; the texts update every frame.
add([rect(50, 70, { radius: 6 }), pos(110, 130), anchor("center"), color(90, 160, 240)]);
const enemyBody = add([rect(70, 80, { radius: 6 }), pos(480, 125), anchor("center"), color(210, 80, 90)]);
const playerInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(110, 188), anchor("center")]);
const enemyInfo = add([text("", { size: 14, font: FONT, align: "center" }), pos(480, 188), anchor("center")]);
const intentInfo = add([text("", { size: 15, font: FONT }), pos(480, 55), anchor("center"), color(255, 200, 120)]);
const energyInfo = add([text("", { size: 16, font: FONT }), pos(110, 40), anchor("center"), color(255, 230, 120)]);
const piles = add([text("", { size: 13, font: FONT }), pos(10, 228), color(190, 190, 220)]);
const endButton = add([rect(120, 32, { radius: 6 }), pos(510, 212), color(70, 110, 80), area()]);
endButton.add([text(t({ es: "Terminar turno", en: "End turn" }), { size: 15, font: FONT }), pos(60, 16), anchor("center")]);
endButton.onClick(endTurn);
onButtonPress("endTurn", endTurn);
onUpdate(() => {
playerInfo.text = `${t({ es: "Tú", en: "You" })}\n${t({ es: "Vida", en: "HP" })} ${player.hp}/${MAX_HP}` + (player.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${player.block}` : "");
enemyInfo.text = `${enemy.name}\n${t({ es: "Vida", en: "HP" })} ${Math.max(0, enemy.hp)}/${enemy.maxHp}` + (enemy.block ? ` · ${t({ es: "Bloqueo", en: "Block" })} ${enemy.block}` : "");
intentInfo.text = phase === "player" ? intentText(enemy.pattern[turn % enemy.pattern.length]) : "";
energyInfo.text = `${t({ es: "Energía", en: "Energy" })}: ${energy}/${ENERGY_PER_TURN}`;
piles.text = `${t({ es: "Robo", en: "Draw" })}: ${drawPile.length} ${t({ es: "Descarte", en: "Discard" })}: ${discard.length} ${t({ es: "Mazo", en: "Deck" })}: ${deck.length} ${t({ es: "Combate", en: "Fight" })} ${fight + 1}/${ENEMIES.length}`;
enemyBody.opacity = enemy.hp > 0 ? 1 : 0.3;
endButton.opacity = phase === "player" ? 1 : 0.4;
});
function cardView(card, x, y, w, h, parent, onPick) {
const usable = phase === "reward" || card.cost <= energy;
const c = parent.add([rect(w, h, { radius: 6 }), pos(x, y), color(card.damage ? rgb(120, 60, 60) : rgb(60, 80, 120)), outline(2, usable ? rgb(240, 230, 200) : rgb(80, 80, 90)), opacity(usable ? 1 : 0.55), area(), "card"]);
c.add([circle(10), pos(12, 12), anchor("center"), color(255, 220, 90)]);
c.add([text(String(card.cost), { size: 13, font: FONT }), pos(12, 12), anchor("center"), color(30, 30, 30)]);
c.add([text(t(card.name), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, 34), anchor("center")]);
c.add([text(describe(card), { size: 12, font: FONT, width: w - 8, align: "center" }), pos(w / 2, h * 0.68), anchor("center"), color(230, 230, 240)]);
c.onClick(() => onPick(card));
}
function renderHand() {
destroyAll("card");
const w = Math.min(82, Math.floor(460 / Math.max(1, hand.length)) - 4);
const x0 = 320 - (hand.length * (w + 4)) / 2;
hand.forEach((card, i) => cardView(card, x0 + i * (w + 4), 250, w, 104, getTreeRoot(), play));
}
// #endregion
startFight();
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Construcción de mazos", en: "Deckbuilder" }),
t({
es: "Toca una carta para jugarla: cuesta energía (el número amarillo). Mira qué va a hacer el enemigo y decide si atacar o protegerte.\nVence a 3 enemigos; tras cada victoria eliges una carta nueva.\nHaz clic o pulsa espacio para empezar.",
en: "Tap a card to play it: it costs energy (the yellow number). Check what the enemy is about to do and decide whether to attack or protect yourself.\nBeat 3 enemies; after each victory you pick a new card.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won, fights, deckSize) =>
screen(
won ? t({ es: "¡Venciste a los tres!", en: "You beat all three!" }) : t({ es: "Has caído", en: "You fell" }),
(won ? t({ es: `Mazo final: ${deckSize} cartas.`, en: `Final deck: ${deckSize} cards.` }) : t({ es: `Caíste en el combate ${fights + 1}.`, en: `You fell in fight ${fights + 1}.` })) +
t({ es: " Haz clic para repetir.", en: " Click to play again." }),
() => go("game"),
),
);
go("title");
// #endregionRetos de diseño
- Diseñar cartas que combinen entre sí sin que una combinación rompa el juego.
- Que las recompensas sean decisiones de verdad. Si siempre conviene tomar carta, no hay decisión, y cada carta añadida también diluye el mazo.
- Mostrar la información que hace manejable el azar, como la intención del enemigo y el tamaño de las pilas, para que perder no se sienta injusto.
- Barajar sin sesgos (Fisher–Yates) y decidir cuánto azar hay mediante el tamaño de mano, los robos extra y las cartas que manipulan el mazo.
- Que muchas cartas distintas se lean de un vistazo, con textos breves, iconos y palabras clave consistentes.
Historia
- 2008OrigenDominion
Dominion (2008), de Donald X. Vaccarino, es un juego de cartas de mesa que fijó el modelo del género y luego tuvo versiones digitales. Wikipedia menciona StarCraft: The Board Game (2007) como precedente.
Dominion (card game) (Wikipedia) - 2013Dominion
Dominion llega al navegador con una versión oficial de Goko, en la que el juego base es gratuito. Desde 2017 la versión en línea la mantiene Shuffle iT.
Dominion (card game) (Wikipedia) - 2014
Dream Quest, un juego para móviles de Peter Whalen, es el primer ejemplo conocido de construcción de mazos con estructura roguelike. Tras él surgieron otros, y su autor fue contratado por Blizzard para trabajar en Hearthstone.
Roguelike deck-building game (Wikipedia) - 2015Hand of Fate
Hand of Fate arma cada mazmorra con cartas de tu mazo y resuelve los combates como acción en tercera persona. Muestra que el mazo puede generar la aventura y no solo el combate.
Hand of Fate (Wikipedia) - 2017Slay the Spire
Slay the Spire sale en acceso anticipado (versión final en 2019). Su energía por turno, los enemigos que anuncian su próximo movimiento y la elección de una carta entre tres tras cada combate popularizan los deckbuilders roguelike.
Slay the Spire (Wikipedia) - 2020Monster Train
Monster Train añade una dimensión espacial, porque las cartas invocan criaturas en los distintos pisos de un tren que hay que defender.
Monster Train (Wikipedia) - 2021Inscryption
Inscryption mezcla la construcción de mazos con el terror, los acertijos y una historia, con cartas que exigen sacrificar otras para jugarse.
Inscryption (Wikipedia) - 2024Balatro
Balatro, obra de un solo desarrollador, aplica la construcción de mazos a una baraja de póquer y a comodines que cambian las reglas de puntuación.
Balatro (Wikipedia)
Juegos de referencia
2008
Dominion
Juego de cartas de mesa que inventó la construcción de mazos. Todos empiezan con diez cartas iguales (siete monedas y tres de puntos) y cada turno compran cartas de un mercado común para mejorar su mazo. Ganó el Spiel des Jahres en 2009 y tiene una versión oficial en línea.
2015
Hand of Fate
Un misterioso repartidor arma cada mazmorra con cartas boca abajo, tomadas de tu mazo y del suyo. Cuando aparece un monstruo, el juego pasa a combate de acción en tercera persona. Tras cada mazmorra ganas cartas nuevas para personalizar tu mazo.
2019
Slay the Spire
Subes una torre de pisos generados al azar combatiendo con cartas. Cada turno tienes tres de energía y una mano nueva, y los enemigos anuncian su próximo movimiento. Tras cada victoria eliges una de tres cartas para tu mazo. Salió en acceso anticipado en 2017.
2020
Cardice
Construcción de mazos con estructura roguelike hecha para la competencia js13kGames 2020, en menos de 13 KB. Eliges un héroe, avanzas por un mapa hacia el jefe y juegas cartas arrastrando dados sobre ellas, que ocupan el lugar de la energía. Tras cada combate eliges recompensas para tu mazo.
2020
Monster Train
Defiendes un tren de cuatro pisos jugando cartas que invocan criaturas o activan efectos. Los invasores suben piso a piso hacia la pira de la cima, así que además de construir el mazo decides en qué piso colocar cada criatura.
2021
Inscryption
Juegas a las cartas en una cabaña oscura contra un rival misterioso. Entre partidas añades o quitas cartas del mazo, muchas exigen sacrificar otras para entrar en juego, y puedes levantarte de la mesa para resolver acertijos de la cabaña.
2024
Balatro
Juegas manos de póquer con una baraja de 52 cartas para superar puntuaciones cada vez más altas, con manos y descartes limitados. Entre rondas compras comodines que cambian cómo se puntúa y cartas que modifican tu baraja. Lo hizo una sola persona.
Ejercicio
Juega la demo dos veces, una tomando siempre una carta y otra saltándote todas las recompensas. ¿Qué mazo llegó más lejos y por qué? Después diseña en el objeto CARDS tres cartas nuevas, una de ellas pensada para combinar con otra, y pruébalas.