Saltar al contenido
GameGenresEnglish
Acción

Lucha

Juego de acción en el que controlas a un luchador en combate cuerpo a cuerpo contra uno o más rivales, normalmente a varias rondas, y ganas leyendo sus intenciones y castigando sus errores con golpes en el momento justo.

También se llama: juego de lucha, juego de peleas, videojuego de pelea

Mecánicas que lo definen

  • Combate uno contra uno en un escenario corto visto de lado, con barras de vida y rondas al mejor de tres.
  • Golpes con alcance, arranque (startup) y recuperación (recovery) distintos; el tiempo que dura cada fase decide quién golpea primero.
  • Bloqueo y golpes altos o bajos, que obligan a adivinar y a reaccionar.
  • Movimientos especiales con combinaciones de botones y combos que encadenan golpes.
  • Plantel de personajes con estilos distintos, lo que crea enfrentamientos (matchups) diferentes.

Bucle de juego

Controlar la distancia, provocar o esperar un error del rival, castigarlo con el golpe adecuado y volver a la distancia segura.

Demo propia

Controles. Contra la IA: flechas o A/D para moverte, Z o F para el golpe rápido, X o G para el fuerte; mantén «atrás» para bloquear. Dos jugadores (tecla 2 en el título): el jugador 1 usa A/D, F y G, y el jugador 2, flechas, K y L. En móvil, los botones en pantalla.

Abrir en pantalla completa

Dos luchadores en vista lateral, un golpe rápido, uno fuerte y bloqueo, con barras de vida y rondas al mejor de tres. Juegas contra una IA simple o contra otra persona en el mismo teclado. Lo justo para sentir lo que define al género, que es controlar la distancia, medir los tiempos de cada golpe y leer lo que hará el rival.

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

