
Estrategia en tiempo real
Juego de estrategia en el que reúnes recursos, levantas una base, produces unidades y diriges ataques mientras tus rivales hacen lo mismo, sin turnos ni pausas. El reto es decidir rápido y atender a la vez la economía y el combate.
También se llama: ETR, RTS
Mecánicas que lo definen
- Recolectar recursos con unidades obreras que, una vez mandadas, trabajan solas.
- Gastar esos recursos en edificios y unidades, que cuestan dinero y tiempo de producción.
- Seleccionar unidades con el ratón (clic o recuadro) y darles órdenes que dependen de dónde haces clic (mover, atacar, recolectar).
- Combate entre unidades con papeles distintos, que se contrarrestan unas a otras, a menudo con niebla de guerra y un árbol tecnológico.
Bucle de juego
Recolectar, gastar en unidades, defender y explorar, y atacar cuando la ventaja es tuya, todo a la vez y mientras el rival hace lo mismo.
Demo propia
Controles. Clic en una unidad o arrastra un recuadro para seleccionar. Con unidades seleccionadas, clic (o clic derecho) en el suelo para mover, en un cristal para recolectar o en un enemigo para atacar. Botones de arriba, o teclas 1 y 2, para entrenar obreros y soldados; Escape deselecciona. En móvil, toca y arrastra con el dedo.
Abrir en pantalla completaUna partida corta contra una base enemiga. Tus obreros recolectan cristales, con ellos entrenas soldados y diriges el ataque con el ratón, mientras el rival te manda grupos cada vez mayores. Todo pasa a la vez y sin turnos, que es lo que define al género.
Hecha con Kaplay, en unas 196 líneas de JavaScript.
Cómo está hecha
Configuración y números
Las constantes son el equilibrio del juego: lo que cuesta cada unidad, cuánto tarda en entrenarse, cuánto carga un obrero, la vida de las bases y cada cuánto ataca el rival. Cambiar uno solo de estos números cambia la estrategia que conviene.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [44, 66, 48], // Everything is played with the mouse or a finger; keys are only shortcuts. buttons: { start: { keyboard: ["space", "enter"] }, worker: { keyboard: ["1"] }, soldier: { keyboard: ["2"] }, deselect: { keyboard: ["escape"] }, }, }); const HUD = 34; // height of the top bar const COST = { worker: 40, soldier: 60 }; const TRAIN_TIME = 2; // seconds to train one unit const CARRY = 10; // crystals a worker brings back per trip const MINE_TIME = 1; // seconds to fill the bag const SIGHT = 90; // idle fighters attack enemies closer than this const BASE_HP = { player: 200, enemy: 150 }; const WAVE_EVERY = 20, MAX_WAVE = 3; // enemy attacks: how often, and the largest group const STATS = { worker: { hp: 6, speed: 85, radius: 6 }, soldier: { hp: 14, speed: 65, radius: 8, damage: 2, range: 26, rate: 0.6 }, raider: { hp: 10, speed: 55, radius: 8, damage: 2, range: 26, rate: 0.7 }, };El mapa
Dos bases enfrentadas y cuatro campos de cristal más cerca de la tuya. Cada campo se agota, así que la economía no es infinita y llega un momento en que conviene atacar.
// Two bases face each other; crystal fields lie closer to yours. const HOME = vec2(60, 200); const ENEMY_HOME = vec2(585, 200); const addBase = (p, team) => add([rect(44, 44), pos(p), anchor("center"), color(team === "player" ? rgb(70, 130, 230) : rgb(200, 70, 70)), outline(3, rgb(20, 20, 20)), "building", team, { team, hp: BASE_HP[team], maxHp: BASE_HP[team], size: 22 }]); const [playerBase] = [addBase(HOME, "player"), addBase(ENEMY_HOME, "enemy")]; for (const p of [vec2(165, 95), vec2(195, 125), vec2(165, 300), vec2(200, 275)]) add([polygon([vec2(0, -11), vec2(9, 0), vec2(0, 11), vec2(-9, 0)]), pos(p), color(110, 230, 240), outline(2, rgb(20, 60, 70)), "crystal", { amount: 150 }]);Las unidades
Una unidad es una forma con unos pocos datos (equipo, vida y su orden actual). El círculo es un obrero y el cuadrado, un soldado. El anillo verde marca la selección y el punto celeste, la carga que lleva un obrero.
// A unit is a shape plus a few numbers: team, health and its current order. function addUnit(kind, team, p, order = null) { const s = STATS[kind]; const look = kind === "worker" ? circle(s.radius) : rect(s.radius * 2, s.radius * 2); const tint = team === "player" ? (kind === "worker" ? rgb(170, 210, 255) : rgb(60, 110, 230)) : rgb(220, 70, 60); const u = add([look, pos(p), anchor("center"), color(tint), outline(2, rgb(20, 20, 20)), "unit", team, kind, { kind, team, hp: s.hp, maxHp: s.hp, size: s.radius, order, cooldown: 0, cargo: 0, selected: false, shot: 0, shotAt: null }]); u.onDraw(() => { if (u.selected) drawCircle({ radius: u.size + 5, fill: false, outline: { width: 2, color: rgb(140, 255, 150) } }); if (u.cargo) drawCircle({ pos: vec2(0, -11), radius: 3, color: rgb(110, 230, 240) }); if (u.shot > 0) drawLine({ p1: vec2(0, 0), p2: u.shotAt.sub(u.pos), width: 2, color: rgb(255, 240, 150) }); }); return u; } for (const [i, p] of [vec2(100, 170), vec2(100, 200), vec2(100, 230)].entries()) addUnit("worker", "player", p, { type: "gather", node: get("crystal")[i % 2 ? 2 : 0] }); addUnit("soldier", "player", vec2(120, 200)); for (const p of [vec2(530, 170), vec2(530, 230), vec2(545, 200)]) addUnit("raider", "enemy", p); // Health bars, drawn above every unit. add([z(45)]).onDraw(() => { for (const o of [...get("unit"), ...get("building")].filter((o) => o.hp < o.maxHp)) { drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: o.size * 2, height: 3, color: rgb(40, 20, 20) }); drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: (o.size * 2 * o.hp) / o.maxHp, height: 3, color: rgb(120, 230, 120) }); } });Selección con el ratón
Es el gesto que inventó el género para mandar a muchas unidades: un clic elige una y un recuadro, varias. Si el recuadro atrapa soldados, se quedan solo ellos, para no sacar sin querer a los obreros del trabajo. Se distingue clic de arrastre por la distancia entre el punto donde se pulsó y donde se soltó.
// Click a unit to select it, or drag a box around several of yours. // A box with soldiers in it keeps only the soldiers: workers stay at work. const mine = () => get("player").filter((o) => o.is("unit")); const selection = () => mine().filter((u) => u.selected); const clearSelection = () => mine().forEach((u) => (u.selected = false)); let dragStart = null; onMousePress("left", () => (dragStart = mousePos().y > HUD ? mousePos() : null)); onMouseRelease("left", () => { if (!dragStart) return; const [a, b] = [dragStart, mousePos()]; dragStart = null; const picked = a.dist(b) > 8 ? mine().filter((u) => u.pos.x >= Math.min(a.x, b.x) && u.pos.x <= Math.max(a.x, b.x) && u.pos.y >= Math.min(a.y, b.y) && u.pos.y <= Math.max(a.y, b.y)) : mine().filter((u) => u.pos.dist(b) <= u.size + 5).slice(0, 1); if (!picked.length && a.dist(b) <= 8) return selection().length && giveOrder(b); // click on the ground = order const army = picked.filter((u) => u.kind !== "worker"); clearSelection(); (army.length ? army : picked).forEach((u) => (u.selected = true)); }); onMousePress("right", () => giveOrder(mousePos())); onButtonPress("deselect", clearSelection); add([z(50)]).onDraw(() => { const m = mousePos(); if (!dragStart || dragStart.dist(m) <= 8) return; drawRect({ pos: vec2(Math.min(dragStart.x, m.x), Math.min(dragStart.y, m.y)), width: Math.abs(m.x - dragStart.x), height: Math.abs(m.y - dragStart.y), fill: false, outline: { width: 1, color: rgb(140, 255, 150) } }); });Órdenes contextuales
Un mismo clic significa cosas distintas según lo que haya debajo y quién lo reciba. Sobre un cristal, los obreros recolectan; sobre un enemigo, los soldados atacan; en el suelo, todos se mueven, repartidos en una pequeña rejilla para no amontonarse en un punto.
// The same click means different things depending on what is under the // cursor and who receives it: gather, attack or simply move. function giveOrder(p) { const foe = get("enemy").find((o) => o.pos.dist(p) <= o.size + 6); const node = get("crystal").find((c) => c.pos.dist(p) <= 16); selection().forEach((u, i) => { if (u.kind === "worker" && node) u.order = { type: "gather", node }; else if (u.kind === "soldier" && foe) u.order = { type: "attack", target: foe }; // Spread the group so they do not all pile onto one point. else u.order = { type: "move", to: p.add(((i % 3) - 1) * 18, (Math.floor(i / 3) - 1) * 18) }; }); add([circle(6), pos(p), anchor("center"), color(node ? rgb(110, 230, 240) : foe ? rgb(255, 90, 80) : rgb(140, 255, 150)), opacity(0.6), lifespan(0.3, { fade: 0.3 })]); } // Step towards a point; returns true once there. function moveToward(u, p, stopAt = 2) { const d = p.sub(u.pos); if (d.len() > stopAt) u.pos = u.pos.add(d.unit().scale(Math.min(d.len(), STATS[u.kind].speed * dt()))); return d.len() <= stopAt; } const nearest = (list, p, max = Infinity) => list.reduce((best, o) => (o.pos.dist(p) < max && (!best || o.pos.dist(p) < best.pos.dist(p)) ? o : best), null);Recolectar
Cada obrero repite un ciclo sin que lo vigiles: ir al cristal, llenar la bolsa, volver a la base y descargar. Este automatismo es clave en el género, porque libera tu atención para el combate. Si el campo se agota, el obrero busca el más cercano.
// Workers loop on their own: walk to the crystal, mine, carry the load home. function updateWorker(u) { const o = u.order; if (!o) return; if (o.type === "move") return moveToward(u, o.to) && (u.order = null); if (u.cargo > 0) { if (moveToward(u, HOME, 28)) [crystals, u.cargo] = [crystals + u.cargo, 0]; // deliver the load return; } if (!o.node.exists()) o.node = nearest(get("crystal"), u.pos); // field used up: find another one if (!o.node) return (u.order = null); if (!moveToward(u, o.node.pos, 14)) return; o.timer = (o.timer ?? 0) + dt(); if (o.timer < MINE_TIME) return; o.timer = 0; u.cargo = Math.min(CARRY, o.node.amount); o.node.amount -= u.cargo; if (o.node.amount <= 0) destroy(o.node); }Producir unidades
Gastar es la otra mitad de la economía. Cada unidad cuesta cristales y tiempo, y espera en una cola. ¿Más obreros, que rinden después, o soldados ya? Esa es la decisión de fondo de toda partida de estrategia en tiempo real.
// Spending is the other half of the economy: each unit costs crystals and time. const notice = add([text("", { size: 14, font: FONT }), pos(320, 346), anchor("center"), z(60)]); function train(kind) { if (crystals < COST[kind]) { notice.text = t({ es: "No tienes cristales suficientes", en: "Not enough crystals" }); return wait(1.2, () => (notice.text = "")); } crystals -= COST[kind]; queue.push(kind); } onUpdate(() => { if (!queue.length || (trainTimer += dt()) < TRAIN_TIME) return; trainTimer = 0; const rally = HOME.add(55, ((spawned++ % 5) - 2) * 20); // where new units wait addUnit(queue.shift(), "player", HOME.add(26, 0), { type: "move", to: rally }); }); add([rect(width(), HUD), color(16, 22, 18), z(40)]); const hud = add([text("", { size: 14, font: FONT }), pos(10, 10), z(41)]); onUpdate(() => { const busy = queue.length ? ` ${t({ es: "Entrenando", en: "Training" })}: ${queue.length}` : ""; hud.text = `${t({ es: "Cristales", en: "Crystals" })}: ${crystals} ${t({ es: "Unidades", en: "Units" })}: ${mine().length}${busy}`; }); [["worker", t({ es: "1 Obrero", en: "1 Worker" })], ["soldier", t({ es: "2 Soldado", en: "2 Soldier" })]].forEach(([kind, label], i) => { const b = add([rect(128, 26, { radius: 4 }), pos(370 + i * 136, 4), color(40, 56, 46), outline(2, rgb(110, 230, 240)), area(), z(41)]); b.add([text(`${label} (${COST[kind]})`, { size: 13, font: FONT }), pos(64, 13), anchor("center")]); b.onClick(() => train(kind)); onButtonPress(kind, () => train(kind)); });Combate
Soldados y asaltantes comparten una regla, que es perseguir al objetivo hasta tenerlo a tiro y golpear cada cierto tiempo. Las unidades sin orden atacan solas a lo que se acerque, y las unidades vecinas se empujan un poco para no fundirse en un punto. Destruir una base termina la partida.
// Soldiers and raiders share one rule: chase the target until in range, // then strike every `rate` seconds. Idle fighters defend themselves. const foesOf = (team) => get(team === "player" ? "enemy" : "player"); function updateFighter(u) { const s = STATS[u.kind]; u.cooldown -= dt(), u.shot -= dt(); let target = u.order?.type === "attack" && u.order.target.exists() ? u.order.target : null; if (u.order?.type === "attack" && !target) u.order = null; if (!target && u.order?.type !== "move") target = nearest(foesOf(u.team), u.pos, SIGHT); if (!target && u.order?.type === "assault") target = playerBase; if (!target) return u.order?.type === "move" && moveToward(u, u.order.to) && (u.order = null); if (u.pos.dist(target.pos) > s.range + target.size) return moveToward(u, target.pos, 0); if (u.cooldown > 0) return; u.cooldown = s.rate; [u.shot, u.shotAt] = [0.08, target.pos.clone()]; // a short flash shows who hits whom target.hp -= s.damage; if (target.hp > 0) return; destroy(target); if (target.is("building")) go("end", target.team === "enemy"); } onUpdate("unit", (u) => { // Neighbours push each other apart, so a group does not collapse into one point. for (const o of get("unit")) if (o !== u && u.pos.dist(o.pos) < u.size + o.size) u.pos = u.pos.add(u.pos.sub(o.pos).unit().scale(40 * dt())); u.kind === "worker" ? updateWorker(u) : updateFighter(u); });El rival
La IA más simple posible. Cada cierto tiempo la base enemiga envía un grupo de asaltantes contra tu base, y cada grupo es un poco mayor que el anterior. Basta para crear presión, pues si solo recolectas, pierdes.
// A very simple opponent: every few seconds its base sends a group of // raiders at your base, and each group is a little bigger than the last. let waveSize = 1; loop(WAVE_EVERY, () => { for (let i = 0; i < waveSize; i++) addUnit("raider", "enemy", ENEMY_HOME.add(-30, (i - 1) * 18), { type: "assault" }); waveSize = Math.min(waveSize + 1, MAX_WAVE); }, undefined, true);Escenas
Tres escenas (title, game y end). Al reiniciar con go(), todos los objetos y temporizadores de la partida anterior desaparecen.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 85)), anchor("center")]); add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 25)), anchor("center"), color(200, 230, 210)]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Estrategia en tiempo real", en: "Real-time strategy" }), t({ es: "Haz clic en una unidad o arrastra un recuadro para seleccionar. Luego haz clic en el suelo para mover, en un cristal para recolectar (obreros) o en un enemigo para atacar (soldados).\nCompra unidades arriba con los cristales. Destruye la base roja antes de que caiga la tuya.\nHaz clic o pulsa espacio para empezar.", en: "Click a unit or drag a box to select. Then click the ground to move, a crystal to gather (workers) or an enemy to attack (soldiers).\nBuy units at the top with crystals. Destroy the red base before yours falls.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won) => screen( won ? t({ es: "¡Base enemiga destruida!", en: "Enemy base destroyed!" }) : t({ es: "Tu base cayó", en: "Your base fell" }), t({ es: "Haz clic o pulsa espacio para jugar otra vez.", en: "Click or press space to play again." }), () => go("game"), )); go("title");
Qué simplificamos
- Un solo recurso y dos tipos de unidad; no hay edificios que construir ni árbol tecnológico.
- No hay niebla de guerra, todo el mapa está a la vista desde el principio.
- Las unidades van en línea recta hacia su destino, sin buscar camino, porque el mapa no tiene obstáculos.
- La IA enemiga no gestiona una economía, solo envía oleadas a tu base a intervalos fijos.
Para modificarla
- Agrega niebla de guerra, de modo que solo veas a los enemigos que están cerca de tus unidades.
- Crea un tercer tipo de unidad, por ejemplo un arquero de largo alcance y poca vida, y observa cómo cambia la mezcla de tropas que conviene.
- Dale una economía al rival, con obreros propios que recolecten, para que sus oleadas dependan de lo que reúna.
Código completo
// Real-time strategy 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: [44, 66, 48],
// Everything is played with the mouse or a finger; keys are only shortcuts.
buttons: {
start: { keyboard: ["space", "enter"] },
worker: { keyboard: ["1"] },
soldier: { keyboard: ["2"] },
deselect: { keyboard: ["escape"] },
},
});
const HUD = 34; // height of the top bar
const COST = { worker: 40, soldier: 60 };
const TRAIN_TIME = 2; // seconds to train one unit
const CARRY = 10; // crystals a worker brings back per trip
const MINE_TIME = 1; // seconds to fill the bag
const SIGHT = 90; // idle fighters attack enemies closer than this
const BASE_HP = { player: 200, enemy: 150 };
const WAVE_EVERY = 20, MAX_WAVE = 3; // enemy attacks: how often, and the largest group
const STATS = {
worker: { hp: 6, speed: 85, radius: 6 },
soldier: { hp: 14, speed: 65, radius: 8, damage: 2, range: 26, rate: 0.6 },
raider: { hp: 10, speed: 55, radius: 8, damage: 2, range: 26, rate: 0.7 },
};
// #endregion
scene("game", () => {
let crystals = 100, trainTimer = 0, spawned = 0;
const queue = []; // units waiting to be trained
// #region map
// Two bases face each other; crystal fields lie closer to yours.
const HOME = vec2(60, 200);
const ENEMY_HOME = vec2(585, 200);
const addBase = (p, team) =>
add([rect(44, 44), pos(p), anchor("center"), color(team === "player" ? rgb(70, 130, 230) : rgb(200, 70, 70)),
outline(3, rgb(20, 20, 20)), "building", team, { team, hp: BASE_HP[team], maxHp: BASE_HP[team], size: 22 }]);
const [playerBase] = [addBase(HOME, "player"), addBase(ENEMY_HOME, "enemy")];
for (const p of [vec2(165, 95), vec2(195, 125), vec2(165, 300), vec2(200, 275)])
add([polygon([vec2(0, -11), vec2(9, 0), vec2(0, 11), vec2(-9, 0)]), pos(p), color(110, 230, 240), outline(2, rgb(20, 60, 70)), "crystal", { amount: 150 }]);
// #endregion
// #region units
// A unit is a shape plus a few numbers: team, health and its current order.
function addUnit(kind, team, p, order = null) {
const s = STATS[kind];
const look = kind === "worker" ? circle(s.radius) : rect(s.radius * 2, s.radius * 2);
const tint = team === "player" ? (kind === "worker" ? rgb(170, 210, 255) : rgb(60, 110, 230)) : rgb(220, 70, 60);
const u = add([look, pos(p), anchor("center"), color(tint), outline(2, rgb(20, 20, 20)), "unit", team, kind,
{ kind, team, hp: s.hp, maxHp: s.hp, size: s.radius, order, cooldown: 0, cargo: 0, selected: false, shot: 0, shotAt: null }]);
u.onDraw(() => {
if (u.selected) drawCircle({ radius: u.size + 5, fill: false, outline: { width: 2, color: rgb(140, 255, 150) } });
if (u.cargo) drawCircle({ pos: vec2(0, -11), radius: 3, color: rgb(110, 230, 240) });
if (u.shot > 0) drawLine({ p1: vec2(0, 0), p2: u.shotAt.sub(u.pos), width: 2, color: rgb(255, 240, 150) });
});
return u;
}
for (const [i, p] of [vec2(100, 170), vec2(100, 200), vec2(100, 230)].entries()) addUnit("worker", "player", p, { type: "gather", node: get("crystal")[i % 2 ? 2 : 0] });
addUnit("soldier", "player", vec2(120, 200));
for (const p of [vec2(530, 170), vec2(530, 230), vec2(545, 200)]) addUnit("raider", "enemy", p);
// Health bars, drawn above every unit.
add([z(45)]).onDraw(() => {
for (const o of [...get("unit"), ...get("building")].filter((o) => o.hp < o.maxHp)) {
drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: o.size * 2, height: 3, color: rgb(40, 20, 20) });
drawRect({ pos: o.pos.add(-o.size, -o.size - 8), width: (o.size * 2 * o.hp) / o.maxHp, height: 3, color: rgb(120, 230, 120) });
}
});
// #endregion
// #region selection
// Click a unit to select it, or drag a box around several of yours.
// A box with soldiers in it keeps only the soldiers: workers stay at work.
const mine = () => get("player").filter((o) => o.is("unit"));
const selection = () => mine().filter((u) => u.selected);
const clearSelection = () => mine().forEach((u) => (u.selected = false));
let dragStart = null;
onMousePress("left", () => (dragStart = mousePos().y > HUD ? mousePos() : null));
onMouseRelease("left", () => {
if (!dragStart) return;
const [a, b] = [dragStart, mousePos()];
dragStart = null;
const picked = a.dist(b) > 8 ? mine().filter((u) => u.pos.x >= Math.min(a.x, b.x) && u.pos.x <= Math.max(a.x, b.x)
&& u.pos.y >= Math.min(a.y, b.y) && u.pos.y <= Math.max(a.y, b.y)) : mine().filter((u) => u.pos.dist(b) <= u.size + 5).slice(0, 1);
if (!picked.length && a.dist(b) <= 8) return selection().length && giveOrder(b); // click on the ground = order
const army = picked.filter((u) => u.kind !== "worker");
clearSelection();
(army.length ? army : picked).forEach((u) => (u.selected = true));
});
onMousePress("right", () => giveOrder(mousePos()));
onButtonPress("deselect", clearSelection);
add([z(50)]).onDraw(() => {
const m = mousePos();
if (!dragStart || dragStart.dist(m) <= 8) return;
drawRect({ pos: vec2(Math.min(dragStart.x, m.x), Math.min(dragStart.y, m.y)), width: Math.abs(m.x - dragStart.x),
height: Math.abs(m.y - dragStart.y), fill: false, outline: { width: 1, color: rgb(140, 255, 150) } });
});
// #endregion
// #region orders
// The same click means different things depending on what is under the
// cursor and who receives it: gather, attack or simply move.
function giveOrder(p) {
const foe = get("enemy").find((o) => o.pos.dist(p) <= o.size + 6);
const node = get("crystal").find((c) => c.pos.dist(p) <= 16);
selection().forEach((u, i) => {
if (u.kind === "worker" && node) u.order = { type: "gather", node };
else if (u.kind === "soldier" && foe) u.order = { type: "attack", target: foe };
// Spread the group so they do not all pile onto one point.
else u.order = { type: "move", to: p.add(((i % 3) - 1) * 18, (Math.floor(i / 3) - 1) * 18) };
});
add([circle(6), pos(p), anchor("center"), color(node ? rgb(110, 230, 240) : foe ? rgb(255, 90, 80) : rgb(140, 255, 150)), opacity(0.6), lifespan(0.3, { fade: 0.3 })]);
}
// Step towards a point; returns true once there.
function moveToward(u, p, stopAt = 2) {
const d = p.sub(u.pos);
if (d.len() > stopAt) u.pos = u.pos.add(d.unit().scale(Math.min(d.len(), STATS[u.kind].speed * dt())));
return d.len() <= stopAt;
}
const nearest = (list, p, max = Infinity) =>
list.reduce((best, o) => (o.pos.dist(p) < max && (!best || o.pos.dist(p) < best.pos.dist(p)) ? o : best), null);
// #endregion
// #region economy
// Workers loop on their own: walk to the crystal, mine, carry the load home.
function updateWorker(u) {
const o = u.order;
if (!o) return;
if (o.type === "move") return moveToward(u, o.to) && (u.order = null);
if (u.cargo > 0) {
if (moveToward(u, HOME, 28)) [crystals, u.cargo] = [crystals + u.cargo, 0]; // deliver the load
return;
}
if (!o.node.exists()) o.node = nearest(get("crystal"), u.pos); // field used up: find another one
if (!o.node) return (u.order = null);
if (!moveToward(u, o.node.pos, 14)) return;
o.timer = (o.timer ?? 0) + dt();
if (o.timer < MINE_TIME) return;
o.timer = 0;
u.cargo = Math.min(CARRY, o.node.amount);
o.node.amount -= u.cargo;
if (o.node.amount <= 0) destroy(o.node);
}
// #endregion
// #region production
// Spending is the other half of the economy: each unit costs crystals and time.
const notice = add([text("", { size: 14, font: FONT }), pos(320, 346), anchor("center"), z(60)]);
function train(kind) {
if (crystals < COST[kind]) {
notice.text = t({ es: "No tienes cristales suficientes", en: "Not enough crystals" });
return wait(1.2, () => (notice.text = ""));
}
crystals -= COST[kind];
queue.push(kind);
}
onUpdate(() => {
if (!queue.length || (trainTimer += dt()) < TRAIN_TIME) return;
trainTimer = 0;
const rally = HOME.add(55, ((spawned++ % 5) - 2) * 20); // where new units wait
addUnit(queue.shift(), "player", HOME.add(26, 0), { type: "move", to: rally });
});
add([rect(width(), HUD), color(16, 22, 18), z(40)]);
const hud = add([text("", { size: 14, font: FONT }), pos(10, 10), z(41)]);
onUpdate(() => {
const busy = queue.length ? ` ${t({ es: "Entrenando", en: "Training" })}: ${queue.length}` : "";
hud.text = `${t({ es: "Cristales", en: "Crystals" })}: ${crystals} ${t({ es: "Unidades", en: "Units" })}: ${mine().length}${busy}`;
});
[["worker", t({ es: "1 Obrero", en: "1 Worker" })], ["soldier", t({ es: "2 Soldado", en: "2 Soldier" })]].forEach(([kind, label], i) => {
const b = add([rect(128, 26, { radius: 4 }), pos(370 + i * 136, 4), color(40, 56, 46), outline(2, rgb(110, 230, 240)), area(), z(41)]);
b.add([text(`${label} (${COST[kind]})`, { size: 13, font: FONT }), pos(64, 13), anchor("center")]);
b.onClick(() => train(kind));
onButtonPress(kind, () => train(kind));
});
// #endregion
// #region combat
// Soldiers and raiders share one rule: chase the target until in range,
// then strike every `rate` seconds. Idle fighters defend themselves.
const foesOf = (team) => get(team === "player" ? "enemy" : "player");
function updateFighter(u) {
const s = STATS[u.kind];
u.cooldown -= dt(), u.shot -= dt();
let target = u.order?.type === "attack" && u.order.target.exists() ? u.order.target : null;
if (u.order?.type === "attack" && !target) u.order = null;
if (!target && u.order?.type !== "move") target = nearest(foesOf(u.team), u.pos, SIGHT);
if (!target && u.order?.type === "assault") target = playerBase;
if (!target) return u.order?.type === "move" && moveToward(u, u.order.to) && (u.order = null);
if (u.pos.dist(target.pos) > s.range + target.size) return moveToward(u, target.pos, 0);
if (u.cooldown > 0) return;
u.cooldown = s.rate;
[u.shot, u.shotAt] = [0.08, target.pos.clone()]; // a short flash shows who hits whom
target.hp -= s.damage;
if (target.hp > 0) return;
destroy(target);
if (target.is("building")) go("end", target.team === "enemy");
}
onUpdate("unit", (u) => {
// Neighbours push each other apart, so a group does not collapse into one point.
for (const o of get("unit")) if (o !== u && u.pos.dist(o.pos) < u.size + o.size) u.pos = u.pos.add(u.pos.sub(o.pos).unit().scale(40 * dt()));
u.kind === "worker" ? updateWorker(u) : updateFighter(u);
});
// #endregion
// #region enemy-ai
// A very simple opponent: every few seconds its base sends a group of
// raiders at your base, and each group is a little bigger than the last.
let waveSize = 1;
loop(WAVE_EVERY, () => {
for (let i = 0; i < waveSize; i++) addUnit("raider", "enemy", ENEMY_HOME.add(-30, (i - 1) * 18), { type: "assault" });
waveSize = Math.min(waveSize + 1, MAX_WAVE);
}, undefined, true);
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 85)), anchor("center")]);
add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 25)), anchor("center"), color(200, 230, 210)]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Estrategia en tiempo real", en: "Real-time strategy" }),
t({
es: "Haz clic en una unidad o arrastra un recuadro para seleccionar. Luego haz clic en el suelo para mover, en un cristal para recolectar (obreros) o en un enemigo para atacar (soldados).\nCompra unidades arriba con los cristales. Destruye la base roja antes de que caiga la tuya.\nHaz clic o pulsa espacio para empezar.",
en: "Click a unit or drag a box to select. Then click the ground to move, a crystal to gather (workers) or an enemy to attack (soldiers).\nBuy units at the top with crystals. Destroy the red base before yours falls.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won) => screen(
won ? t({ es: "¡Base enemiga destruida!", en: "Enemy base destroyed!" }) : t({ es: "Tu base cayó", en: "Your base fell" }),
t({ es: "Haz clic o pulsa espacio para jugar otra vez.", en: "Click or press space to play again." }),
() => go("game"),
));
go("title");
// #endregionRetos de diseño
- Una interfaz que permita mandar a decenas de unidades sin pelear con el ratón (selección, grupos y atajos).
- Búsqueda de caminos y movimiento en grupo sin atascos, con muchas unidades a la vez y sin que baje el rendimiento.
- Equilibrar unidades y facciones para que ninguna estrategia domine a todas las demás.
- Una IA rival que juegue con las mismas reglas que el jugador y sea un desafío sin hacer trampa.
Historia
- 1982OrigenUtopia
Utopia (1982), con rondas cronometradas, se considera el antepasado más antiguo; Herzog Zwei (1989) suele citarse como el primer juego del género propiamente dicho.
Real-time strategy (Wikipedia) - 1989Herzog Zwei
Herzog Zwei, para Mega Drive, une la estrategia con acción en tiempo real. Solo pilotas una nave, pero desde ella compras unidades y les das órdenes, un control que anticipa el de apuntar y hacer clic.
Real-time strategy (Wikipedia) - 1992Dune II
Dune II reúne las bases que el género todavía usa, como mover unidades con el ratón y recolectar recursos. Brett Sperry, de Westwood, acuña el término «estrategia en tiempo real» para promocionarlo.
Real-time strategy (Wikipedia) - 1994
Warcraft: Orcs & Humans lleva el género a la fantasía, y sus edificios, como las granjas, representan una sociedad entera y no solo un ejército.
Real-time strategy (Wikipedia) - 1995
Command & Conquer y su continuación Red Alert (1996) son los juegos del género más populares de la primera época. De ellos viene la táctica del «tank rush», atacar en masa muy pronto.
Real-time strategy (Wikipedia) - 1997Age of Empires
Age of Empires baja el ritmo y suma ideas de Civilization, con una civilización que avanza por edades tecnológicas.
Real-time strategy (Wikipedia) - 1998StarCraft
StarCraft enfrenta a tres razas muy distintas entre sí y se vuelve un fenómeno internacional; todavía se juega en ligas profesionales.
Real-time strategy (Wikipedia) - 2002
Hacia 2002, con Warcraft III, el 3D se vuelve lo habitual. Un mod de ese juego, Defense of the Ancients (2003), da origen a los MOBA.
Real-time strategy (Wikipedia) - 20090 A.D.
Wildfire Games libera el código de 0 A.D. con licencia GPL, de modo que es un juego del género que cualquiera puede descargar, estudiar y modificar.
0 A.D. (Wikipedia) - 2013Littlewargame
Littlewargame, un juego de estrategia en tiempo real sencillo, hecho en HTML5 y con multijugador, se juega entero en el navegador.
Littlewargame - an RTS with multiplayer and map editor (HTML5 Game Devs Forum)
Juegos de referencia
1982
Utopia
Dos jugadores gobiernan cada uno una isla en rondas cronometradas. Con lingotes de oro construyes casas, escuelas, fábricas, hospitales y cultivos para alojar, alimentar y contentar a una población que crece; si no lo logras, aparecen rebeldes. Se le atribuye el origen del género.
1989
Herzog Zwei
Pilotas una nave que se transforma en robot, compras unidades de tierra, las llevas por el aire y les das órdenes para tomar bases en un duelo en tiempo real contra otro comandante. Se le considera el precursor del género.
1992
Dune II
Diriges a una de tres casas que se disputan un planeta desierto. Tus recolectores juntan especia, la refinería la convierte en créditos y con ellos levantas edificios y compras tropas que mueves con el ratón. Fijó el modelo del género.
1997
Age of Empires
Llevas a una civilización antigua desde la Edad de Piedra, recolectando comida, madera, piedra y oro, y avanzas por edades que desbloquean nuevas tropas y tecnologías. Un ritmo más pausado que el de otros juegos del género.
1998
StarCraft
Tres razas muy distintas (terran, zerg y protoss), cada una con sus propias unidades y su forma de jugar, recolectan minerales y gas para enfrentarse. Su multijugador se volvió un deporte electrónico profesional.
2010
0 A.D.
Estrategia en tiempo real histórica, libre y de código abierto. Levantas una base, desarrollas la economía, entrenas un ejército e investigas tecnologías, y tu asentamiento pasa de aldea a pueblo y a ciudad.
2013
Littlewargame
Un RTS medieval de fantasía, sencillo y hecho en HTML5, que se juega entero en el navegador, con multijugador de hasta seis personas.
2023
Retro Commander
Estrategia en tiempo real posapocalíptica, de estilo clásico, con escaramuzas contra la IA, campaña en cómic y multijugador entre plataformas. Se maneja con ratón o pantalla táctil.
Ejercicio
En la demo, cambia el costo del soldado y el tiempo entre oleadas, y prueba si conviene entrenar más obreros al principio. Luego dibuja en papel el árbol tecnológico de un juego del género que conozcas y discute qué decisiones obliga a tomar.