Saltar al contenido
GameGenresEnglish
Estrategia

Artillería

Juego, normalmente por turnos, en el que tú y tus rivales se disparan desde posiciones fijas eligiendo el ángulo y la potencia de cada tiro. El reto es calcular la trayectoria teniendo en cuenta la distancia, el terreno y a veces el viento.

También se llama: juego de artillería, videojuego de artillería

Mecánicas que lo definen

  • Elegir el ángulo y la potencia de cada disparo; ese par de números es toda la acción del turno.
  • Trayectoria parabólica con gravedad, a menudo desviada por un viento que cambia entre turnos o entre rondas.
  • Terreno de colinas o edificios que tapa la línea de tiro y que los impactos deforman o destruyen.
  • Turnos alternos contra otra persona o contra la computadora, a veces con un arsenal de armas que se compra entre rondas.

Bucle de juego

Mirar la distancia, el viento y el terreno, ajustar ángulo y potencia, disparar, ver dónde cae el tiro y corregir en el turno siguiente.

Demo propia

Controles. Flechas izquierda y derecha (o A/D) para el ángulo, arriba y abajo (o W/S) para la potencia, espacio o Enter para disparar. Con ratón o en móvil, arrastra desde tu tanque hacia donde quieres apuntar y suelta. En el título, espacio o clic a la izquierda para jugar contra la IA, y la tecla 2 o clic a la derecha para dos jugadores.

Abrir en pantalla completa

Dos tanques se disparan por turnos sobre un terreno de colinas generado al azar. Eliges ángulo y potencia, tienes en cuenta el viento, y cada disparo abre un cráter que cambia el terreno. Puedes jugar contra una IA que corrige su tiro o contra otra persona.

Hecha con Kaplay, en unas 189 líneas de JavaScript.