Cómo está hecha

  1. Configuración y controles

    Se definen dos juegos de botones virtuales, uno por jugador, para que dos personas compartan teclado. Contra la IA, el jugador humano puede usar cualquiera de los dos. Retroceder es más lento que avanzar, porque bloquear no debe salir gratis. La constante REACTION_TIME fija cuánto tarda la IA en notar lo que haces.

    const k = kaplay({
      width: 640, height: 360, letterbox: true, pixelDensity, background: [36, 28, 52],
      // Player 1: A/D, F or Z (quick), G or X (heavy). Player 2: arrows, K and L.
      buttons: {
        left1: { keyboard: ["a"] }, right1: { keyboard: ["d"] },
        quick1: { keyboard: ["f", "z"] }, heavy1: { keyboard: ["g", "x"] },
        left2: { keyboard: ["left"] }, right2: { keyboard: ["right"] },
        quick2: { keyboard: ["k"] }, heavy2: { keyboard: ["l"] },
      },
    });
    touchControls(k, [
      { button: "left1", label: "◀" }, { button: "right1", label: "▶" },
      { button: "quick1", label: "R", side: "right" }, { button: "heavy1", label: "F" },
    ]);
    
    const GROUND = 320;
    const BODY_WIDTH = 40; // width of each fighter's body
    const SPEED = 170;
    const BACK_SPEED = 110; // walking back is slower: blocking has a cost
    const REACTION_TIME = 0.22; // seconds the AI takes to "see" what you do
  2. Las tres fases de un golpe

    Todo golpe pasa por tres fases. En el arranque (startup) se anuncia pero aún no pega; en la fase activa puede pegar; en la recuperación (recovery) sigue estirado y no puedes hacer nada. El golpe rápido arranca y se recupera pronto, pero llega cerca. El fuerte llega lejos y quita más del doble, pero tarda en salir y, si falla, te deja medio segundo expuesto. Ningún golpe es mejor en todo, y elegir cuál usar según la distancia es la base del género.

    // Every attack has three phases measured in seconds (real games use frames):
    // startup: it is telegraphed but cannot hit yet; active: it can hit;
    // recovery: the arm is still out and you cannot do anything.
    // The quick attack hits early and recovers early, but has short reach.
    // The heavy one reaches far and hurts a lot, but leaves you exposed if it misses.
    const ATTACKS = {
      quick: { startup: 0.08, active: 0.08, recovery: 0.18, reach: 50, damage: 7, stun: 0.25 },
      heavy: { startup: 0.26, active: 0.1, recovery: 0.5, reach: 95, damage: 16, stun: 0.45 },
    };
  3. Luchadores y mandos

    Cada luchador guarda su vida, su estado y su golpe actual, y recibe una función readInput() que devuelve las órdenes del momento (izquierda, derecha, golpe rápido, golpe fuerte). Para una persona, readInput() lee botones; para la IA, decide. Como el resto del código solo pregunta a readInput(), el mismo luchador sirve para un jugador, para dos o para la máquina.

    function makeFighter(x, col, readInput) {
      return add([
        rect(BODY_WIDTH, 90), pos(x, GROUND), anchor("bot"), color(col),
        { hp: 100, state: "free", timer: 0, attack: null, hasHit: false, dir: 1, blocking: false, readInput },
      ]);
    }
    // A human controller reads buttons; the AI returns the same orders (see "ai").
    const humanInput = (n) => () => ({
      left: isButtonDown(n.map((i) => "left" + i)),
      right: isButtonDown(n.map((i) => "right" + i)),
      quick: isButtonPressed(n.map((i) => "quick" + i)),
      heavy: isButtonPressed(n.map((i) => "heavy" + i)),
    });
  4. Una IA que lee con retraso

    La IA mira tu estado cada 0,22 segundos, que es aproximadamente lo que tarda una persona en reaccionar. Si te ve arrancar un golpe cerca, a veces bloquea; si te ve en recuperación, te castiga; si no, se mantiene a una distancia media y a veces tantea. Por ese retraso, un golpe rápido le cuesta verlo y un golpe fuerte fallado lo aprovecha casi siempre, igual que un rival humano.

    // The AI keeps a safe distance, sometimes blocks when it sees you attack and
    // punishes when it sees you in recovery. It reacts with a delay, like a person,
    // so it struggles to see a quick attack and exploits a missed heavy one.
    function aiInput(f, rival) {
      const ai = { seen: "free", clock: 0, willBlock: false };
      return () => {
        ai.clock -= dt();
        if (ai.clock <= 0) {
          ai.clock = REACTION_TIME;
          ai.seen = rival.state;
          ai.willBlock = chance(0.6);
        }
        const dist = Math.abs(rival.pos.x - f.pos.x) - BODY_WIDTH;
        const o = { left: false, right: false, quick: false, heavy: false };
        const forward = f.dir > 0 ? "right" : "left";
        const back = f.dir > 0 ? "left" : "right";
        const pick = () => (dist < ATTACKS.quick.reach ? "quick" : "heavy");
        if ((ai.seen === "startup" || ai.seen === "active") && dist < 110 && ai.willBlock) o[back] = true;
        else if (ai.seen === "recovery" && dist < ATTACKS.heavy.reach) o[pick()] = true;
        else if (dist > 85) o[forward] = true;
        else if (dist < 35) o[back] = true;
        else if (chance(dt() * 1.4)) o[pick()] = true;
        return o;
      };
    }
  5. La máquina de estados

    Cada luchador está siempre en un estado: libre (free), arranque (startup), activo (active), recuperación (recovery), aturdido (stunned) o bloqueo (blockstun). Solo en «libre» puede moverse y atacar. Mantener la dirección contraria al rival es bloquear. Esta máquina de estados es el corazón técnico de todo juego de lucha: decide qué puedes hacer en cada instante y cuánto te comprometes al pulsar un botón.

    // Each fighter's state machine: free → startup → active → recovery → free.
    // Taking a hit leaves it "stunned" and blocking one, in "blockstun": in both it cannot act.
    function startAttack(f, type) {
      Object.assign(f, { attack: ATTACKS[type], state: "startup", timer: ATTACKS[type].startup, hasHit: false });
    }
    function updateFighter(f, rival) {
      f.dir = rival.pos.x > f.pos.x ? 1 : -1;
      f.timer -= dt();
      const o = f.readInput();
      if (f.state === "free") {
        const dx = (o.right ? 1 : 0) - (o.left ? 1 : 0);
        f.blocking = dx === -f.dir; // holding "back" means blocking
        f.pos.x += dx * (f.blocking ? BACK_SPEED : SPEED) * dt();
        if (o.quick) startAttack(f, "quick");
        else if (o.heavy) startAttack(f, "heavy");
      } else if (f.state === "startup" && f.timer <= 0) {
        f.state = "active";
        f.timer = f.attack.active;
      } else if (f.state === "active") {
        checkHit(f, rival);
        if (f.timer <= 0) {
          f.state = "recovery";
          f.timer = f.attack.recovery;
        }
      } else if (f.timer <= 0 && f.state !== "free") f.state = "free";
      f.pos.x = clamp(f.pos.x, 30, width() - 30);
    }
  6. Impacto, bloqueo y castigo

    El golpe acierta si el tramo del brazo toca el cuerpo del rival durante la fase activa. Si el rival bloqueaba, no pierde vida y solo retrocede un poco. Si estaba en arranque o en recuperación, el juego lo marca como «castigo». Esa es la lectura del rival, porque provocar que el otro ataque fuera de alcance y pegarle mientras se recupera es la forma más segura de ganar.

    // The attack connects if the arm's span touches the rival's body. If the
    // rival was blocking, it loses no health. Hitting it during startup or
    // recovery is a punish: that is how you win when the other one takes a risk and misses.
    function checkHit(f, rival) {
      if (f.hasHit) return;
      const front = f.pos.x + f.dir * (BODY_WIDTH / 2);
      const tip = front + f.dir * f.attack.reach;
      const [a, b] = [Math.min(front, tip), Math.max(front, tip)];
      if (b < rival.pos.x - BODY_WIDTH / 2 || a > rival.pos.x + BODY_WIDTH / 2) return;
      f.hasHit = true;
      const isBlocking = (rival.state === "free" && rival.blocking) || rival.state === "blockstun";
      if (isBlocking) {
        Object.assign(rival, { state: "blockstun", timer: f.attack.stun * 0.6 });
        rival.pos.x += f.dir * 10;
        announce(t({ es: "Bloqueo", en: "Blocked" }), 0.4);
        return;
      }
      if (rival.state === "startup" || rival.state === "recovery") announce(t({ es: "¡Castigo!", en: "Punish!" }));
      rival.hp = Math.max(0, rival.hp - f.attack.damage);
      Object.assign(rival, { state: "stunned", timer: f.attack.stun });
      rival.pos.x += f.dir * 22;
      shake(f.attack.damage / 3);
    }
  7. Rondas y espaciado

    Cada ronda empieza con los luchadores en su sitio y termina cuando una barra de vida llega a cero; gana el primero en llevarse dos. Los cuerpos no se atraviesan y se empujan al tocarse, así que la distancia entre los dos, el espaciado, es un recurso que se gana y se pierde paso a paso.

    // Best of three rounds. At the start of each one, the fighters go back to their places.
    const wins = [0, 0];
    let [round, paused] = [0, true];
    function startRound() {
      round++;
      [p1.pos.x, p2.pos.x] = [200, 440];
      for (const f of [p1, p2]) Object.assign(f, { hp: 100, state: "free", timer: 0, attack: null });
      announce(t({ es: `Ronda ${round}`, en: `Round ${round}` }), 1);
      wait(1, () => {
        announce(t({ es: "¡Pelea!", en: "Fight!" }), 0.6);
        paused = false;
      });
    }
    startRound();
    onUpdate(() => {
      if (paused) return;
      updateFighter(p1, p2);
      updateFighter(p2, p1);
      // Bodies cannot pass through each other: if they touch, both get pushed.
      const overlap = BODY_WIDTH - Math.abs(p2.pos.x - p1.pos.x);
      if (overlap > 0) {
        p1.pos.x -= p1.dir * overlap / 2;
        p2.pos.x -= p2.dir * overlap / 2;
      }
      const loser = [p1, p2].findIndex((f) => f.hp <= 0);
      if (loser < 0) return;
      paused = true;
      const winner = 1 - loser;
      wins[winner]++;
      announce(t({ es: `K.O. Gana ${names[winner]}`, en: `K.O. ${names[winner]} wins` }), 2);
      wait(2, () => (wins[winner] >= 2 ? go("end", names[winner]) : startRound()));
    });
  8. Mostrar las fases

    El brazo se pinta amarillo en el arranque, rojo cuando puede pegar y gris en la recuperación; el escudo celeste indica bloqueo y el destello blanco, que te golpearon. En un juego real esa información la dan las animaciones, y los modos de entrenamiento la muestran así de explícita para aprender. Arriba están las barras de vida y un punto por cada ronda ganada.

    // Draw the arm in the colour of its phase, the shield when blocking, and the HUD.
    // Seeing the phases on screen helps you learn when it is safe to attack.
    const PHASE_COLOR = { startup: rgb(255, 220, 80), active: rgb(255, 60, 60), recovery: rgb(150, 150, 170) };
    onDraw(() => {
      for (const f of [p1, p2]) {
        const front = vec2(f.pos.x + f.dir * (BODY_WIDTH / 2), GROUND - 66);
        const side = f.dir > 0 ? "left" : "right";
        if (f.attack && PHASE_COLOR[f.state]) {
          const length = f.state === "startup" ? 12 : f.attack.reach;
          drawRect({ pos: front, width: length, height: 12, anchor: side, color: PHASE_COLOR[f.state] });
        }
        if (f.state === "blockstun" || (f.state === "free" && f.blocking))
          drawRect({ pos: front.add(f.dir * 6, 20), width: 8, height: 70, anchor: "center", color: rgb(140, 230, 255) });
        if (f.state === "stunned") drawRect({ pos: f.pos, width: BODY_WIDTH, height: 90, anchor: "bot", color: WHITE, opacity: 0.4 });
      }
      // Health bars and rounds won (one dot per round).
      [p1, p2].forEach((f, i) => {
        const x = i === 0 ? 20 : width() - 20;
        const side = i === 0 ? "left" : "right";
        drawRect({ pos: vec2(x, 20), width: 260, height: 16, anchor: side, color: rgb(60, 40, 60) });
        drawRect({ pos: vec2(x, 20), width: 2.6 * f.hp, height: 16, anchor: side, color: f.color });
        drawText({ text: names[i] + "  " + "●".repeat(wins[i]), pos: vec2(x, 44), size: 14, font: FONT, anchor: side });
      });
      drawText({
        text: t({ es: "amarillo: arranque · rojo: golpe activo · gris: recuperación", en: "yellow: startup · red: active hit · grey: recovery" }),
        pos: vec2(width() / 2, GROUND + 22), size: 12, font: FONT, anchor: "center", color: rgb(200, 190, 220),
      });
    });
  9. Escenas

    El juego tiene tres escenas (title, game y end) y go() salta de una a otra. La escena de juego recibe si se juega contra la IA o entre dos personas, y crea los mandos según eso.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]);
      add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 20)), anchor("center"), color(220, 210, 240)]);
      onKeyPress(["space", "enter"], () => next(false));
      onClick(() => next(false));
      onKeyPress("2", () => next(true));
    }
    
    scene("title", () =>
      screen(
        t({ es: "Lucha", en: "Fighting" }),
        t({
          es: "Flechas o A/D para moverte; Z o F, golpe rápido; X o G, golpe fuerte.\nMantén «atrás» (alejarte del rival) para bloquear.\nDos jugadores: J1 con A/D F G, J2 con flechas K L.\nEspacio o clic: contra la IA · tecla 2: dos jugadores",
          en: "Arrows or A/D to move; Z or F, quick attack; X or G, heavy attack.\nHold «back» (away from the opponent) to block.\nTwo players: P1 with A/D F G, P2 with arrows K L.\nSpace or click: against the CPU · key 2: two players",
        }),
        (two) => go("game", two),
      ),
    );
    
    scene("end", (winner) =>
      screen(
        t({ es: `Gana ${winner}`, en: `${winner} wins` }),
        t({ es: "Espacio o clic para la revancha contra la IA · tecla 2: dos jugadores", en: "Space or click for a rematch against the CPU · key 2: two players" }),
        (two) => go("game", two),
      ),
    );
    
    go("title");

