
Boomer shooter
Recorres a toda velocidad niveles laberínticos llenos de monstruos, cargando a la vez con un arsenal entero, buscando llaves y secretos y recogiendo vida y armadura del suelo. Es un FPS nuevo que imita a propósito los de los noventa, como Doom o Quake: sin recarga lenta ni coberturas, todo depende de moverte y apuntar rápido.
También se llama: shooter retro, disparos clásicos, FPS retro
Esta es una ficha básica: tiene la definición y la clasificación. Las fichas completas suman historia, juegos y una demo.
Demo propia
Controles. Haz clic para capturar el ratón y mirar, WASD o flechas para moverte y desplazarte de lado, clic o espacio para disparar. En móvil, arrastra para mirar y usa los botones en pantalla.
Abrir en pantalla completaUn laberinto en primera persona con monstruos, una llave, una puerta cerrada y la salida. Te mueves rápido, disparas sin recargar y recoges vida del suelo, como en los FPS de los noventa. Está hecha con threeplay, un motor 3D en la línea de Kaplay.
Hecha con threeplay, en unas 155 líneas de JavaScript.
Cómo está hecha
Configuración y controles
Se crea el juego a 640×360 con botones virtuales para moverse y disparar; el disparo responde al ratón y al teclado a la vez. La velocidad es alta a propósito, porque moverse rápido es la mitad del género.
const k = threeplay({ width: 640, height: 360, letterbox: true, background: "#2b1d1d", buttons: { left: { keyboard: ["left", "a"] }, right: { keyboard: ["right", "d"] }, up: { keyboard: ["up", "w"] }, down: { keyboard: ["down", "s"] }, shoot: { keyboard: ["space", "j"], mouse: "left" }, }, }); touchControls(k, [ { button: "left", label: "◀" }, { button: "up", label: "▲" }, { button: "down", label: "▼" }, { button: "right", label: "▶" }, { button: "shoot", label: "✹", side: "right" }, ]); const SPEED = 9; // fast on purpose: speed is half of the genre const TILE = 3;El laberinto como texto
El nivel es un mapa de caracteres y addLevel() lo convierte en objetos sobre el suelo, una casilla de 3 m por carácter. Así se diseñaban los niveles de los noventa, con llaves que obligan a recorrer el mapa y puertas que lo cierran. La niebla esconde lo lejano y da el tono.
// "#" wall, "@" start, "e" enemy, "+" health, "k" key, "D" locked door, "x" exit. const MAP = [ "#############", "#@ # e #", "# # ## #", "# e #k #", "### #### ##", "# + e #", "# ###### ###", "#e # x # #", "# # D e #", "# ####### #", "# e #", "#############", ]; function buildLevel() { let start = vec3(); addLevel(MAP, { tileSize: TILE, tiles: { "#": (p) => [box(TILE, 3.5, TILE), pos(p.x, 1.75, p.z), color("#7f5539"), area(), body({ isStatic: true })], "@": (p) => { start = p.clone(); return []; }, e: (p) => [box(1, 1.6, 1), pos(p.x, 0.8, p.z), rotate(), color("#c1121f"), area(), character(), health(3), "enemy"], "+": (p) => [box(0.6, 0.6, 0.6), pos(p.x, 0.5, p.z), color("#52b788"), area(), "medkit"], k: (p) => [box(0.5, 0.5, 0.15), pos(p.x, 1, p.z), rotate(), color("gold"), area(), "key"], D: (p) => [box(TILE, 3.5, 0.5), pos(p.x, 1.75, p.z), rotate(0, 90, 0), color("#ffd60a"), area(), body({ kinematic: true }), "door"], x: (p) => [cylinder(1, 0.1), pos(p.x, 0.05, p.z), color("#00f5d4"), area(), "exit"], }, }); const w = MAP[0].length * TILE; const d = MAP.length * TILE; add([box(w, 1, d), pos(0, -0.5, 0), color("#3a3a3a"), area(), body({ isStatic: true })]); setFog({ color: "#2b1d1d", near: 4, far: 30 }); return start; }Correr y desplazarse de lado
firstPerson() pone la cámara en los ojos del jugador y gira con el ratón. Como inputDir() es relativo a la cámara, A y D desplazan de lado sin girar, el movimiento que permite esquivar mientras disparas.
const player = add([capsule(0.4, 1.6), pos(start.x, 1, start.z), rotate(), area(), character(), health(10), "player"]); player.hidden = true; // we look out of its eyes firstPerson(player, { height: 0.6, lockOnClick: !isTouchscreen() }); onUpdate(() => { // Strafing: inputDir is relative to where the camera looks, so A/D step sideways. player.move(inputDir().multiplyScalar(SPEED)); });Disparo instantáneo
El disparo es un rayo desde el ojo hacia donde mira la cámara, con raycast(). Lo primero que toca recibe el daño al instante, sin proyectil. El arma y el fogonazo son rectángulos en pantalla que se animan con un tween.
// Hitscan: the shot is a ray from the eye; whatever it hits first takes the damage. const gun = add([uiRect(36, 70, { radius: 4 }), pos(320, 372), anchor("bot"), color("#495057"), fixed()]); const flash = add([uiRect(30, 30, { radius: 15 }), pos(320, 296), anchor("center"), color("#ffd166"), fixed()]); flash.hidden = true; onButtonPress("shoot", () => { flash.hidden = false; wait(0.05, () => (flash.hidden = true)); gun.pos.y = 380; tween(380, 372, 0.12, (y) => (gun.pos.y = y)); beep({ freq: 180, slideTo: 60, duration: 0.1, type: "sawtooth" }); const hit = raycast(camPos(), camera3d.getWorldDirection(vec3()), 60, { exclude: [player] }); if (hit?.obj.is("enemy")) hit.obj.hurt(1); });Monstruos que te ven
Cada enemigo lanza otro rayo hacia el jugador y solo lo persigue si no hay un muro en medio. Tiene health(3), y los eventos hurt y death sirven para el destello blanco y para eliminarlo. Tocarte te quita vida, con un instante de invulnerabilidad para que no se acumule en un solo fotograma.
// An enemy chases only when it can see you: a ray to the player must not hit a wall first. function canSee(enemy) { const from = enemy.worldPos().add(vec3(0, 0.5, 0)); const dir = player.worldPos().sub(from); const hit = raycast(from, dir, 20, { exclude: [enemy] }); return hit?.obj === player; } onUpdate("enemy", (e) => { if (e.distanceTo(player) < 20 && canSee(e)) { e.moveTo(player, 3.5); e.lookAt(vec3(player.pos.x, e.pos.y, player.pos.z)); } }); on("hurt", "enemy", (e) => { e.color.set("white"); wait(0.06, () => e.exists() && e.color.set("#c1121f")); }); on("death", "enemy", (e) => { kills++; destroy(e); updateHud(); }); let invulnerable = false; player.onCollideUpdate("enemy", () => { if (invulnerable) return; invulnerable = true; player.hurt(1); camShake(0.15, 0.25); updateHud(); wait(0.8, () => (invulnerable = false)); }); player.onDeath(() => go("end", false));Llave, puerta y botiquines
Los objetos del suelo son sensores. El botiquín cura, la llave queda anotada y la puerta amarilla, un cuerpo cinemático, solo se hunde si la tocas con la llave. Este candado es lo que convierte el laberinto en un recorrido.
let hasKey = false; player.onCollide("medkit", (m) => { destroy(m); player.heal(3); beep({ freq: 660, slideTo: 990, duration: 0.15 }); updateHud(); }); player.onCollide("key", (key) => { destroy(key); hasKey = true; beep({ freq: 880, slideTo: 1320, duration: 0.2 }); updateHud(); }); player.onCollide("door", (door) => { if (!hasKey || door.is("opening")) return; door.tag("opening"); tween(door.pos.y, -1.8, 1, (y) => (door.pos.y = y)); }); player.onCollide("exit", () => go("end", true));El marcador
Una línea con la vida, las bajas y si tienes la llave, más una mira en el centro de la pantalla.
let kills = 0; const total = get("enemy").length; const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), fixed()]); add([text("+", { size: 22, font: FONT }), pos(320, 180), anchor("center"), fixed()]); function updateHud() { const key = hasKey ? t({ es: " · llave ✓", en: " · key ✓" }) : ""; hud.text = `${t({ es: "Vida", en: "Health" })} ${player.hp} ${t({ es: "Bajas", en: "Kills" })} ${kills}/${total}${key}`; } updateHud();Escenas
Título, partida y final. Al terminar se libera el ratón con unlockPointer() para que puedas volver a usar la página.
function screen(title, subtitle) { add([text(title, { size: 38, font: FONT }), pos(320, 140), anchor("center"), fixed()]); add([text(subtitle, { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed()]); add([text(t({ es: "Clic o espacio para jugar", en: "Click or space to play" }), { size: 15, font: FONT }), pos(320, 240), anchor("center"), fixed()]); onButtonPress("shoot", () => go("game")); } scene("title", () => { screen("Boomer shooter", t({ es: "Encuentra la llave, abre la puerta amarilla y llega a la salida", en: "Find the key, open the yellow door and reach the exit" })); }); scene("end", (won) => { unlockPointer(); screen(won ? t({ es: "¡Saliste!", en: "You made it out!" }) : t({ es: "Has caído", en: "You died" }), ""); }); go("title");
Qué simplificamos
- Hay un solo arma y un solo tipo de enemigo; el género vive de arsenales enteros y de monstruos con conductas distintas.
- Los enemigos solo persiguen y empujan; no disparan ni esquivan.
- Sin armadura, munición ni secretos, y el arma es un rectángulo en pantalla.
Para modificarla
- Agrega un segundo arma, una escopeta que lance cinco rayos con un poco de dispersión.
- Haz que un tipo de enemigo dispare proyectiles lentos que se puedan esquivar desplazándote de lado.
- Esconde un secreto detrás de un muro falso que se abra al tocarlo.
Código completo
import threeplay from "../../vendor/threeplay/threeplay.mjs";
import { FONT, t, touchControls } from "../common.js";
// #region setup
const k = threeplay({
width: 640,
height: 360,
letterbox: true,
background: "#2b1d1d",
buttons: {
left: { keyboard: ["left", "a"] },
right: { keyboard: ["right", "d"] },
up: { keyboard: ["up", "w"] },
down: { keyboard: ["down", "s"] },
shoot: { keyboard: ["space", "j"], mouse: "left" },
},
});
touchControls(k, [
{ button: "left", label: "◀" },
{ button: "up", label: "▲" },
{ button: "down", label: "▼" },
{ button: "right", label: "▶" },
{ button: "shoot", label: "✹", side: "right" },
]);
const SPEED = 9; // fast on purpose: speed is half of the genre
const TILE = 3;
// #endregion
// #region level
// "#" wall, "@" start, "e" enemy, "+" health, "k" key, "D" locked door, "x" exit.
const MAP = [
"#############",
"#@ # e #",
"# # ## #",
"# e #k #",
"### #### ##",
"# + e #",
"# ###### ###",
"#e # x # #",
"# # D e #",
"# ####### #",
"# e #",
"#############",
];
function buildLevel() {
let start = vec3();
addLevel(MAP, {
tileSize: TILE,
tiles: {
"#": (p) => [box(TILE, 3.5, TILE), pos(p.x, 1.75, p.z), color("#7f5539"), area(), body({ isStatic: true })],
"@": (p) => {
start = p.clone();
return [];
},
e: (p) => [box(1, 1.6, 1), pos(p.x, 0.8, p.z), rotate(), color("#c1121f"), area(), character(), health(3), "enemy"],
"+": (p) => [box(0.6, 0.6, 0.6), pos(p.x, 0.5, p.z), color("#52b788"), area(), "medkit"],
k: (p) => [box(0.5, 0.5, 0.15), pos(p.x, 1, p.z), rotate(), color("gold"), area(), "key"],
D: (p) => [box(TILE, 3.5, 0.5), pos(p.x, 1.75, p.z), rotate(0, 90, 0), color("#ffd60a"), area(), body({ kinematic: true }), "door"],
x: (p) => [cylinder(1, 0.1), pos(p.x, 0.05, p.z), color("#00f5d4"), area(), "exit"],
},
});
const w = MAP[0].length * TILE;
const d = MAP.length * TILE;
add([box(w, 1, d), pos(0, -0.5, 0), color("#3a3a3a"), area(), body({ isStatic: true })]);
setFog({ color: "#2b1d1d", near: 4, far: 30 });
return start;
}
// #endregion
scene("game", () => {
const start = buildLevel();
// #region player
const player = add([capsule(0.4, 1.6), pos(start.x, 1, start.z), rotate(), area(), character(), health(10), "player"]);
player.hidden = true; // we look out of its eyes
firstPerson(player, { height: 0.6, lockOnClick: !isTouchscreen() });
onUpdate(() => {
// Strafing: inputDir is relative to where the camera looks, so A/D step sideways.
player.move(inputDir().multiplyScalar(SPEED));
});
// #endregion
// #region shooting
// Hitscan: the shot is a ray from the eye; whatever it hits first takes the damage.
const gun = add([uiRect(36, 70, { radius: 4 }), pos(320, 372), anchor("bot"), color("#495057"), fixed()]);
const flash = add([uiRect(30, 30, { radius: 15 }), pos(320, 296), anchor("center"), color("#ffd166"), fixed()]);
flash.hidden = true;
onButtonPress("shoot", () => {
flash.hidden = false;
wait(0.05, () => (flash.hidden = true));
gun.pos.y = 380;
tween(380, 372, 0.12, (y) => (gun.pos.y = y));
beep({ freq: 180, slideTo: 60, duration: 0.1, type: "sawtooth" });
const hit = raycast(camPos(), camera3d.getWorldDirection(vec3()), 60, { exclude: [player] });
if (hit?.obj.is("enemy")) hit.obj.hurt(1);
});
// #endregion
// #region enemies
// An enemy chases only when it can see you: a ray to the player must not hit a wall first.
function canSee(enemy) {
const from = enemy.worldPos().add(vec3(0, 0.5, 0));
const dir = player.worldPos().sub(from);
const hit = raycast(from, dir, 20, { exclude: [enemy] });
return hit?.obj === player;
}
onUpdate("enemy", (e) => {
if (e.distanceTo(player) < 20 && canSee(e)) {
e.moveTo(player, 3.5);
e.lookAt(vec3(player.pos.x, e.pos.y, player.pos.z));
}
});
on("hurt", "enemy", (e) => {
e.color.set("white");
wait(0.06, () => e.exists() && e.color.set("#c1121f"));
});
on("death", "enemy", (e) => {
kills++;
destroy(e);
updateHud();
});
let invulnerable = false;
player.onCollideUpdate("enemy", () => {
if (invulnerable) return;
invulnerable = true;
player.hurt(1);
camShake(0.15, 0.25);
updateHud();
wait(0.8, () => (invulnerable = false));
});
player.onDeath(() => go("end", false));
// #endregion
// #region pickups
let hasKey = false;
player.onCollide("medkit", (m) => {
destroy(m);
player.heal(3);
beep({ freq: 660, slideTo: 990, duration: 0.15 });
updateHud();
});
player.onCollide("key", (key) => {
destroy(key);
hasKey = true;
beep({ freq: 880, slideTo: 1320, duration: 0.2 });
updateHud();
});
player.onCollide("door", (door) => {
if (!hasKey || door.is("opening")) return;
door.tag("opening");
tween(door.pos.y, -1.8, 1, (y) => (door.pos.y = y));
});
player.onCollide("exit", () => go("end", true));
// #endregion
// #region hud
let kills = 0;
const total = get("enemy").length;
const hud = add([text("", { size: 16, font: FONT }), pos(12, 10), fixed()]);
add([text("+", { size: 22, font: FONT }), pos(320, 180), anchor("center"), fixed()]);
function updateHud() {
const key = hasKey ? t({ es: " · llave ✓", en: " · key ✓" }) : "";
hud.text = `${t({ es: "Vida", en: "Health" })} ${player.hp} ${t({ es: "Bajas", en: "Kills" })} ${kills}/${total}${key}`;
}
updateHud();
// #endregion
});
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 38, font: FONT }), pos(320, 140), anchor("center"), fixed()]);
add([text(subtitle, { size: 15, font: FONT }), pos(320, 190), anchor("center"), fixed()]);
add([text(t({ es: "Clic o espacio para jugar", en: "Click or space to play" }), { size: 15, font: FONT }), pos(320, 240), anchor("center"), fixed()]);
onButtonPress("shoot", () => go("game"));
}
scene("title", () => {
screen("Boomer shooter", t({ es: "Encuentra la llave, abre la puerta amarilla y llega a la salida", en: "Find the key, open the yellow door and reach the exit" }));
});
scene("end", (won) => {
unlockPointer();
screen(won ? t({ es: "¡Saliste!", en: "You made it out!" }) : t({ es: "Has caído", en: "You died" }), "");
});
go("title");
// #endregionHistoria
- 2018OrigenDusk
Según Dave Oshry, de New Blood Interactive, los fans acuñaron el término tras el lanzamiento de Dusk (2018). «Boomer» alude, en broma, a lo anticuado.
Shooter game (Wikipedia) - 2020Ultrakill
Ultrakill sale en acceso anticipado y suma al modelo de los noventa movimientos rápidos de juegos modernos y el estilo de los juegos de acción.
Ultrakill (Wikipedia)
Juegos de referencia
2018
Dusk
FPS de ambiente oscuro que imita los gráficos y el ritmo de los shooters de los noventa. Tras su lanzamiento, sus fans acuñaron el término «boomer shooter».
2020
Ultrakill
FPS con gráficos al estilo de las primeras PlayStation que suma movimientos rápidos de shooters modernos y elementos de juegos de acción como Devil May Cry. Está en acceso anticipado desde 2020.
2021
Q1K3
Homenaje a Quake (1996) en unos 13 kilobytes de JavaScript, hecho para la competición js13kGames de 2021. Te mueves con el teclado, apuntas con el ratón y recorres dos niveles basados en el primer episodio de Quake.