Cómo está hecha

  1. Configuración y constantes

    Se definen botones virtuales para el ángulo, la potencia y el disparo, que funcionan igual con teclado o con los botones táctiles. La gravedad, la velocidad máxima de lanzamiento y la fuerza del viento son los números que deciden cómo se siente cada tiro; el radio del cráter decide cuánto cambia el terreno.

    const k = kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [24, 30, 64],
      buttons: {
        angleUp: { keyboard: ["left", "a"] },
        angleDown: { keyboard: ["right", "d"] },
        powerUp: { keyboard: ["up", "w"] },
        powerDown: { keyboard: ["down", "s"] },
        fire: { keyboard: ["space", "enter"] },
      },
    });
    touchControls(k, [
      { button: "angleUp", label: "↺" },
      { button: "angleDown", label: "↻" },
      { button: "powerDown", label: "−", side: "right" },
      { button: "powerUp", label: "+", side: "right" },
      { button: "fire", label: "●", side: "right" },
    ]);
    
    const W = 640;
    const H = 360;
    const GRAVITY = 240; // px/s² pulling the shell down
    const MAX_POWER = 420; // launch speed at 100 % power
    const WIND_MAX = 60; // px/s² of horizontal push, at most
    const CRATER = 26; // crater radius in px
    const COL = 4; // terrain column width in px
  2. El terreno como mapa de alturas

    El terreno es una lista de números, uno por columna de 4 píxeles, que guarda a qué altura empieza el suelo. Se genera sumando ondas seno con fases al azar, así cada partida tiene colinas distintas. Deformarlo es solo cambiar números: el cráter recorre las columnas cercanas al impacto y baja cada una hasta el borde inferior de un círculo. Por eso el terreno puede hundirse, pero no puede formar cuevas ni salientes.

    // The terrain is a height map: one number per column (the y of the ground top).
    function makeTerrain() {
      const a = rand(0, 6), b = rand(0, 6);
      return Array.from({ length: W / COL }, (_, i) => {
        const x = i / (W / COL);
        return 230 - 50 * Math.sin(x * 3.1 + a) - 25 * Math.sin(x * 9 + b) - 60 * Math.sin(x * Math.PI);
      });
    }
    const groundAt = (ground, x) => ground[Math.max(0, Math.min(ground.length - 1, Math.floor(x / COL)))];
    
    // A crater removes the dirt inside a circle: each column's top drops to the circle's bottom edge.
    function digCrater(ground, cx, cy) {
      for (let i = 0; i < ground.length; i++) {
        const dx = i * COL + COL / 2 - cx;
        if (Math.abs(dx) < CRATER) ground[i] = Math.max(ground[i], cy + Math.sqrt(CRATER ** 2 - dx ** 2));
      }
    }
  3. El tiro parabólico

    Es la mecánica del género. El disparo sale con una velocidad que es la potencia en la dirección del ángulo. En cada instante la gravedad suma velocidad hacia abajo y el viento suma velocidad hacia un lado, y la posición avanza con esa velocidad: el resultado es una parábola, inclinada por el viento. El recorrido se calcula en pasos pequeños para que un proyectil rápido no atraviese una colina delgada sin tocarla. Un ángulo de 45° llega más lejos en terreno llano; ángulos más altos sirven para pasar por encima de una colina.

    // One step of a parabolic flight: gravity bends the path down, wind pushes it sideways.
    function step(p, v, d) {
      v.y += GRAVITY * d;
      v.x += wind * d;
      p.x += v.x * d;
      p.y += v.y * d;
    }
    
    function fire() {
      if (shell) return;
      const tk = tanks[turn];
      shell = { p: muzzle(tk), v: launchVel(tk) };
    }
    
    onUpdate(() => {
      if (!shell) return;
      for (let i = 0; i < 4; i++) {
        // small sub-steps so a fast shell cannot skip through a thin hill
        step(shell.p, shell.v, dt() / 4);
        if (shell.p.x < 0 || shell.p.x > W) return land(false);
        const enemy = tanks[1 - turn];
        if (shell.p.dist(tankPos(enemy).add(0, -6)) < 14) return land(true);
        if (shell.p.y >= groundAt(ground, shell.p.x)) return land(false);
      }
    });
  4. Impacto, cráter y turnos

    Cuando el proyectil toca el suelo o un tanque, se abre un cráter y la pantalla tiembla. Cualquier tanque dentro del radio de la explosión pierde, también el tuyo si disparas demasiado cerca. Si nadie cae, pasa el turno y el viento cambia un poco, de modo que repetir el último tiro no garantiza nada.

    function land(hit) {
      const at = shell.p.clone();
      shell = null;
      if (at.x >= 0 && at.x <= W) {
        digCrater(ground, at.x, at.y);
        add([circle(CRATER), pos(at), anchor("center"), color(255, 200, 80), opacity(0.8), lifespan(0.25, { fade: 0.2 })]);
        shake(hit ? 12 : 4);
      }
      // Any tank within the blast radius is destroyed (including your own!).
      const caught = tanks.findIndex((tk) => tankPos(tk).add(0, -6).dist(at) < CRATER);
      const loser = hit ? 1 - turn : caught;
      if (loser >= 0) return wait(0.6, () => go("end", 1 - loser, vsAI));
      if (turn === 1 && vsAI) aiMemory.lastLanding = at.x;
      nextTurn();
    }
    
    function nextTurn() {
      turn = 1 - turn;
      // Variable wind: it drifts a little every turn, so no shot can be repeated blindly.
      wind = Math.max(-WIND_MAX, Math.min(WIND_MAX, wind + rand(-20, 20)));
      if (!isHuman()) wait(0.9, aiShot);
    }
  5. Una IA que aprende por aproximaciones

    La IA hace lo mismo que una persona: prueba, mira dónde cayó y corrige. Su primer tiro sale de la fórmula del alcance en terreno llano, con un error a propósito. Después, como en terreno llano el alcance crece con el cuadrado de la velocidad, multiplica la potencia por la raíz de «distancia que quería / distancia que logró». Si el tiro se queda muy corto porque chocó con una colina, en vez de disparar más fuerte levanta el ángulo.

    // The AI fixes its angle and corrects the power by successive approximations.
    // On flat ground the range grows with the square of the speed, so it scales the
    // power by √(wanted distance / distance reached) and gets closer every turn.
    function aiShot() {
      const me = tanks[1];
      const target = Math.abs(me.x - tanks[0].x);
      me.angle = aiMemory.angle;
      if (aiMemory.power === null) {
        const rad = (180 - me.angle) * (Math.PI / 180);
        aiMemory.power = Math.sqrt((target * GRAVITY) / Math.sin(2 * rad)) / MAX_POWER;
        aiMemory.power *= rand(0.8, 1.2); // a deliberately rough first guess
      } else {
        const reached = Math.max(20, me.x - aiMemory.lastLanding);
        // A shell that stops very short hit a hill: aim higher instead of harder.
        if (reached < target / 3 && aiMemory.angle > 105) aiMemory.angle -= 10;
        else aiMemory.power *= Math.sqrt(target / reached) * rand(0.97, 1.03);
      }
      me.power = Math.max(0.15, Math.min(1, aiMemory.power));
      fire();
    }
  6. Apuntar con teclado o arrastrando

    Con el teclado, mantener una flecha cambia el ángulo o la potencia poco a poco. Con ratón o en una pantalla táctil, el vector que va del cañón al puntero da las dos cosas a la vez, su dirección es el ángulo y su largo es la potencia, y al soltar se dispara.

    // Keyboard: angle and power in small steps. Mouse or touch: drag from the tank.
    onUpdate(() => {
      if (shell || !isHuman()) return;
      const tk = tanks[turn];
      if (isButtonDown("angleUp")) tk.angle = Math.min(180, tk.angle + 60 * dt());
      if (isButtonDown("angleDown")) tk.angle = Math.max(0, tk.angle - 60 * dt());
      if (isButtonDown("powerUp")) tk.power = Math.min(1, tk.power + 0.4 * dt());
      if (isButtonDown("powerDown")) tk.power = Math.max(0.1, tk.power - 0.4 * dt());
      if (aiming) {
        const d = mousePos().sub(muzzle(tk));
        tk.angle = Math.max(0, Math.min(180, -d.angle()));
        tk.power = Math.max(0.1, Math.min(1, d.len() / 160));
      }
    });
    onButtonPress("fire", () => isHuman() && fire());
    onMousePress(() => (aiming = !shell && isHuman()));
    onMouseRelease(() => {
      if (aiming) fire();
      aiming = false;
    });
  7. Dibujo, trayectoria y viento

    Todo se dibuja cada fotograma con formas simples. La vista previa simula con la misma función del disparo solo el primer tramo de la trayectoria, que alcanza para orientarse pero no para saber dónde caerá; mostrarla entera quitaría el reto. La flecha de arriba indica hacia dónde sopla el viento y con cuánta fuerza.

    onDraw(() => {
      ground.forEach((y, i) => drawRect({ pos: vec2(i * COL, y), width: COL, height: H - y, color: rgb(80, 150, 90) }));
      tanks.forEach((tk) => {
        const p = tankPos(tk);
        drawRect({ pos: p.add(-12, -10), width: 24, height: 10, color: tk.colour });
        drawLine({ p1: muzzle(tk), p2: muzzle(tk).add(Vec2.fromAngle(-tk.angle).scale(18)), width: 4, color: tk.colour });
      });
      // Preview of the first part of the path: enough to aim, not enough to be sure.
      if (!shell && isHuman()) {
        const p = muzzle(tanks[turn]);
        const v = launchVel(tanks[turn]);
        for (let i = 0; i < 14; i++) {
          for (let j = 0; j < 3; j++) step(p, v, 1 / 60);
          drawCircle({ pos: p, radius: 2, color: rgb(255, 255, 255), opacity: 1 - i / 14 });
        }
      }
      if (shell) drawCircle({ pos: shell.p, radius: 4, color: rgb(255, 240, 160) });
      // Wind arrow: length and direction show its strength.
      const c = vec2(W / 2, 22);
      const tip = c.add(wind, 0);
      drawLine({ p1: c, p2: tip, width: 3, color: rgb(200, 220, 255) });
      drawTriangle({ p1: tip.add(Math.sign(wind) * 8, 0), p2: tip.add(0, -5), p3: tip.add(0, 5), color: rgb(200, 220, 255) });
      const tk = tanks[turn];
      const who = isHuman() ? t({ es: `Jugador ${turn + 1}`, en: `Player ${turn + 1}` }) : t({ es: "IA", en: "AI" });
      hud.text = `${who} · ${t({ es: "Ángulo", en: "Angle" })} ${Math.round(tk.angle)}° · ${t({ es: "Potencia", en: "Power" })} ${Math.round(tk.power * 100)} %`;
    });
  8. Escenas

    El juego tiene tres escenas (title, game y end). La escena game recibe si se juega contra la IA o entre dos personas, y go() salta de una a otra, generando un terreno nuevo en cada partida.

    // On the title, the left half of the screen means "vs AI" and the right half "two players".
    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: 15, font: FONT, align: "center", width: 560 }),
        pos(center().add(0, 25)),
        anchor("center"),
        color(200, 210, 255),
      ]);
      onButtonPress("fire", () => next(true));
      onKeyPress("2", () => next(false));
      onClick(() => next(mousePos().x < W / 2));
    }
    
    scene("title", () =>
      screen(
        t({ es: "Artillería", en: "Artillery" }),
        t({
          es: "Por turnos: ←/→ cambian el ángulo, ↑/↓ la potencia, espacio dispara.\nO arrastra desde tu tanque y suelta. Mira el viento (flecha de arriba).\nEspacio o clic a la izquierda: contra la IA. Tecla 2 o clic a la derecha: dos jugadores.",
          en: "Turn-based: ←/→ change the angle, ↑/↓ the power, space fires.\nOr drag from your tank and let go. Watch the wind (arrow at the top).\nSpace or click on the left: against the AI. Key 2 or click on the right: two players.",
        }),
        (vsAI) => go("game", vsAI),
      ),
    );
    
    scene("end", (winner, vsAI) =>
      screen(
        vsAI && winner === 1 ? t({ es: "Ganó la IA", en: "The AI wins" }) : t({ es: `¡Gana el jugador ${winner + 1}!`, en: `Player ${winner + 1} wins!` }),
        t({ es: "Espacio o clic para volver al título.", en: "Space or click to go back to the title." }),
        () => go("title"),
      ),
    );
    
    go("title");