Qué simplificamos

  • Los luchadores son rectángulos y los golpes, un brazo que cambia de color según su fase. No hay animaciones, sonido, salto ni agacharse.
  • Solo hay golpes de una altura, así que el bloqueo siempre funciona; los juegos reales suman golpes bajos y agarres que obligan a adivinar cómo bloquear.
  • No hay combos, movimientos especiales, tiempo límite por ronda ni personajes distintos.
  • Los tiempos se miden en segundos; los juegos de lucha los miden en fotogramas (a 60 por segundo, 0,1 s son 6 fotogramas).

Para modificarla

  • Juega diez rondas usando solo el golpe fuerte y otras diez solo con el rápido. Anota cuál gana más contra la IA y por qué.
  • Baja la recuperación del golpe fuerte a 0,2 s y comprueba si la IA todavía puede castigarlo. ¿Qué pasa con el equilibrio?
  • Añade agacharse con un golpe bajo que solo se pueda bloquear agachado, para crear un juego de adivinanzas entre alto y bajo.
  • Cambia la constante REACTION_TIME de la IA a 0,1 y a 0,4 y describe cómo cambia la sensación de jugar contra ella.
Código completo
// Fighting 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: [36, 28, 52],
  // Player 1: A/D, F or Z (quick), G or X (heavy). Player 2: arrows, K and L.
  buttons: {
    left1: { keyboard: ["a"] }, right1: { keyboard: ["d"] },
    quick1: { keyboard: ["f", "z"] }, heavy1: { keyboard: ["g", "x"] },
    left2: { keyboard: ["left"] }, right2: { keyboard: ["right"] },
    quick2: { keyboard: ["k"] }, heavy2: { keyboard: ["l"] },
  },
});
touchControls(k, [
  { button: "left1", label: "◀" }, { button: "right1", label: "▶" },
  { button: "quick1", label: "R", side: "right" }, { button: "heavy1", label: "F" },
]);

