
Disparos con doble stick
Con un stick mueves a tu personaje y con el otro disparas en cualquier dirección, sin importar hacia dónde caminas, mientras sobrevives a hordas que te rodean vistas desde arriba.
También se llama: twin-stick shooter, shooter de doble stick
Mecánicas que lo definen
- Dos controles independientes, uno para moverse y otro para apuntar y disparar. En PC suelen ser el teclado y el ratón, y en el móvil, dos sticks virtuales.
- Disparo en cualquier dirección, a menudo automático mientras se apunta, que permite retroceder disparando hacia adelante.
- Una arena o unas salas cerradas vistas desde arriba en las que los enemigos llegan desde todos los lados.
- Oleadas de enemigos con conductas distintas, como perseguir, embestir, rodear o disparar desde lejos, que se combinan para presionar.
- Puntuación, vidas y a veces mejoras de arma que premian sobrevivir más tiempo.
Bucle de juego
Moverte para abrir espacio mientras apuntas a la amenaza más urgente, limpiar la oleada y prepararte para la siguiente, que llega más grande.
Demo propia
Controles. WASD para moverte. Apunta con el ratón y mantén el clic para disparar, o dispara con las flechas. En pantalla táctil, arrastra en la mitad izquierda para moverte y en la mitad derecha para disparar.
Abrir en pantalla completaUna arena vista desde arriba en la que te mueves con un control y apuntas con otro, mientras llegan oleadas de enemigos con tres conductas distintas (perseguir, embestir y disparar desde lejos). Sumas puntos por cada enemigo y tienes tres vidas.
Hecha con Kaplay, en unas 200 líneas de JavaScript.
Cómo está hecha
Configuración y botones
Hay dos grupos de botones virtuales: WASD para moverse y las flechas para apuntar. Las flechas funcionan como un segundo «stick» en el teclado, igual que la segunda palanca de Robotron: 2084. Se desactiva touchToMouse porque en una pantalla táctil cada dedo controla un stick distinto y no puede convertirse en un único puntero.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [20, 22, 40], touchToMouse: false, // we read each finger separately (one per stick) buttons: { left: { keyboard: ["a"] }, right: { keyboard: ["d"] }, up: { keyboard: ["w"] }, down: { keyboard: ["s"] }, // The arrow keys are a second "stick" on the keyboard: they aim and fire. aimLeft: { keyboard: ["left"] }, aimRight: { keyboard: ["right"] }, aimUp: { keyboard: ["up"] }, aimDown: { keyboard: ["down"] }, start: { keyboard: ["space", "enter"] }, }, }); const SPEED = 170; const BULLET_SPEED = 480; const FIRE_DELAY = 0.12; // seconds between shots const STICK_RADIUS = 40; // how far a finger must drag to push a virtual stick fully const ARENA = { x: 20, y: 36, w: 600, h: 310 }; function keepInArena(obj) { obj.pos.x = clamp(obj.pos.x, ARENA.x + 10, ARENA.x + ARENA.w - 10); obj.pos.y = clamp(obj.pos.y, ARENA.y + 10, ARENA.y + ARENA.h - 10); }Dos vectores independientes: moverse y apuntar
Es la idea que define al género. moveVector() junta el teclado y el stick izquierdo en un vector que dice hacia dónde caminar, y lo recorta a longitud 1 para que ir en diagonal no sea más rápido. aimVector() devuelve otro vector, de longitud 1, que dice hacia dónde disparar: las flechas, el stick derecho o, con el ratón, la resta entre la posición del cursor y la del jugador. Como son dos vectores separados, puedes retroceder mientras disparas hacia adelante o rodear a un enemigo sin dejar de apuntarle. Un stick táctil vale el arrastre desde donde se apoyó el dedo, dividido por su radio.
// Two independent vectors: where you MOVE and where you AIM. const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0); // Touch: a finger on the left half is the move stick, on the right half the aim stick. // (Called from the game scene: go() clears the input events of the previous scene.) const sticks = { move: null, aim: null }; function listenToSticks() { sticks.move = sticks.aim = null; onTouchStart((p, touch) => { const side = p.x < width() / 2 ? "move" : "aim"; sticks[side] = { id: touch.identifier, origin: p, now: p }; }); onTouchMove((p, touch) => { for (const s of Object.values(sticks)) if (s && s.id === touch.identifier) s.now = p; }); onTouchEnd((_, touch) => { for (const side of ["move", "aim"]) if (sticks[side]?.id === touch.identifier) sticks[side] = null; }); } // A stick's value is the drag from where the finger landed, at most length 1. const stickVec = (s) => (s ? s.now.sub(s.origin).scale(1 / STICK_RADIUS) : vec2(0)); const clampLen = (v) => (v.len() > 1 ? v.unit() : v); function moveVector() { return clampLen(vec2(axis("left", "right"), axis("up", "down")).add(stickVec(sticks.move))); } // Returns a unit vector to fire along, or null if the player is not aiming. function aimVector(player) { const keys = vec2(axis("aimLeft", "aimRight"), axis("aimUp", "aimDown")); if (keys.len() > 0) return keys.unit(); const s = stickVec(sticks.aim); if (s.len() > 0.25) return s.unit(); if (isMouseDown("left")) return mousePos().sub(player.pos).unit(); return null; }El jugador y el disparo
Cada fotograma el jugador avanza según el vector de movimiento y, aparte, guarda el de apuntado. Si estás apuntando y el arma se enfrió (FIRE_DELAY), nace una bala en la punta del cañón con el componente move(), que la desplaza siempre en esa dirección. La línea blanca dibuja el vector de apuntado, para que veas que no depende de hacia dónde caminas. Tras un golpe, el jugador parpadea mientras es invulnerable.
const player = add([circle(10), pos(320, 190), color(120, 220, 255), opacity(1), area(), anchor("center"), z(5), { aim: vec2(1, 0) }]); let cooldown = 0; player.onUpdate(() => { // 1) Movement: the left stick (or WASD) moves, whatever the aim. player.move(moveVector().scale(SPEED)); keepInArena(player); // 2) Aiming: a separate vector; with the mouse it always points at the cursor. const aim = aimVector(player); if (aim) player.aim = aim; else if (!sticks.aim) player.aim = mousePos().sub(player.pos).unit(); cooldown -= dt(); if (aim && cooldown <= 0) { cooldown = FIRE_DELAY; add([circle(3), pos(player.pos.add(aim.scale(14))), color(255, 240, 150), area(), anchor("center"), move(aim, BULLET_SPEED), offscreen({ destroy: true }), "bullet"]); } invulnerable -= dt(); player.opacity = invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.3 : 1; }); // The barrel shows the aim vector, so you can see it is independent of movement. add([z(6), { draw: () => drawLine({ p1: player.pos, p2: player.pos.add(player.aim.scale(20)), width: 4, color: rgb(255, 255, 255) }) }]);Tres conductas de persecución
Cada tipo de enemigo es una regla distinta para elegir su velocidad, y combinarlos obliga a moverse y apuntar a la vez. El perseguidor camina siempre en línea recta hacia ti, como los Grunts de Robotron. El embestidor se detiene, apunta y se lanza en línea recta, de modo que esquivarlo de lado es la respuesta. El tirador mantiene la distancia, te rodea usando el vector perpendicular (normal()) y dispara balas lentas. Cada uno da más puntos cuanto más difícil es.
// Three behaviours, each a different rule for choosing a velocity. const KINDS = { chaser: { points: 10, color: rgb(230, 80, 80), shape: () => rect(18, 18) }, dasher: { points: 20, color: rgb(255, 160, 60), shape: () => polygon([vec2(-11, -11), vec2(11, 0), vec2(-11, 11)]) }, shooter: { points: 30, color: rgb(190, 110, 255), shape: () => circle(11) }, }; function spawnEnemy(kind) { // Spawn on an edge of the arena, never right next to the player. let p; do p = vec2(rand(ARENA.x + 15, ARENA.x + ARENA.w - 15), choose([ARENA.y + 15, ARENA.y + ARENA.h - 15])); while (p.dist(player.pos) < 160); add([KINDS[kind].shape(), pos(p), color(KINDS[kind].color), area(), anchor("center"), rotate(0), "enemy", { kind, timer: rand(0.5, 1.5), vel: vec2(0) }]); } onUpdate("enemy", (e) => { const toPlayer = player.pos.sub(e.pos); e.timer -= dt(); if (e.kind === "chaser") { // Chaser: always walks straight at you. e.move(toPlayer.unit().scale(70 + wave * 5)); } else if (e.kind === "dasher") { // Dasher: stops, takes aim, then charges in a straight line. if (e.timer <= 0) { e.vel = toPlayer.unit().scale(320); e.timer = 1.6; } if (e.timer < 1.1) e.vel = e.vel.scale(0.94); // brakes after the dash e.move(e.vel); e.angle = toPlayer.angle(); } else { // Shooter: keeps its distance, circles around you and fires slow bullets. const keepAway = toPlayer.len() < 170 ? -1 : 1; e.move(toPlayer.unit().scale(60 * keepAway).add(toPlayer.normal().unit().scale(50))); if (e.timer <= 0) { e.timer = 2; add([circle(5), pos(e.pos), color(220, 140, 255), area(), anchor("center"), move(toPlayer.unit(), 140), offscreen({ destroy: true }), "enemyBullet"]); } } keepInArena(e); }); onCollide("bullet", "enemy", (b, e) => { destroy(b); destroy(e); score += KINDS[e.kind].points; updateHud(); });Vidas e invulnerabilidad
Tocar a un enemigo o una bala enemiga quita una vida y da 1,5 segundos de invulnerabilidad, para que un mismo grupo no te quite varias vidas de golpe. La pantalla tiembla para que el golpe se note. Sin vidas, la partida termina.
function hurt() { if (invulnerable > 0) return; lives--; invulnerable = 1.5; shake(6); updateHud(); if (lives <= 0) go("end", score, wave); } player.onCollideUpdate("enemy", hurt); // every frame while touching player.onCollide("enemyBullet", (b) => { destroy(b); hurt(); });Oleadas
Cada oleada trae más enemigos (3 + 2 por oleada) y suma una conducta nueva. En la primera solo hay perseguidores, en la segunda llegan los embestidores y desde la tercera, los tiradores. Los enemigos aparecen escalonados en los bordes y nunca junto al jugador, y la siguiente oleada empieza cuando no queda ninguno.
// Each wave is bigger and adds a new behaviour: chasers, then dashers, then shooters. const banner = add([text("", { size: 26, font: FONT }), pos(center()), anchor("center"), z(10)]); function nextWave() { wave++; updateHud(); banner.text = t({ es: `Oleada ${wave}`, en: `Wave ${wave}` }); wait(1.2, () => { banner.text = ""; for (let i = 0; i < 3 + wave * 2; i++) { const kinds = ["chaser", "dasher", "shooter"].slice(0, Math.min(3, wave)); wait(i * 0.25, () => spawnEnemy(choose(kinds))); } wait((3 + wave * 2) * 0.25 + 0.5, () => (waiting = false)); }); } let waiting = true; nextWave(); onUpdate(() => { if (!waiting && get("enemy").length === 0) { waiting = true; nextWave(); } });Sticks virtuales en pantalla táctil
En la pantalla táctil no hay palancas, así que el stick aparece donde apoyas el dedo. El primer dedo en la mitad izquierda es el de moverse y el de la mitad derecha, el de apuntar; cada uno se reconoce por su identifier. Se dibujan un círculo base y una perilla que sigue al dedo hasta el borde.
// Draw each virtual stick where the finger landed. add([ z(9), { draw() { for (const s of Object.values(sticks)) { if (!s) continue; drawCircle({ pos: s.origin, radius: STICK_RADIUS, color: rgb(255, 255, 255), opacity: 0.15 }); drawCircle({ pos: s.origin.add(clampLen(stickVec(s)).scale(STICK_RADIUS)), radius: 16, color: rgb(255, 255, 255), opacity: 0.4 }); } }, }, ]);Escenas
Tres escenas (title, game y end) con go(). El final muestra la puntuación y la oleada alcanzada, las dos medidas de progreso del género.
function screen(title, subtitle, next) { add([text(title, { size: 32, 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, 30)), anchor("center"), color(200, 210, 255)]); onButtonPress("start", next); onClick(next); onTouchStart(next); } scene("title", () => screen( t({ es: "Disparos con doble stick", en: "Twin-stick shooter" }), t({ es: "WASD para moverte. Apunta con el ratón y mantén el clic para disparar (o dispara con las flechas).\nEn pantalla táctil, arrastra en la mitad izquierda para moverte y en la derecha para disparar.\nPulsa espacio o toca para empezar.", en: "WASD to move. Aim with the mouse and hold the button to fire (or fire with the arrow keys).\nOn a touch screen, drag on the left half to move and on the right half to fire.\nPress space or tap to start.", }), () => go("game"), ), ); scene("end", (score, wave) => screen( t({ es: "Fin de la partida", en: "Game over" }), t({ es: `Puntos: ${score} · Llegaste a la oleada ${wave}.\nPulsa espacio o toca para repetir.`, en: `Score: ${score} · You reached wave ${wave}.\nPress space or tap to play again.`, }), () => go("game"), ), ); go("title");
Qué simplificamos
- Una sola arma de disparo continuo, sin mejoras, bombas ni recarga.
- La arena es un rectángulo vacío; no hay obstáculos ni coberturas que cambien las rutas de los enemigos.
- Las oleadas se generan al azar en los bordes y crecen sin fin, en vez de estar diseñadas a mano.
- Los enemigos pueden superponerse entre sí; no se separan unos de otros.
Para modificarla
- Añade un cuarto enemigo que se divida en dos más pequeños al recibir un disparo.
- Prueba apuntar con el mismo vector que el movimiento (dispara hacia donde caminas) y compara cómo cambia el juego. ¿Por qué el género separa los dos vectores?
- Pon dos o tres obstáculos en la arena que bloqueen las balas y obliguen a los enemigos a rodearlos.
- Haz que los enemigos se separen entre sí sumando a su velocidad un pequeño empuje lejos de sus vecinos.
Código completo
// Twin-stick shooter demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";
// #region setup
const k = kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [20, 22, 40],
touchToMouse: false, // we read each finger separately (one per stick)
buttons: {
left: { keyboard: ["a"] },
right: { keyboard: ["d"] },
up: { keyboard: ["w"] },
down: { keyboard: ["s"] },
// The arrow keys are a second "stick" on the keyboard: they aim and fire.
aimLeft: { keyboard: ["left"] },
aimRight: { keyboard: ["right"] },
aimUp: { keyboard: ["up"] },
aimDown: { keyboard: ["down"] },
start: { keyboard: ["space", "enter"] },
},
});
const SPEED = 170;
const BULLET_SPEED = 480;
const FIRE_DELAY = 0.12; // seconds between shots
const STICK_RADIUS = 40; // how far a finger must drag to push a virtual stick fully
const ARENA = { x: 20, y: 36, w: 600, h: 310 };
function keepInArena(obj) {
obj.pos.x = clamp(obj.pos.x, ARENA.x + 10, ARENA.x + ARENA.w - 10);
obj.pos.y = clamp(obj.pos.y, ARENA.y + 10, ARENA.y + ARENA.h - 10);
}
// #endregion
// #region input
// Two independent vectors: where you MOVE and where you AIM.
const axis = (a, b) => (isButtonDown(b) ? 1 : 0) - (isButtonDown(a) ? 1 : 0);
// Touch: a finger on the left half is the move stick, on the right half the aim stick.
// (Called from the game scene: go() clears the input events of the previous scene.)
const sticks = { move: null, aim: null };
function listenToSticks() {
sticks.move = sticks.aim = null;
onTouchStart((p, touch) => {
const side = p.x < width() / 2 ? "move" : "aim";
sticks[side] = { id: touch.identifier, origin: p, now: p };
});
onTouchMove((p, touch) => {
for (const s of Object.values(sticks)) if (s && s.id === touch.identifier) s.now = p;
});
onTouchEnd((_, touch) => {
for (const side of ["move", "aim"]) if (sticks[side]?.id === touch.identifier) sticks[side] = null;
});
}
// A stick's value is the drag from where the finger landed, at most length 1.
const stickVec = (s) => (s ? s.now.sub(s.origin).scale(1 / STICK_RADIUS) : vec2(0));
const clampLen = (v) => (v.len() > 1 ? v.unit() : v);
function moveVector() {
return clampLen(vec2(axis("left", "right"), axis("up", "down")).add(stickVec(sticks.move)));
}
// Returns a unit vector to fire along, or null if the player is not aiming.
function aimVector(player) {
const keys = vec2(axis("aimLeft", "aimRight"), axis("aimUp", "aimDown"));
if (keys.len() > 0) return keys.unit();
const s = stickVec(sticks.aim);
if (s.len() > 0.25) return s.unit();
if (isMouseDown("left")) return mousePos().sub(player.pos).unit();
return null;
}
// #endregion
scene("game", () => {
listenToSticks();
let score = 0;
let lives = 3;
let wave = 0;
let invulnerable = 0;
const hud = add([text("", { size: 15, font: FONT }), pos(20, 10), z(10)]);
const updateHud = () =>
(hud.text = t({ es: `Puntos: ${score} Vidas: ${lives} Oleada: ${wave}`, en: `Score: ${score} Lives: ${lives} Wave: ${wave}` }));
add([rect(ARENA.w, ARENA.h), pos(ARENA.x, ARENA.y), color(32, 36, 62), outline(2, rgb(90, 100, 160))]);
// #region player
const player = add([circle(10), pos(320, 190), color(120, 220, 255), opacity(1), area(), anchor("center"), z(5), { aim: vec2(1, 0) }]);
let cooldown = 0;
player.onUpdate(() => {
// 1) Movement: the left stick (or WASD) moves, whatever the aim.
player.move(moveVector().scale(SPEED));
keepInArena(player);
// 2) Aiming: a separate vector; with the mouse it always points at the cursor.
const aim = aimVector(player);
if (aim) player.aim = aim;
else if (!sticks.aim) player.aim = mousePos().sub(player.pos).unit();
cooldown -= dt();
if (aim && cooldown <= 0) {
cooldown = FIRE_DELAY;
add([circle(3), pos(player.pos.add(aim.scale(14))), color(255, 240, 150), area(), anchor("center"), move(aim, BULLET_SPEED), offscreen({ destroy: true }), "bullet"]);
}
invulnerable -= dt();
player.opacity = invulnerable > 0 && Math.floor(time() * 12) % 2 ? 0.3 : 1;
});
// The barrel shows the aim vector, so you can see it is independent of movement.
add([z(6), { draw: () => drawLine({ p1: player.pos, p2: player.pos.add(player.aim.scale(20)), width: 4, color: rgb(255, 255, 255) }) }]);
// #endregion
// #region enemies
// Three behaviours, each a different rule for choosing a velocity.
const KINDS = {
chaser: { points: 10, color: rgb(230, 80, 80), shape: () => rect(18, 18) },
dasher: { points: 20, color: rgb(255, 160, 60), shape: () => polygon([vec2(-11, -11), vec2(11, 0), vec2(-11, 11)]) },
shooter: { points: 30, color: rgb(190, 110, 255), shape: () => circle(11) },
};
function spawnEnemy(kind) {
// Spawn on an edge of the arena, never right next to the player.
let p;
do p = vec2(rand(ARENA.x + 15, ARENA.x + ARENA.w - 15), choose([ARENA.y + 15, ARENA.y + ARENA.h - 15]));
while (p.dist(player.pos) < 160);
add([KINDS[kind].shape(), pos(p), color(KINDS[kind].color), area(), anchor("center"), rotate(0), "enemy", { kind, timer: rand(0.5, 1.5), vel: vec2(0) }]);
}
onUpdate("enemy", (e) => {
const toPlayer = player.pos.sub(e.pos);
e.timer -= dt();
if (e.kind === "chaser") {
// Chaser: always walks straight at you.
e.move(toPlayer.unit().scale(70 + wave * 5));
} else if (e.kind === "dasher") {
// Dasher: stops, takes aim, then charges in a straight line.
if (e.timer <= 0) {
e.vel = toPlayer.unit().scale(320);
e.timer = 1.6;
}
if (e.timer < 1.1) e.vel = e.vel.scale(0.94); // brakes after the dash
e.move(e.vel);
e.angle = toPlayer.angle();
} else {
// Shooter: keeps its distance, circles around you and fires slow bullets.
const keepAway = toPlayer.len() < 170 ? -1 : 1;
e.move(toPlayer.unit().scale(60 * keepAway).add(toPlayer.normal().unit().scale(50)));
if (e.timer <= 0) {
e.timer = 2;
add([circle(5), pos(e.pos), color(220, 140, 255), area(), anchor("center"), move(toPlayer.unit(), 140), offscreen({ destroy: true }), "enemyBullet"]);
}
}
keepInArena(e);
});
onCollide("bullet", "enemy", (b, e) => {
destroy(b);
destroy(e);
score += KINDS[e.kind].points;
updateHud();
});
// #endregion
// #region damage
function hurt() {
if (invulnerable > 0) return;
lives--;
invulnerable = 1.5;
shake(6);
updateHud();
if (lives <= 0) go("end", score, wave);
}
player.onCollideUpdate("enemy", hurt); // every frame while touching
player.onCollide("enemyBullet", (b) => {
destroy(b);
hurt();
});
// #endregion
// #region waves
// Each wave is bigger and adds a new behaviour: chasers, then dashers, then shooters.
const banner = add([text("", { size: 26, font: FONT }), pos(center()), anchor("center"), z(10)]);
function nextWave() {
wave++;
updateHud();
banner.text = t({ es: `Oleada ${wave}`, en: `Wave ${wave}` });
wait(1.2, () => {
banner.text = "";
for (let i = 0; i < 3 + wave * 2; i++) {
const kinds = ["chaser", "dasher", "shooter"].slice(0, Math.min(3, wave));
wait(i * 0.25, () => spawnEnemy(choose(kinds)));
}
wait((3 + wave * 2) * 0.25 + 0.5, () => (waiting = false));
});
}
let waiting = true;
nextWave();
onUpdate(() => {
if (!waiting && get("enemy").length === 0) {
waiting = true;
nextWave();
}
});
// #endregion
// #region touch-sticks
// Draw each virtual stick where the finger landed.
add([
z(9),
{
draw() {
for (const s of Object.values(sticks)) {
if (!s) continue;
drawCircle({ pos: s.origin, radius: STICK_RADIUS, color: rgb(255, 255, 255), opacity: 0.15 });
drawCircle({ pos: s.origin.add(clampLen(stickVec(s)).scale(STICK_RADIUS)), radius: 16, color: rgb(255, 255, 255), opacity: 0.4 });
}
},
},
]);
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 32, 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, 30)), anchor("center"), color(200, 210, 255)]);
onButtonPress("start", next);
onClick(next);
onTouchStart(next);
}
scene("title", () =>
screen(
t({ es: "Disparos con doble stick", en: "Twin-stick shooter" }),
t({
es: "WASD para moverte. Apunta con el ratón y mantén el clic para disparar (o dispara con las flechas).\nEn pantalla táctil, arrastra en la mitad izquierda para moverte y en la derecha para disparar.\nPulsa espacio o toca para empezar.",
en: "WASD to move. Aim with the mouse and hold the button to fire (or fire with the arrow keys).\nOn a touch screen, drag on the left half to move and on the right half to fire.\nPress space or tap to start.",
}),
() => go("game"),
),
);
scene("end", (score, wave) =>
screen(
t({ es: "Fin de la partida", en: "Game over" }),
t({
es: `Puntos: ${score} · Llegaste a la oleada ${wave}.\nPulsa espacio o toca para repetir.`,
en: `Score: ${score} · You reached wave ${wave}.\nPress space or tap to play again.`,
}),
() => go("game"),
),
);
go("title");
// #endregionRetos de diseño
- Que mover y apuntar se sientan igual de precisos con mando, con teclado y ratón y con tacto, que son tres esquemas de control muy distintos.
- Diseñar conductas de enemigos que se lean de un vistazo y obliguen a combinar las dos manos, en vez de solo retroceder disparando.
- Mantener legible una pantalla llena de enemigos, balas y efectos.
- Ritmo de oleadas que alterne presión y respiro, y apariciones que avisen para que ningún golpe se sienta injusto.
Historia
- 1975OrigenGun Fight
Gun Fight (1975) ya usaba un mando para moverse y otro para disparar, pero el esquema se hizo famoso con Robotron: 2084 (1982).
Twin-stick shooter (Wikipedia) - 1979
Sheriff, de Nintendo, separa el movimiento (con una palanca de ocho direcciones) del apuntado (con una perilla giratoria, también de ocho direcciones).
Twin-stick shooter (Wikipedia) - 1982Robotron: 2084
Robotron: 2084, de Eugene Jarvis y Larry DeMar, se vuelve el ejemplo fundacional del género y populariza las dos palancas entre los juegos de disparos.
Robotron: 2084 (Wikipedia) - 1990Smash TV
Smash TV, de Eugene Jarvis, retoma el doble stick de Robotron en salas cerradas y para dos jugadores.
Smash TV (Wikipedia) - 2005Geometry Wars: Retro Evolved
Geometry Wars: Retro Evolved revive el género gracias a los mandos con dos sticks analógicos.
Twin-stick shooter (Wikipedia) - 2009Minigore
Minigore lleva el doble stick a iOS y a la pantalla táctil, y logra el aplauso de la crítica y seguidores fieles.
Twin-stick shooter (Wikipedia) - 2011The Binding of Isaac
The Binding of Isaac une el disparo al estilo de doble stick con las salas aleatorias y la muerte permanente de los roguelikes.
The Binding of Isaac (Wikipedia)
Juegos de referencia
1975
Gun Fight
Duelo entre dos pistoleros, cada uno con dos mandos, uno para moverse y otro para elegir hacia dónde dispara, mientras se cubren detrás de obstáculos. Fue el primer videojuego que mostró un combate entre personas.
1982
Robotron: 2084
Con la palanca izquierda te mueves y con la derecha disparas en ocho direcciones contra oleadas sin fin de robots, cada tipo con su conducta, mientras rescatas a los humanos que quedan en la pantalla.
1990
Smash TV
Sala tras sala de un concurso de televisión, uno o dos jugadores se mueven con una palanca y disparan con la otra a multitudes que entran por todas las puertas. Retoma el control de Robotron: 2084, del mismo diseñador.
2005
Geometry Wars: Retro Evolved
Una nave en una arena cerrada de gráficos vectoriales de neón se mueve con un stick analógico y dispara con el otro contra enjambres de figuras geométricas cada vez más densos. Empezó como minijuego en Project Gotham Racing 2 (2003).
2009
Minigore
Sobrevives a hordas de monstruos en un bosque, moviéndote con un stick y disparando con el otro, ambos en la pantalla táctil del teléfono.
2011
The Binding of Isaac
Disparas a monstruos sala por sala en sótanos armados al azar con salas prediseñadas. Perder todos los corazones termina la partida y empiezas en un sótano nuevo; los objetos que encuentras cambian cómo juegas cada vez.
2013
Radius Raid
Shoot 'em up espacial en el que te mueves con el teclado y apuntas con el ratón contra 13 tipos de enemigos. Ganó js13kGames 2013 y su autor lo publicó jugable en el navegador y con código abierto.
2021
Solar Defence
Defiendes un sistema solar de oleadas de naves alienígenas moviéndote con WASD y apuntando y disparando con el ratón; en pantalla táctil, la mitad izquierda mueve y la derecha dispara. Se hizo para js13kGames 2021 y su autor lo publicó jugable en el navegador y con código abierto.
Ejercicio
Juega la demo primero con teclado y ratón y luego solo con el teclado (WASD y flechas). Después modifícala para que dispare siempre hacia donde caminas y compara las dos versiones. Explica con un dibujo de vectores por qué separar movimiento y apuntado cambia lo que puede hacer el jugador.