Qué simplificamos

  • Un solo tipo de proyectil y un impacto directo o cercano destruye el tanque; no hay vida, ni dinero, ni tienda de armas.
  • Los tanques no se mueven y la tierra que quitan los cráteres desaparece, no cae ni se desliza.
  • La IA mantiene casi siempre el mismo ángulo y solo corrige la potencia; no razona sobre el viento.

Para modificarla

  • Dale a cada tanque 100 puntos de vida y haz que el daño dependa de la distancia al centro de la explosión.
  • Haz que la IA también tenga en cuenta el viento. Pista, compara dónde cayó su tiro con dónde habría caído sin viento.
  • Añade un segundo proyectil que en vez de cavar un cráter añada tierra, por ejemplo para protegerte detrás de un muro.
Código completo
// Artillery demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, touchControls, pixelDensity } from "../common.js";

// #region setup
const k = kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [24, 30, 64],
  buttons: {
    angleUp: { keyboard: ["left", "a"] },
    angleDown: { keyboard: ["right", "d"] },
    powerUp: { keyboard: ["up", "w"] },
    powerDown: { keyboard: ["down", "s"] },
    fire: { keyboard: ["space", "enter"] },
  },
});
touchControls(k, [
  { button: "angleUp", label: "↺" },
  { button: "angleDown", label: "↻" },
  { button: "powerDown", label: "−", side: "right" },
  { button: "powerUp", label: "+", side: "right" },
  { button: "fire", label: "●", side: "right" },
]);