const GROUND = 320;
const BODY_WIDTH = 40; // width of each fighter's body
const SPEED = 170;
const BACK_SPEED = 110; // walking back is slower: blocking has a cost
const REACTION_TIME = 0.22; // seconds the AI takes to "see" what you do
// #endregion

// #region attacks
// Every attack has three phases measured in seconds (real games use frames):
// startup: it is telegraphed but cannot hit yet; active: it can hit;
// recovery: the arm is still out and you cannot do anything.
// The quick attack hits early and recovers early, but has short reach.
// The heavy one reaches far and hurts a lot, but leaves you exposed if it misses.
const ATTACKS = {
  quick: { startup: 0.08, active: 0.08, recovery: 0.18, reach: 50, damage: 7, stun: 0.25 },
  heavy: { startup: 0.26, active: 0.1, recovery: 0.5, reach: 95, damage: 16, stun: 0.45 },
};
// #endregion

// #region fighters
function makeFighter(x, col, readInput) {
  return add([
    rect(BODY_WIDTH, 90), pos(x, GROUND), anchor("bot"), color(col),
    { hp: 100, state: "free", timer: 0, attack: null, hasHit: false, dir: 1, blocking: false, readInput },
  ]);
}
// A human controller reads buttons; the AI returns the same orders (see "ai").
const humanInput = (n) => () => ({
  left: isButtonDown(n.map((i) => "left" + i)),
  right: isButtonDown(n.map((i) => "right" + i)),
  quick: isButtonPressed(n.map((i) => "quick" + i)),
  heavy: isButtonPressed(n.map((i) => "heavy" + i)),
});
// #endregion

