
Disparos con pistola de luz
Apuntas a la pantalla con un mando con forma de pistola y disparas a los objetivos que aparecen; el reto es reaccionar rápido y acertar antes de que te alcancen.
También se llama: light gun shooter, juego de pistola, pistola de luz, galería de tiro
Mecánicas que lo definen
- Apuntar directamente a la pantalla, de modo que la mira está donde señalas. Hoy cumplen ese papel el ratón, la pantalla táctil o los mandos con sensor de movimiento.
- Blancos que asoman y se esconden, con un tiempo breve para acertarles antes de que disparen.
- Munición limitada y recarga, que se hace disparando fuera de la pantalla o poniéndose a cubierto.
- Civiles o rehenes a los que no debes disparar, que obligan a mirar antes de apretar el gatillo.
- Un recorrido fijo en el que el juego mueve la cámara y tú solo apuntas, y puntos extra por la precisión.
Bucle de juego
Asoma un blanco, decides si es enemigo o civil, apuntas y disparas antes de que te alcance, recargas cuando hay calma y la escena avanza.
Demo propia
Controles. El ratón o el dedo son la pistola; haz clic o toca para disparar. Recarga con clic derecho, con R, disparando al marco negro que rodea la pantalla o, en móvil, con el botón de recarga.
Abrir en pantalla completaUna galería de tiro con cuatro coberturas tras las que asoman figuras de cartón. Las rojas te disparan si tardas y las verdes son civiles. Lo justo para sentir lo que define al género, que es apuntar rápido a la pantalla, distinguir a quién disparar y decidir cuándo recargar.
Hecha con Kaplay, en unas 195 líneas de JavaScript.
Cómo está hecha
Configuración y controles
El ratón o el dedo hacen de pistola, así que casi no hacen falta botones virtuales; solo uno para recargar en móvil. SCREEN es el rectángulo que hace de televisor, y disparar al marco de alrededor equivale a apuntar fuera de la pantalla, como se recargaba con una pistola de luz real. El cargador de seis balas y el tiempo de recarga son los números que más cambian la tensión.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [16, 18, 30], buttons: { reload: { keyboard: ["r"] }, start: { keyboard: ["space", "enter"] }, }, }); touchControls(k, [{ button: "reload", label: "⟳", side: "right" }]); // The mouse (or your finger) is the gun. The inner rectangle is the "screen": // shooting at the frame around it is like pointing the gun away to reload. const SCREEN = { x: 28, y: 28, w: 584, h: 304 }; const GROUND = 262; // y where the floor starts const MAGAZINE = 6; const RELOAD_TIME = 0.4; const RED = rgb(220, 70, 60); const GREEN = rgb(90, 190, 120);El escenario y las coberturas
Las coberturas son rectángulos dibujados por delante de las figuras, así que lo que está detrás queda oculto sin recortar nada. Un disparo que cae sobre una cobertura se detiene en ella. El marco negro se dibuja encima de todo para que nada se vea fuera del televisor.
// Covers are drawn in front of the targets, so a target behind one is hidden // and a shot that lands on a cover stops there. const COVERS = [ { x: 60, y: 222, w: 130, h: 110 }, { x: 215, y: 140, w: 110, h: 96 }, { x: 350, y: 236, w: 120, h: 96 }, { x: 482, y: 160, w: 110, h: 96 }, ]; function buildStage() { add([rect(SCREEN.w, SCREEN.h), pos(SCREEN.x, SCREEN.y), color(52, 58, 84)]); add([rect(SCREEN.w, SCREEN.y + SCREEN.h - GROUND), pos(SCREEN.x, GROUND), color(70, 62, 56)]); for (const c of COVERS) { // A post under the high covers, so they stand on the ground. if (c.y + c.h < GROUND) add([rect(20, GROUND - c.y - c.h), pos(c.x + c.w / 2 - 10, c.y + c.h), color(90, 64, 40), z(1)]); add([rect(c.w, c.h), pos(c.x, c.y), color(150, 110, 70), outline(3, rgb(90, 64, 40)), z(5)]); add([rect(c.w - 16, 6), pos(c.x + 8, c.y + 14), color(120, 86, 54), z(5)]); } // The frame: everything outside SCREEN. It is drawn on top of the whole stage. const frame = rgb(10, 10, 16); add([rect(640, SCREEN.y), color(frame), z(15)]); add([rect(640, 360 - SCREEN.y - SCREEN.h), pos(0, SCREEN.y + SCREEN.h), color(frame), z(15)]); add([rect(SCREEN.x, 360), color(frame), z(15)]); add([rect(640 - SCREEN.x - SCREEN.w, 360), pos(SCREEN.x + SCREEN.w, 0), color(frame), z(15)]); } const inside = (p, r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;Figuras que asoman
Cada figura pasa por tres estados: sube desde detrás de su cobertura, espera a la vista y vuelve a bajar. Las enemigas son rojas y tienen diana; las civiles son verdes y tienen las manos en alto, porque en el género hay que distinguirlas en un instante por el color y la silueta. La barra amarilla sobre la cabeza es la cuenta atrás: si llega a cero, la figura dispara y pierdes una vida. El impacto se comprueba a mano, mirando si el punto cae en la cabeza o en el cuerpo y no sobre una cobertura.
// A target is a cardboard figure that rises from behind a cover, waits and hides again. // Enemies (red, with a bullseye) shoot you if you are too slow; civilians (green, // hands up) must not be shot. function popUp(coverIndex, kind, window) { const c = COVERS[coverIndex]; const hiddenY = c.y + 6; const shownY = c.y - 64; const target = add([pos(c.x + c.w / 2, hiddenY), z(2), "target", { kind, state: "rising", timer: window, window, cover: coverIndex, draw() { const col = kind === "enemy" ? RED : GREEN; drawRect({ pos: vec2(-17, 26), width: 34, height: 52, color: col, outline: { width: 2, color: WHITE } }); drawCircle({ pos: vec2(0, 13), radius: 13, color: col, outline: { width: 2, color: WHITE } }); if (kind === "enemy") { for (const [r, c2] of [[16, WHITE], [10, RED], [5, WHITE]]) drawCircle({ pos: vec2(0, 48), radius: r, color: c2 }); // The bar above the head is the time you have left before it shoots. drawRect({ pos: vec2(-17, -10), width: (34 * this.timer) / this.window, height: 4, color: rgb(255, 220, 80) }); } else { drawRect({ pos: vec2(-24, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } }); drawRect({ pos: vec2(18, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } }); } }, }]); target.onUpdate(() => { if (target.state === "rising") { target.pos.y = Math.max(shownY, target.pos.y - 500 * dt()); if (target.pos.y === shownY) target.state = "up"; } else if (target.state === "up") { target.timer -= dt(); if (target.timer <= 0) { if (kind === "enemy") target.trigger("fire"); target.state = "hiding"; } } else { target.pos.y += 500 * dt(); if (target.pos.y >= hiddenY) destroy(target); } }); return target; } // Hit test by hand: the shot must land on the head or the body, and not on a cover. function hitTarget(p) { if (COVERS.some((c) => inside(p, c))) return null; return get("target").find((g) => { if (g.state === "hiding") return false; const head = p.dist(g.pos.add(0, 13)) <= 13; const body = inside(p, { x: g.pos.x - 17, y: g.pos.y + 26, w: 34, h: 52 }); return head || body; }); }El guion de la ronda
La ronda es un texto. Cada palabra dice quién asoma (E, enemigo; C, civil) y por qué cobertura (0 a 3), y las unidas con + salen a la vez. Es el mismo truco que el mapa de texto del juego de plataformas. Cada paso llega un poco antes y las figuras quedan menos tiempo a la vista, así que la ronda se acelera sola. Si la cobertura de un paso sigue ocupada, el paso espera, porque detrás de cada cobertura hay una sola figura a la vez.
// The round is a text: E = enemy, C = civilian, digit = cover. Tokens joined with + // pop up at the same time. Each step comes a little sooner and stays a little less. const SCRIPT = "E0 E2 E1 C3 E3 E0 E2+E1 C0 E3 E1+C2 E0 E2 E3+E1 C1 E0+E2 E3 C0+E1 E2 E3+E0 E1+E2"; const steps = SCRIPT.split(" ").map((s) => s.split("+")); let step = 0; let next = 1.2; onUpdate(() => { next -= dt(); if (next > 0 || step >= steps.length) return; // One figure per cover at a time: if a cover is busy, the step waits. if (steps[step].some((tok) => get("target").some((g) => g.cover === Number(tok[1])))) return; const window = Math.max(1.3, 2.6 - step * 0.07); for (const tok of steps[step]) { const g = popUp(Number(tok[1]), tok[0] === "E" ? "enemy" : "civilian", window); g.on("fire", () => loseLife()); } step++; next = Math.max(0.9, 1.8 - step * 0.05); });Disparar con precisión
Un clic dentro de la pantalla gasta una bala; uno en el marco recarga. Si aciertas a una enemiga, los puntos dependen de la distancia al centro de la diana (100, 50 o 25), y el marcador lleva la cuenta de tu precisión, aciertos entre disparos. Disparar a una civil resta puntos y cuesta una vida.
// Precision pays: the closer to the centre of the bullseye, the more points. function fire(p) { if (reloading > 0) return; if (ammo === 0) return shake(2); ammo--; stats.shots++; add([circle(5), pos(p), color(255, 240, 160), opacity(1), lifespan(0.12, { fade: 0.1 }), z(18)]); const g = hitTarget(p); if (!g) return; g.state = "hiding"; if (g.kind === "civilian") { stats.civilians++; stats.score = Math.max(0, stats.score - 200); return loseLife(); } stats.hits++; const d = p.dist(g.pos.add(0, 48)); const points = d < 5 ? 100 : d < 10 ? 50 : 25; stats.score += points; add([text(`+${points}`, { size: 16, font: FONT }), pos(p.sub(0, 16)), anchor("center"), opacity(1), lifespan(0.5, { fade: 0.3 }), move(UP, 40), z(18)]); } onMousePress("left", () => { const p = mousePos(); if (inside(p, SCREEN)) fire(p); else reload(); // shooting off-screen reloads, as with a real light gun });La recarga como decisión
Con seis balas no puedes disparar sin pensar. Recargar lleva un instante en el que no puedes disparar, así que conviene hacerlo en un momento de calma y no con un enemigo a la vista. Cuando el cargador se vacía, un aviso parpadeante te lo recuerda.
// With six shots, reloading is a decision: when is it safe to look away? function reload() { if (reloading > 0 || ammo === MAGAZINE) return; reloading = RELOAD_TIME; } onMousePress("right", reload); onButtonPress("reload", reload); onUpdate(() => { if (reloading > 0) { reloading -= dt(); if (reloading <= 0) ammo = MAGAZINE; } warning.text = reloading > 0 ? "…" : ammo === 0 && Math.floor(time() * 3) % 2 ? t({ es: "¡RECARGA!", en: "RELOAD!" }) : ""; });Vidas, marcador y mira
Pierdes una vida si una enemiga llega a disparar o si alcanzas a una civil; la pantalla tiembla y destella en rojo para que lo notes. La ronda se supera al terminar el guion con alguna vida. La mira se dibuja la última, encima de todo, y el cursor del sistema se oculta durante la partida.
function loseLife() { stats.lives--; shake(10); flash(RED, 0.3); if (stats.lives <= 0) go("end", false, stats); } onUpdate(() => { const acc = stats.shots ? Math.round((100 * stats.hits) / stats.shots) : 100; hud.text = t({ es: `Puntos ${stats.score} Vidas ${"♥".repeat(stats.lives)} Precisión ${acc}% Balas ${"▮".repeat(ammo)}`, en: `Score ${stats.score} Lives ${"♥".repeat(stats.lives)} Accuracy ${acc}% Ammo ${"▮".repeat(ammo)}`, }); if (step >= steps.length && get("target").length === 0) go("end", true, stats); }); // The crosshair is drawn last, on top of everything. add([z(30), { draw() { const m = mousePos(); drawCircle({ pos: m, radius: 10, fill: false, outline: { width: 2, color: WHITE } }); drawCircle({ pos: m, radius: 2, color: WHITE }); } }]);Escenas
Hay tres escenas (title, game y end) y go() pasa de una a otra. La pantalla final muestra puntos, precisión y civiles alcanzados, que son las tres cosas que el género mide.
function screen(title, subtitle, next) { setCursor("default"); add([text(title, { size: 32, 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, 30)), anchor("center"), color(200, 210, 255), ]); onButtonPress("start", next); onClick(next); } scene("title", () => screen( t({ es: "Galería de tiro", en: "Shooting gallery" }), t({ es: "Apunta con el ratón o el dedo y dispara a las figuras rojas antes de que te disparen.\nNo dispares a las verdes, que tienen las manos en alto.\nTienes seis balas: recarga con clic derecho, con R o disparando al marco negro.\nCuanto más cerca del centro de la diana, más puntos.\nHaz clic o pulsa espacio para empezar.", en: "Aim with the mouse or your finger and shoot the red figures before they shoot you.\nDo not shoot the green ones with their hands up.\nYou have six shots: reload with right-click, with R or by shooting the black frame.\nThe closer to the centre of the bullseye, the more points.\nClick or press space to start.", }), () => go("game"), ), ); scene("end", (won, s) => { const acc = s.shots ? Math.round((100 * s.hits) / s.shots) : 0; screen( won ? t({ es: "¡Ronda superada!", en: "Round cleared!" }) : t({ es: "Fin de la partida", en: "Game over" }), t({ es: `Puntos: ${s.score} Precisión: ${acc}% Civiles alcanzados: ${s.civilians}\nHaz clic o pulsa espacio para repetir.`, en: `Score: ${s.score} Accuracy: ${acc}% Civilians hit: ${s.civilians}\nClick or press space to play again.`, }), () => go("game"), ); }); go("title");
Qué simplificamos
- La cámara no se mueve; la escena es una sola galería, sin el recorrido sobre raíles de muchos juegos del género.
- Los enemigos no disparan balas visibles; cuando se acaba su tiempo pierdes una vida directamente.
- No hay cobertura para el jugador (como el pedal de Time Crisis) ni disparos a la cabeza o a la mano.
Para modificarla
- Añade una cobertura para el jugador. Mientras mantienes una tecla no recibes disparos ni puedes disparar, y recargas automáticamente, como con el pedal de Time Crisis.
- Haz que las figuras también salgan por un lado de la cobertura, deslizándose en horizontal.
- Crea una figura que necesite dos disparos y piensa cómo mostrar que ya recibió uno.
Código completo
// Light-gun shooter 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: [16, 18, 30],
buttons: {
reload: { keyboard: ["r"] },
start: { keyboard: ["space", "enter"] },
},
});
touchControls(k, [{ button: "reload", label: "⟳", side: "right" }]);
// The mouse (or your finger) is the gun. The inner rectangle is the "screen":
// shooting at the frame around it is like pointing the gun away to reload.
const SCREEN = { x: 28, y: 28, w: 584, h: 304 };
const GROUND = 262; // y where the floor starts
const MAGAZINE = 6;
const RELOAD_TIME = 0.4;
const RED = rgb(220, 70, 60);
const GREEN = rgb(90, 190, 120);
// #endregion
// #region stage
// Covers are drawn in front of the targets, so a target behind one is hidden
// and a shot that lands on a cover stops there.
const COVERS = [
{ x: 60, y: 222, w: 130, h: 110 },
{ x: 215, y: 140, w: 110, h: 96 },
{ x: 350, y: 236, w: 120, h: 96 },
{ x: 482, y: 160, w: 110, h: 96 },
];
function buildStage() {
add([rect(SCREEN.w, SCREEN.h), pos(SCREEN.x, SCREEN.y), color(52, 58, 84)]);
add([rect(SCREEN.w, SCREEN.y + SCREEN.h - GROUND), pos(SCREEN.x, GROUND), color(70, 62, 56)]);
for (const c of COVERS) {
// A post under the high covers, so they stand on the ground.
if (c.y + c.h < GROUND) add([rect(20, GROUND - c.y - c.h), pos(c.x + c.w / 2 - 10, c.y + c.h), color(90, 64, 40), z(1)]);
add([rect(c.w, c.h), pos(c.x, c.y), color(150, 110, 70), outline(3, rgb(90, 64, 40)), z(5)]);
add([rect(c.w - 16, 6), pos(c.x + 8, c.y + 14), color(120, 86, 54), z(5)]);
}
// The frame: everything outside SCREEN. It is drawn on top of the whole stage.
const frame = rgb(10, 10, 16);
add([rect(640, SCREEN.y), color(frame), z(15)]);
add([rect(640, 360 - SCREEN.y - SCREEN.h), pos(0, SCREEN.y + SCREEN.h), color(frame), z(15)]);
add([rect(SCREEN.x, 360), color(frame), z(15)]);
add([rect(640 - SCREEN.x - SCREEN.w, 360), pos(SCREEN.x + SCREEN.w, 0), color(frame), z(15)]);
}
const inside = (p, r) => p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
// #endregion
// #region targets
// A target is a cardboard figure that rises from behind a cover, waits and hides again.
// Enemies (red, with a bullseye) shoot you if you are too slow; civilians (green,
// hands up) must not be shot.
function popUp(coverIndex, kind, window) {
const c = COVERS[coverIndex];
const hiddenY = c.y + 6;
const shownY = c.y - 64;
const target = add([pos(c.x + c.w / 2, hiddenY), z(2), "target", {
kind, state: "rising", timer: window, window, cover: coverIndex,
draw() {
const col = kind === "enemy" ? RED : GREEN;
drawRect({ pos: vec2(-17, 26), width: 34, height: 52, color: col, outline: { width: 2, color: WHITE } });
drawCircle({ pos: vec2(0, 13), radius: 13, color: col, outline: { width: 2, color: WHITE } });
if (kind === "enemy") {
for (const [r, c2] of [[16, WHITE], [10, RED], [5, WHITE]]) drawCircle({ pos: vec2(0, 48), radius: r, color: c2 });
// The bar above the head is the time you have left before it shoots.
drawRect({ pos: vec2(-17, -10), width: (34 * this.timer) / this.window, height: 4, color: rgb(255, 220, 80) });
} else {
drawRect({ pos: vec2(-24, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } });
drawRect({ pos: vec2(18, -2), width: 6, height: 30, color: GREEN, outline: { width: 2, color: WHITE } });
}
},
}]);
target.onUpdate(() => {
if (target.state === "rising") {
target.pos.y = Math.max(shownY, target.pos.y - 500 * dt());
if (target.pos.y === shownY) target.state = "up";
} else if (target.state === "up") {
target.timer -= dt();
if (target.timer <= 0) {
if (kind === "enemy") target.trigger("fire");
target.state = "hiding";
}
} else {
target.pos.y += 500 * dt();
if (target.pos.y >= hiddenY) destroy(target);
}
});
return target;
}
// Hit test by hand: the shot must land on the head or the body, and not on a cover.
function hitTarget(p) {
if (COVERS.some((c) => inside(p, c))) return null;
return get("target").find((g) => {
if (g.state === "hiding") return false;
const head = p.dist(g.pos.add(0, 13)) <= 13;
const body = inside(p, { x: g.pos.x - 17, y: g.pos.y + 26, w: 34, h: 52 });
return head || body;
});
}
// #endregion
scene("game", () => {
buildStage();
setCursor("none");
const stats = { score: 0, shots: 0, hits: 0, civilians: 0, lives: 3 };
let ammo = MAGAZINE;
let reloading = 0;
const hud = add([text("", { size: 15, font: FONT }), pos(36, 6), z(20)]);
const warning = add([text("", { size: 26, font: FONT }), pos(320, 190), anchor("center"), color(255, 230, 90), z(20)]);
// #region script
// The round is a text: E = enemy, C = civilian, digit = cover. Tokens joined with +
// pop up at the same time. Each step comes a little sooner and stays a little less.
const SCRIPT = "E0 E2 E1 C3 E3 E0 E2+E1 C0 E3 E1+C2 E0 E2 E3+E1 C1 E0+E2 E3 C0+E1 E2 E3+E0 E1+E2";
const steps = SCRIPT.split(" ").map((s) => s.split("+"));
let step = 0;
let next = 1.2;
onUpdate(() => {
next -= dt();
if (next > 0 || step >= steps.length) return;
// One figure per cover at a time: if a cover is busy, the step waits.
if (steps[step].some((tok) => get("target").some((g) => g.cover === Number(tok[1])))) return;
const window = Math.max(1.3, 2.6 - step * 0.07);
for (const tok of steps[step]) {
const g = popUp(Number(tok[1]), tok[0] === "E" ? "enemy" : "civilian", window);
g.on("fire", () => loseLife());
}
step++;
next = Math.max(0.9, 1.8 - step * 0.05);
});
// #endregion
// #region shooting
// Precision pays: the closer to the centre of the bullseye, the more points.
function fire(p) {
if (reloading > 0) return;
if (ammo === 0) return shake(2);
ammo--;
stats.shots++;
add([circle(5), pos(p), color(255, 240, 160), opacity(1), lifespan(0.12, { fade: 0.1 }), z(18)]);
const g = hitTarget(p);
if (!g) return;
g.state = "hiding";
if (g.kind === "civilian") {
stats.civilians++;
stats.score = Math.max(0, stats.score - 200);
return loseLife();
}
stats.hits++;
const d = p.dist(g.pos.add(0, 48));
const points = d < 5 ? 100 : d < 10 ? 50 : 25;
stats.score += points;
add([text(`+${points}`, { size: 16, font: FONT }), pos(p.sub(0, 16)), anchor("center"), opacity(1), lifespan(0.5, { fade: 0.3 }), move(UP, 40), z(18)]);
}
onMousePress("left", () => {
const p = mousePos();
if (inside(p, SCREEN)) fire(p);
else reload(); // shooting off-screen reloads, as with a real light gun
});
// #endregion
// #region reload
// With six shots, reloading is a decision: when is it safe to look away?
function reload() {
if (reloading > 0 || ammo === MAGAZINE) return;
reloading = RELOAD_TIME;
}
onMousePress("right", reload);
onButtonPress("reload", reload);
onUpdate(() => {
if (reloading > 0) {
reloading -= dt();
if (reloading <= 0) ammo = MAGAZINE;
}
warning.text = reloading > 0 ? "…" : ammo === 0 && Math.floor(time() * 3) % 2 ? t({ es: "¡RECARGA!", en: "RELOAD!" }) : "";
});
// #endregion
// #region lives-score
function loseLife() {
stats.lives--;
shake(10);
flash(RED, 0.3);
if (stats.lives <= 0) go("end", false, stats);
}
onUpdate(() => {
const acc = stats.shots ? Math.round((100 * stats.hits) / stats.shots) : 100;
hud.text = t({
es: `Puntos ${stats.score} Vidas ${"♥".repeat(stats.lives)} Precisión ${acc}% Balas ${"▮".repeat(ammo)}`,
en: `Score ${stats.score} Lives ${"♥".repeat(stats.lives)} Accuracy ${acc}% Ammo ${"▮".repeat(ammo)}`,
});
if (step >= steps.length && get("target").length === 0) go("end", true, stats);
});
// The crosshair is drawn last, on top of everything.
add([z(30), { draw() { const m = mousePos(); drawCircle({ pos: m, radius: 10, fill: false, outline: { width: 2, color: WHITE } }); drawCircle({ pos: m, radius: 2, color: WHITE }); } }]);
// #endregion
});
// #region scenes
function screen(title, subtitle, next) {
setCursor("default");
add([text(title, { size: 32, 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, 30)), anchor("center"), color(200, 210, 255),
]);
onButtonPress("start", next);
onClick(next);
}
scene("title", () =>
screen(
t({ es: "Galería de tiro", en: "Shooting gallery" }),
t({
es: "Apunta con el ratón o el dedo y dispara a las figuras rojas antes de que te disparen.\nNo dispares a las verdes, que tienen las manos en alto.\nTienes seis balas: recarga con clic derecho, con R o disparando al marco negro.\nCuanto más cerca del centro de la diana, más puntos.\nHaz clic o pulsa espacio para empezar.",
en: "Aim with the mouse or your finger and shoot the red figures before they shoot you.\nDo not shoot the green ones with their hands up.\nYou have six shots: reload with right-click, with R or by shooting the black frame.\nThe closer to the centre of the bullseye, the more points.\nClick or press space to start.",
}),
() => go("game"),
),
);
scene("end", (won, s) => {
const acc = s.shots ? Math.round((100 * s.hits) / s.shots) : 0;
screen(
won ? t({ es: "¡Ronda superada!", en: "Round cleared!" }) : t({ es: "Fin de la partida", en: "Game over" }),
t({
es: `Puntos: ${s.score} Precisión: ${acc}% Civiles alcanzados: ${s.civilians}\nHaz clic o pulsa espacio para repetir.`,
en: `Score: ${s.score} Accuracy: ${acc}% Civilians hit: ${s.civilians}\nClick or press space to play again.`,
}),
() => go("game"),
);
});
go("title");
// #endregionRetos de diseño
- Que enemigos y civiles se distingan en una fracción de segundo, por color, silueta y postura, y no por detalles pequeños.
- Avisar con claridad antes de que un enemigo dispare (una cuenta atrás visible) para que perder una vida se sienta justo.
- Un ritmo de apariciones que alterne la presión con pausas en las que recargar sea una decisión y no un trámite.
- Diseñar para el dispositivo que de verdad tiene el jugador, porque las pistolas de luz clásicas necesitaban televisores de tubo.
Historia
- 1972Origen
La consola Magnavox Odyssey (1972) ya tenía una pistola de luz como accesorio; en los arcades, los primeros fueron Balloon Gun (Sega) y Qwak! (Atari), en 1974. Antes hubo juegos mecánicos con pistola de luz desde los años treinta.
Light-gun shooter (Wikipedia) - 1984Duck Hunt
Duck Hunt sale para la Famicom, y en 1985 acompaña el lanzamiento de la NES en Norteamérica junto con la pistola Zapper.
Duck Hunt (Wikipedia) - 1987Operation Wolf
Operation Wolf, de Taito, populariza los juegos de pistola de temática militar sobre un recorrido fijo.
Light-gun shooter (Wikipedia) - 1994Virtua Cop
Virtua Cop, de Sega, populariza los gráficos de polígonos 3D en los juegos de pistola y abre un renacimiento del género en los arcades.
Light-gun shooter (Wikipedia) - 1995Time Crisis
Time Crisis añade un pedal para ponerse a cubierto y retroceso simulado.
Light-gun shooter (Wikipedia) - 1997The House of the Dead
The House of the Dead inicia la serie de pistola de terror más exitosa.
Light-gun shooter (Wikipedia) - 2006
El mando de Wii, que se apunta hacia la pantalla, trae un pequeño renacimiento del género en las consolas.
Light-gun shooter (Wikipedia) - 2020
La pistola Sinden funciona en monitores y televisores LCD siguiendo un borde blanco que se dibuja alrededor de la pantalla.
Sinden Light Gun (Wikipedia)
Juegos de referencia
1984
Duck Hunt
Apuntas la pistola Zapper a un televisor de tubo y tienes tres disparos por ronda para derribar los patos que salen volando. Fue uno de los juegos de lanzamiento de la NES en Norteamérica.
1987
Operation Wolf
La pistola tiene forma de subfusil y vibra al disparar. La vista avanza sola mientras disparas a soldados y vehículos, sin tocar a enfermeras ni niños, y recargas munición disparando a objetos. Su éxito fijó el modelo de los juegos de pistola de tema militar sobre un recorrido fijo.
1994
Virtua Cop
Juego de pistola con gráficos de polígonos 3D en tiempo real. Tienes un tambor de seis balas, herir a un civil cuesta una vida y desarmar a un enemigo disparándole a la mano da puntos extra.
1995
Time Crisis
Un pedal decide cuándo te pones a cubierto para recargar y evitar el fuego enemigo, y cada sección tiene un tiempo límite. Así esconderse y asomarse se vuelve parte del ritmo.
1997
The House of the Dead
Juego de pistola de terror en el que dos agentes se enfrentan a un ejército de experimentos no muertos creados por un científico loco. Es el inicio de la serie de pistola de terror más exitosa.
2021
Pixel Crisis
Homenaje independiente en 2D a Time Crisis, con gráficos de sprites. Se juega con el ratón y el teclado o con una pistola de luz Sinden, y su autor lo publica gratis y jugable en el navegador. Sigue en desarrollo.
Ejercicio
En la demo, cambia el guion de la ronda (la cadena de letras y números) y el tiempo que cada figura queda a la vista, y busca un ritmo que te obligue a decidir cuándo recargar. Después añade un tercer tipo de figura, por ejemplo una que necesite dos disparos, y comprueba si se distingue de un vistazo.