const W = 640;
const H = 360;
const GRAVITY = 240; // px/s² pulling the shell down
const MAX_POWER = 420; // launch speed at 100 % power
const WIND_MAX = 60; // px/s² of horizontal push, at most
const CRATER = 26; // crater radius in px
const COL = 4; // terrain column width in px
// #endregion

// #region terrain
// The terrain is a height map: one number per column (the y of the ground top).
function makeTerrain() {
  const a = rand(0, 6), b = rand(0, 6);
  return Array.from({ length: W / COL }, (_, i) => {
    const x = i / (W / COL);
    return 230 - 50 * Math.sin(x * 3.1 + a) - 25 * Math.sin(x * 9 + b) - 60 * Math.sin(x * Math.PI);
  });
}
const groundAt = (ground, x) => ground[Math.max(0, Math.min(ground.length - 1, Math.floor(x / COL)))];

// A crater removes the dirt inside a circle: each column's top drops to the circle's bottom edge.
function digCrater(ground, cx, cy) {
  for (let i = 0; i < ground.length; i++) {
    const dx = i * COL + COL / 2 - cx;
    if (Math.abs(dx) < CRATER) ground[i] = Math.max(ground[i], cy + Math.sqrt(CRATER ** 2 - dx ** 2));
  }
}
// #endregion