// #region ai
// The AI keeps a safe distance, sometimes blocks when it sees you attack and
// punishes when it sees you in recovery. It reacts with a delay, like a person,
// so it struggles to see a quick attack and exploits a missed heavy one.
function aiInput(f, rival) {
  const ai = { seen: "free", clock: 0, willBlock: false };
  return () => {
    ai.clock -= dt();
    if (ai.clock <= 0) {
      ai.clock = REACTION_TIME;
      ai.seen = rival.state;
      ai.willBlock = chance(0.6);
    }
    const dist = Math.abs(rival.pos.x - f.pos.x) - BODY_WIDTH;
    const o = { left: false, right: false, quick: false, heavy: false };
    const forward = f.dir > 0 ? "right" : "left";
    const back = f.dir > 0 ? "left" : "right";
    const pick = () => (dist < ATTACKS.quick.reach ? "quick" : "heavy");
    if ((ai.seen === "startup" || ai.seen === "active") && dist < 110 && ai.willBlock) o[back] = true;
    else if (ai.seen === "recovery" && dist < ATTACKS.heavy.reach) o[pick()] = true;
    else if (dist > 85) o[forward] = true;
    else if (dist < 35) o[back] = true;
    else if (chance(dt() * 1.4)) o[pick()] = true;
    return o;
  };
}
// #endregion

