Saltar al contenido
GameGenresEnglish
Estrategia

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 completa

Una 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

  1. 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 },
    };
  2. 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 }]);
  3. 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) });
      }
    });
  4. 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) } });
    });
  5. Ó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);
  6. 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);
    }
  7. 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));
    });
  8. 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);
    });
  9. 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);
  10. 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");
// #endregion

Retos 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

  1. 1982
    OrigenUtopia

    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)
  2. 1989
    Herzog 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)
  3. 1992
    Dune 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)
  4. 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)
  5. 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)
  6. 1997
    Age 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)
  7. 1998
    StarCraft

    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)
  8. 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)
  9. 2009
    0 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)
  10. 2013
    Littlewargame

    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

Mattel Electronics (Don Daglow). Intellivision

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

Technosoft. Mega Drive

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

Westwood Studios. MS-DOS, Amiga, Mega Drive

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

Ensemble Studios. Windows

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

Blizzard Entertainment. Windows, macOS

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.

2023

Retro Commander

Noble Master Games. navegador, Windows, macOS, Linux, Android, iOS

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.

Fuentes