scene("game", (vsAI) => {
  const ground = makeTerrain();
  const tanks = [
    { x: 64, angle: 45, power: 0.6, colour: rgb(120, 200, 255) },
    { x: W - 64, angle: 135, power: 0.6, colour: rgb(255, 150, 90) },
  ];
  let turn = 0;
  let wind = rand(-WIND_MAX, WIND_MAX);
  let shell = null; // the projectile in flight, if any
  let aiming = false; // is the mouse/finger dragging an aim?
  const aiMemory = { angle: 130, power: null, lastLanding: null };
  const hud = add([text("", { size: 15, font: FONT }), pos(12, 10), z(10)]);
  const tankPos = (tk) => vec2(tk.x, groundAt(ground, tk.x));
  const muzzle = (tk) => tankPos(tk).add(0, -10);
  const launchVel = (tk) => Vec2.fromAngle(-tk.angle).scale(tk.power * MAX_POWER);
  const isHuman = () => !(vsAI && turn === 1);

  // #region projectile
  // One step of a parabolic flight: gravity bends the path down, wind pushes it sideways.
  function step(p, v, d) {
    v.y += GRAVITY * d;
    v.x += wind * d;
    p.x += v.x * d;
    p.y += v.y * d;
  }

  function fire() {
    if (shell) return;
    const tk = tanks[turn];
    shell = { p: muzzle(tk), v: launchVel(tk) };
  }

  onUpdate(() => {
    if (!shell) return;
    for (let i = 0; i < 4; i++) {
      // small sub-steps so a fast shell cannot skip through a thin hill
      step(shell.p, shell.v, dt() / 4);
      if (shell.p.x < 0 || shell.p.x > W) return land(false);
      const enemy = tanks[1 - turn];
      if (shell.p.dist(tankPos(enemy).add(0, -6)) < 14) return land(true);
      if (shell.p.y >= groundAt(ground, shell.p.x)) return land(false);
    }
  });
  // #endregion

  // #region impact
  function land(hit) {
    const at = shell.p.clone();
    shell = null;
    if (at.x >= 0 && at.x <= W) {
      digCrater(ground, at.x, at.y);
      add([circle(CRATER), pos(at), anchor("center"), color(255, 200, 80), opacity(0.8), lifespan(0.25, { fade: 0.2 })]);
      shake(hit ? 12 : 4);
    }
    // Any tank within the blast radius is destroyed (including your own!).
    const caught = tanks.findIndex((tk) => tankPos(tk).add(0, -6).dist(at) < CRATER);
    const loser = hit ? 1 - turn : caught;
    if (loser >= 0) return wait(0.6, () => go("end", 1 - loser, vsAI));
    if (turn === 1 && vsAI) aiMemory.lastLanding = at.x;
    nextTurn();
  }

  function nextTurn() {
    turn = 1 - turn;
    // Variable wind: it drifts a little every turn, so no shot can be repeated blindly.
    wind = Math.max(-WIND_MAX, Math.min(WIND_MAX, wind + rand(-20, 20)));
    if (!isHuman()) wait(0.9, aiShot);
  }
  // #endregion

  // #region ai
  // The AI fixes its angle and corrects the power by successive approximations.
  // On flat ground the range grows with the square of the speed, so it scales the
  // power by √(wanted distance / distance reached) and gets closer every turn.
  function aiShot() {
    const me = tanks[1];
    const target = Math.abs(me.x - tanks[0].x);
    me.angle = aiMemory.angle;
    if (aiMemory.power === null) {
      const rad = (180 - me.angle) * (Math.PI / 180);
      aiMemory.power = Math.sqrt((target * GRAVITY) / Math.sin(2 * rad)) / MAX_POWER;
      aiMemory.power *= rand(0.8, 1.2); // a deliberately rough first guess
    } else {
      const reached = Math.max(20, me.x - aiMemory.lastLanding);
      // A shell that stops very short hit a hill: aim higher instead of harder.
      if (reached < target / 3 && aiMemory.angle > 105) aiMemory.angle -= 10;
      else aiMemory.power *= Math.sqrt(target / reached) * rand(0.97, 1.03);
    }
    me.power = Math.max(0.15, Math.min(1, aiMemory.power));
    fire();
  }
  // #endregion

  // #region aiming
  // Keyboard: angle and power in small steps. Mouse or touch: drag from the tank.
  onUpdate(() => {
    if (shell || !isHuman()) return;
    const tk = tanks[turn];
    if (isButtonDown("angleUp")) tk.angle = Math.min(180, tk.angle + 60 * dt());
    if (isButtonDown("angleDown")) tk.angle = Math.max(0, tk.angle - 60 * dt());
    if (isButtonDown("powerUp")) tk.power = Math.min(1, tk.power + 0.4 * dt());
    if (isButtonDown("powerDown")) tk.power = Math.max(0.1, tk.power - 0.4 * dt());
    if (aiming) {
      const d = mousePos().sub(muzzle(tk));
      tk.angle = Math.max(0, Math.min(180, -d.angle()));
      tk.power = Math.max(0.1, Math.min(1, d.len() / 160));
    }
  });
  onButtonPress("fire", () => isHuman() && fire());
  onMousePress(() => (aiming = !shell && isHuman()));
  onMouseRelease(() => {
    if (aiming) fire();
    aiming = false;
  });
  // #endregion

  // #region draw
  onDraw(() => {
    ground.forEach((y, i) => drawRect({ pos: vec2(i * COL, y), width: COL, height: H - y, color: rgb(80, 150, 90) }));
    tanks.forEach((tk) => {
      const p = tankPos(tk);
      drawRect({ pos: p.add(-12, -10), width: 24, height: 10, color: tk.colour });
      drawLine({ p1: muzzle(tk), p2: muzzle(tk).add(Vec2.fromAngle(-tk.angle).scale(18)), width: 4, color: tk.colour });
    });
    // Preview of the first part of the path: enough to aim, not enough to be sure.
    if (!shell && isHuman()) {
      const p = muzzle(tanks[turn]);
      const v = launchVel(tanks[turn]);
      for (let i = 0; i < 14; i++) {
        for (let j = 0; j < 3; j++) step(p, v, 1 / 60);
        drawCircle({ pos: p, radius: 2, color: rgb(255, 255, 255), opacity: 1 - i / 14 });
      }
    }
    if (shell) drawCircle({ pos: shell.p, radius: 4, color: rgb(255, 240, 160) });
    // Wind arrow: length and direction show its strength.
    const c = vec2(W / 2, 22);
    const tip = c.add(wind, 0);
    drawLine({ p1: c, p2: tip, width: 3, color: rgb(200, 220, 255) });
    drawTriangle({ p1: tip.add(Math.sign(wind) * 8, 0), p2: tip.add(0, -5), p3: tip.add(0, 5), color: rgb(200, 220, 255) });
    const tk = tanks[turn];
    const who = isHuman() ? t({ es: `Jugador ${turn + 1}`, en: `Player ${turn + 1}` }) : t({ es: "IA", en: "AI" });
    hud.text = `${who} · ${t({ es: "Ángulo", en: "Angle" })} ${Math.round(tk.angle)}° · ${t({ es: "Potencia", en: "Power" })} ${Math.round(tk.power * 100)} %`;
  });
  // #endregion
});