scene("game", (twoPlayers) => {
  add([rect(width(), height() - GROUND), pos(0, GROUND), color(70, 56, 90)]);
  const p1 = makeFighter(200, rgb(90, 170, 255), humanInput(twoPlayers ? [1] : [1, 2]));
  const p2 = makeFighter(440, rgb(255, 110, 90), null);
  p2.readInput = twoPlayers ? humanInput([2]) : aiInput(p2, p1);
  const names = [t({ es: "Jugador 1", en: "Player 1" }), twoPlayers ? t({ es: "Jugador 2", en: "Player 2" }) : t({ es: "IA", en: "CPU" })];
  const banner = add([text("", { size: 30, font: FONT }), pos(width() / 2, 140), anchor("center"), z(10)]);
  const announce = (txt, s = 0.7) => {
    banner.text = txt;
    wait(s, () => banner.text === txt && (banner.text = ""));
  };

  // #region states
  // Each fighter's state machine: free → startup → active → recovery → free.
  // Taking a hit leaves it "stunned" and blocking one, in "blockstun": in both it cannot act.
  function startAttack(f, type) {
    Object.assign(f, { attack: ATTACKS[type], state: "startup", timer: ATTACKS[type].startup, hasHit: false });
  }
  function updateFighter(f, rival) {
    f.dir = rival.pos.x > f.pos.x ? 1 : -1;
    f.timer -= dt();
    const o = f.readInput();
    if (f.state === "free") {
      const dx = (o.right ? 1 : 0) - (o.left ? 1 : 0);
      f.blocking = dx === -f.dir; // holding "back" means blocking
      f.pos.x += dx * (f.blocking ? BACK_SPEED : SPEED) * dt();
      if (o.quick) startAttack(f, "quick");
      else if (o.heavy) startAttack(f, "heavy");
    } else if (f.state === "startup" && f.timer <= 0) {
      f.state = "active";
      f.timer = f.attack.active;
    } else if (f.state === "active") {
      checkHit(f, rival);
      if (f.timer <= 0) {
        f.state = "recovery";
        f.timer = f.attack.recovery;
      }
    } else if (f.timer <= 0 && f.state !== "free") f.state = "free";
    f.pos.x = clamp(f.pos.x, 30, width() - 30);
  }
  // #endregion

  // #region hit
  // The attack connects if the arm's span touches the rival's body. If the
  // rival was blocking, it loses no health. Hitting it during startup or
  // recovery is a punish: that is how you win when the other one takes a risk and misses.
  function checkHit(f, rival) {
    if (f.hasHit) return;
    const front = f.pos.x + f.dir * (BODY_WIDTH / 2);
    const tip = front + f.dir * f.attack.reach;
    const [a, b] = [Math.min(front, tip), Math.max(front, tip)];
    if (b < rival.pos.x - BODY_WIDTH / 2 || a > rival.pos.x + BODY_WIDTH / 2) return;
    f.hasHit = true;
    const isBlocking = (rival.state === "free" && rival.blocking) || rival.state === "blockstun";
    if (isBlocking) {
      Object.assign(rival, { state: "blockstun", timer: f.attack.stun * 0.6 });
      rival.pos.x += f.dir * 10;
      announce(t({ es: "Bloqueo", en: "Blocked" }), 0.4);
      return;
    }
    if (rival.state === "startup" || rival.state === "recovery") announce(t({ es: "¡Castigo!", en: "Punish!" }));
    rival.hp = Math.max(0, rival.hp - f.attack.damage);
    Object.assign(rival, { state: "stunned", timer: f.attack.stun });
    rival.pos.x += f.dir * 22;
    shake(f.attack.damage / 3);
  }
  // #endregion

  // #region rounds
  // Best of three rounds. At the start of each one, the fighters go back to their places.
  const wins = [0, 0];
  let [round, paused] = [0, true];
  function startRound() {
    round++;
    [p1.pos.x, p2.pos.x] = [200, 440];
    for (const f of [p1, p2]) Object.assign(f, { hp: 100, state: "free", timer: 0, attack: null });
    announce(t({ es: `Ronda ${round}`, en: `Round ${round}` }), 1);
    wait(1, () => {
      announce(t({ es: "¡Pelea!", en: "Fight!" }), 0.6);
      paused = false;
    });
  }
  startRound();
  onUpdate(() => {
    if (paused) return;
    updateFighter(p1, p2);
    updateFighter(p2, p1);
    // Bodies cannot pass through each other: if they touch, both get pushed.
    const overlap = BODY_WIDTH - Math.abs(p2.pos.x - p1.pos.x);
    if (overlap > 0) {
      p1.pos.x -= p1.dir * overlap / 2;
      p2.pos.x -= p2.dir * overlap / 2;
    }
    const loser = [p1, p2].findIndex((f) => f.hp <= 0);
    if (loser < 0) return;
    paused = true;
    const winner = 1 - loser;
    wins[winner]++;
    announce(t({ es: `K.O. Gana ${names[winner]}`, en: `K.O. ${names[winner]} wins` }), 2);
    wait(2, () => (wins[winner] >= 2 ? go("end", names[winner]) : startRound()));
  });
  // #endregion

  // #region drawing
  // Draw the arm in the colour of its phase, the shield when blocking, and the HUD.
  // Seeing the phases on screen helps you learn when it is safe to attack.
  const PHASE_COLOR = { startup: rgb(255, 220, 80), active: rgb(255, 60, 60), recovery: rgb(150, 150, 170) };
  onDraw(() => {
    for (const f of [p1, p2]) {
      const front = vec2(f.pos.x + f.dir * (BODY_WIDTH / 2), GROUND - 66);
      const side = f.dir > 0 ? "left" : "right";
      if (f.attack && PHASE_COLOR[f.state]) {
        const length = f.state === "startup" ? 12 : f.attack.reach;
        drawRect({ pos: front, width: length, height: 12, anchor: side, color: PHASE_COLOR[f.state] });
      }
      if (f.state === "blockstun" || (f.state === "free" && f.blocking))
        drawRect({ pos: front.add(f.dir * 6, 20), width: 8, height: 70, anchor: "center", color: rgb(140, 230, 255) });
      if (f.state === "stunned") drawRect({ pos: f.pos, width: BODY_WIDTH, height: 90, anchor: "bot", color: WHITE, opacity: 0.4 });
    }
    // Health bars and rounds won (one dot per round).
    [p1, p2].forEach((f, i) => {
      const x = i === 0 ? 20 : width() - 20;
      const side = i === 0 ? "left" : "right";
      drawRect({ pos: vec2(x, 20), width: 260, height: 16, anchor: side, color: rgb(60, 40, 60) });
      drawRect({ pos: vec2(x, 20), width: 2.6 * f.hp, height: 16, anchor: side, color: f.color });
      drawText({ text: names[i] + "  " + "●".repeat(wins[i]), pos: vec2(x, 44), size: 14, font: FONT, anchor: side });
    });
    drawText({
      text: t({ es: "amarillo: arranque · rojo: golpe activo · gris: recuperación", en: "yellow: startup · red: active hit · grey: recovery" }),
      pos: vec2(width() / 2, GROUND + 22), size: 12, font: FONT, anchor: "center", color: rgb(200, 190, 220),
    });
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 70)), anchor("center")]);
  add([text(subtitle, { size: 15, font: FONT, align: "center", width: 580 }), pos(center().add(0, 20)), anchor("center"), color(220, 210, 240)]);
  onKeyPress(["space", "enter"], () => next(false));
  onClick(() => next(false));
  onKeyPress("2", () => next(true));
}