// #region scenes
// On the title, the left half of the screen means "vs AI" and the right half "two players".
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: 15, font: FONT, align: "center", width: 560 }),
    pos(center().add(0, 25)),
    anchor("center"),
    color(200, 210, 255),
  ]);
  onButtonPress("fire", () => next(true));
  onKeyPress("2", () => next(false));
  onClick(() => next(mousePos().x < W / 2));
}

scene("title", () =>
  screen(
    t({ es: "Artillería", en: "Artillery" }),
    t({
      es: "Por turnos: ←/→ cambian el ángulo, ↑/↓ la potencia, espacio dispara.\nO arrastra desde tu tanque y suelta. Mira el viento (flecha de arriba).\nEspacio o clic a la izquierda: contra la IA. Tecla 2 o clic a la derecha: dos jugadores.",
      en: "Turn-based: ←/→ change the angle, ↑/↓ the power, space fires.\nOr drag from your tank and let go. Watch the wind (arrow at the top).\nSpace or click on the left: against the AI. Key 2 or click on the right: two players.",
    }),
    (vsAI) => go("game", vsAI),
  ),
);

scene("end", (winner, vsAI) =>
  screen(
    vsAI && winner === 1 ? t({ es: "Ganó la IA", en: "The AI wins" }) : t({ es: `¡Gana el jugador ${winner + 1}!`, en: `Player ${winner + 1} wins!` }),
    t({ es: "Espacio o clic para volver al título.", en: "Space or click to go back to the title." }),
    () => go("title"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Decidir cuánta información se le da al jugador (una vista previa de la trayectoria, el valor exacto del viento) para que apuntar sea un cálculo y no una lotería, pero tampoco algo trivial.
  • Equilibrar el azar (terreno generado, viento) con la habilidad, para que gane quien mejor corrige sus tiros.
  • Una IA que falle de forma creíble y vaya afinando su puntería, en vez de acertar siempre a la primera.
  • Representar un terreno destructible (un mapa de alturas o una máscara de píxeles) y mantener ágil el ritmo de los turnos cuando juegan varias personas.

Historia

  1. 1969
    Origen

    Potshot (1969), de Arthur Luehrmann, quizá fue la primera versión con salida gráfica, en un trazador; hubo también variantes solo de texto a inicios de los años setenta.

    Artillery game (Wikipedia)
  2. 1980

    Super Artillery y Artillery Simulator, para Apple II, adaptan el género a la pantalla gráfica y le añaden viento, terreno visible y tanques.

    Artillery game (Wikipedia)
  3. 1982
    Artillery Duel

    Artillery Duel lleva a las consolas el duelo de dos cañones separados por una montaña, que cambia de forma en cada turno.

    Artillery Duel (Wikipedia)
  4. 1990

    Tank Wars, de Kenny Morse, introduce la compra de armas y varios tanques controlados por la computadora.

    Artillery game (Wikipedia)
  5. 1991
    Gorillas

    Gorillas llega como programa de ejemplo de QBasic en MS-DOS 5.0, con el código en BASIC a la vista de quien quiera leerlo o cambiarlo.

    Gorillas (Wikipedia)
  6. 1991
    Scorched Earth

    Scorched Earth, shareware de Wendell Hicken, suma terreno destructible y armas cada vez más potentes, y se considera el arquetipo moderno del género.

    Scorched Earth (Wikipedia)
  7. 1995
    Worms

    Worms cambia los tanques por equipos de gusanos, con turnos de tiempo limitado y explosiones que abren agujeros en mapas generados al azar; su éxito da lugar a una larga serie.

    Worms (1995) (Wikipedia)
  8. 2009

    Angry Birds lleva la idea a la pantalla táctil, donde se lanza con una honda y hay que anticipar la trayectoria para derribar estructuras.

    Artillery game (Wikipedia)

Juegos de referencia

1982

Artillery Duel

Perkins Engineering (John Perkins). Bally Astrocade, Atari 2600, ColecoVision, Commodore 64, VIC-20

Dos cañones se enfrentan a ambos lados de una montaña y cada jugador elige el ángulo y la fuerza del disparo. Llevó el género de los programas en BASIC a las consolas, con una montaña distinta en cada turno.

1991

Gorillas

Microsoft. MS-DOS

Dos gorilas en lo alto de unos rascacielos se lanzan plátanos explosivos escribiendo el ángulo y la velocidad de cada tiro, con viento que cambia en cada ronda. Venía con QBasic en MS-DOS 5.0, así que muchos lo jugaron y leyeron su código.

1995

Worms

Team17 (Andy Davidson). Amiga, MS-DOS, PlayStation, Sega Saturn, Mega Drive, Super NES, Game Boy, Mac OS, Atari Jaguar

Equipos de gusanos se turnan para atacarse con bazucas, granadas y otras armas en mapas generados al azar; cada explosión abre un agujero redondo en el terreno, y cada turno tiene tiempo límite. Inició una larga serie.

2025

Worms 13k

Craig Smith. navegador

Homenaje a Worms hecho en menos de 13 kilobytes para js13kGames 2025. Dos equipos de gusanos se atacan por turnos con bazucas, minigun y bombas de racimo sobre un terreno que se destruye. Se juega en el navegador, en el mismo teclado o en línea.

Ejercicio

En la demo, cambia la gravedad y la fuerza máxima del viento y anota cómo cambia la dificultad. Luego mide con varios disparos a qué ángulo se llega más lejos en terreno llano y compáralo con la física del tiro parabólico.

Fuentes