scene("title", () =>
  screen(
    t({ es: "Lucha", en: "Fighting" }),
    t({
      es: "Flechas o A/D para moverte; Z o F, golpe rápido; X o G, golpe fuerte.\nMantén «atrás» (alejarte del rival) para bloquear.\nDos jugadores: J1 con A/D F G, J2 con flechas K L.\nEspacio o clic: contra la IA · tecla 2: dos jugadores",
      en: "Arrows or A/D to move; Z or F, quick attack; X or G, heavy attack.\nHold «back» (away from the opponent) to block.\nTwo players: P1 with A/D F G, P2 with arrows K L.\nSpace or click: against the CPU · key 2: two players",
    }),
    (two) => go("game", two),
  ),
);

scene("end", (winner) =>
  screen(
    t({ es: `Gana ${winner}`, en: `${winner} wins` }),
    t({ es: "Espacio o clic para la revancha contra la IA · tecla 2: dos jugadores", en: "Space or click for a rematch against the CPU · key 2: two players" }),
    (two) => go("game", two),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Dar a cada golpe un costo claro (arranque, recuperación, alcance) para que ningún botón sirva para todo.
  • Que las animaciones anuncien el golpe lo justo para que el rival pueda leerlo y reaccionar.
  • Equilibrar a personajes muy distintos entre sí sin volverlos iguales.
  • Una IA que juegue con errores creíbles y no solo con reflejos perfectos.
  • Enseñar sistemas profundos a quien empieza sin quitarles profundidad a los expertos.

Historia

  1. 1984
    OrigenKarate Champ

    Karate Champ estableció y popularizó la lucha uno contra uno. Heavyweight Champ (1976) ya tenía peleas a puñetazos, pero se consideraba un juego de deportes.

    Fighting game (Wikipedia)
  2. 1984
    Yie Ar Kung-Fu

    Yie Ar Kung-Fu cambia los puntos de Karate Champ por una barra de vida, que se vuelve el estándar del género, y enfrenta al jugador con rivales de estilos distintos.

    Fighting game (Wikipedia)
  3. 1987
    Street Fighter

    Street Fighter introduce los ataques especiales que se hacen con movimientos de palanca, como el Hadouken.

    Street Fighter (Wikipedia)
  4. 1991
    Street Fighter II: The World Warrior

    Street Fighter II populariza los combos, ofrece ocho luchadores para elegir y hace del duelo entre dos personas el centro de los arcades.

    Street Fighter II (Wikipedia)
  5. 1993
    Virtua Fighter

    Virtua Fighter es el primer juego de lucha de arcade con gráficos de polígonos en 3D y suma la victoria por sacar al rival del ring.

    Virtua Fighter (Wikipedia)
  6. 1999
    Super Smash Bros.

    Super Smash Bros. reúne personajes de varias franquicias de Nintendo y cambia la barra de vida por un porcentaje de daño; se gana sacando al rival del escenario.

    Super Smash Bros. (Wikipedia)
  7. 2018
    FOOTSIES

    FOOTSIES reduce el género a moverse y un botón para enseñar sus fundamentos, que son el espaciado, confirmar el golpe y castigar el golpe fallado.

    HiFight develops his own fighting game... called Footsies (EventHubs)

Juegos de referencia

1984

Karate Champ

Data East. arcade

Dos karatecas se enfrentan con dos palancas cada uno y 24 movimientos; cada golpe limpio da medio punto o uno entero y gana quien llega a dos. Estableció la lucha uno contra uno.

1984

Yie Ar Kung-Fu

Konami. arcade

Te enfrentas a once maestros de artes marciales, cada uno con su aspecto y su estilo. Cambió los puntos por una barra de vida que se vacía con cada golpe.

1987

Street Fighter

Capcom. arcade

Ryu recorre el mundo enfrentando rivales. Introdujo los ataques especiales que se hacen con un movimiento de palanca y un botón, como el Hadouken o el Shoryuken.

1991

Street Fighter II: The World Warrior

Capcom. arcade, Super NES

Ocho luchadores con estilos y movimientos especiales propios. Sus combos, descubiertos por accidente, y el duelo entre dos personas definieron el género y revivieron los arcades.

1993

Virtua Fighter

Sega AM2. arcade

El primer juego de lucha de arcade con personajes de polígonos en 3D. Además de vaciar la vida del rival, puedes ganar sacándolo del ring.

1999

Super Smash Bros.

HAL Laboratory. Nintendo 64

Personajes de Nintendo pelean en escenarios abiertos hasta cuatro a la vez. Cada golpe sube un porcentaje de daño que facilita lanzar al rival fuera del escenario, que es como se gana.

2018

FOOTSIES

HiFight. Windows, iOS, Android

Juego de lucha mínimo, sin salto, con caminar y un solo botón de ataque. Lo hizo un divulgador de la comunidad para practicar lo esencial del género, que es el espaciado, confirmar el golpe y castigar el que falla.

2023

Monster Battlegrounds

Weenter. navegador, Windows, Linux

Juego de lucha 2D independiente, pensado para principiantes, en el que monstruos con movimientos propios pelean uno contra uno. Se juega en el navegador contra la IA o contra otra persona en el mismo teclado.

Ejercicio

Juega la demo contra la IA y luego contra otra persona en el mismo teclado. Cambia la recuperación del golpe fuerte y comprueba cuándo deja de valer la pena usarlo. Después piensa por qué el bloqueo bajo y el golpe alto crean un juego de adivinanzas.

Subgéneros

  • Lucha de plataformasPeleas contra varios rivales en un escenario con plataformas por el que te mueves con libertad; no hay barra de vida, sino un daño acumulado que hace que cada golpe los lance más lejos, y ganas al echarlos fuera de los límites. Lo define esta mecánica, no quiénes son los luchadores.
  • Mascot fighterEliges entre personajes famosos de distintas series, a menudo las mascotas de una compañía, y los enfrentas en un mismo juego de lucha: lo que lo define es ese cruce de franquicias. Casi siempre sigue las reglas de la lucha de plataformas, con varios rivales a la vez y, en vez de vaciar una barra de vida, buscas lanzarlos fuera del escenario.

Fuentes