
Ritmo
Pulsas botones, tocas la pantalla o golpeas un instrumento de plástico justo cuando lo marca la música; cuanto más exacta es tu sincronía con el ritmo, más puntos sumas.
También se llama: videojuego de ritmo, juego de ritmo
Mecánicas que lo definen
- Notas que avanzan por carriles o por la pantalla hacia una línea o zona de golpe, colocadas sobre el pulso de la canción. El mapa de notas (chart) es la partitura que lees.
- Juicio por precisión temporal, con ventanas de unas decenas de milisegundos alrededor de cada nota que deciden si fue perfecto, bien o fallo.
- Combo y multiplicador, porque encadenar aciertos multiplica los puntos y un solo fallo rompe la cadena.
- Un medidor de rendimiento que sube con los aciertos y baja con los fallos; si se vacía, la canción termina antes.
- Mandos muy distintos según el juego, como botones, pantalla táctil, alfombras de baile, instrumentos de plástico o sables en realidad virtual, y varias dificultades por canción.
Bucle de juego
Ver la nota que llega, anticipar el pulso, pulsar en el momento justo, recibir el juicio y sostener el combo hasta el final de la canción; luego repetirla para subir la puntuación.
Demo propia
Controles. D, F, J y K (o las cuatro flechas), una tecla por carril, cuando la nota cruza la línea blanca. Con el ratón o el dedo, toca el carril. Espacio, Enter o clic para empezar.
Abrir en pantalla completaUna canción corta de 16 compases en la que las notas bajan por cuatro carriles hacia una línea. Cada golpe se juzga por su precisión en milisegundos (perfecto, bien o fallo), y los aciertos seguidos suben el combo y multiplican los puntos. La música es un metrónomo y una melodía generados con WebAudio, sincronizados con el reloj del audio.
Hecha con Kaplay, en unas 182 líneas de JavaScript.
Cómo está hecha
Configuración y constantes
Se crea el juego con un botón virtual por carril, así el mismo código sirve con teclas o con toques. Las constantes definen la canción (BPM, compases) y la lectura de las notas. NOTE_SPEED no cambia el ritmo, solo cuánto tiempo antes ves venir cada nota.
const k = kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [22, 18, 44], // One virtual button per lane, plus one to start. buttons: { lane0: { keyboard: ["d", "left"] }, lane1: { keyboard: ["f", "down"] }, lane2: { keyboard: ["j", "up"] }, lane3: { keyboard: ["k", "right"] }, start: { keyboard: ["space", "enter"] }, }, }); const BPM = 112; const BEAT = 60 / BPM; // seconds per beat const BARS = 16; // song length: 16 bars of 4 beats const COUNT_IN = 4; // metronome beats before the first note const LANES = 4; const LANE_W = 72; const LEFT = (640 - LANES * LANE_W) / 2; const HIT_Y = 300; // the hit line const NOTE_SPEED = 320; // px per second: how early you see a note coming const KEYS = ["D", "F", "J", "K"]; const LANE_COLORS = [rgb(255, 99, 132), rgb(255, 196, 61), rgb(80, 220, 160), rgb(90, 160, 255)]; const JUDGE_TEXT = { perfect: { es: "¡Perfecto!", en: "Perfect!" }, good: { es: "Bien", en: "Good" }, miss: { es: "Fallo", en: "Miss" } }; const JUDGE_COLOR = { perfect: rgb(120, 255, 200), good: rgb(255, 230, 120), miss: rgb(255, 110, 110) };El chart, la partitura
El chart es una lista de notas con su momento (en segundos de canción) y su carril. Se construye sobre la cuadrícula de pulsos, con una nota por pulso al principio y algunas a contratiempo (corcheas) después, y un silencio al cerrar cada frase. Se usa un azar con semilla para que la canción sea siempre la misma y se pueda aprender.
// The chart is the list of notes: when (in song seconds) and in which lane. // We generate it from the beat grid with a seeded random, so it is always the same song. function makeChart() { let seed = 2024; const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647; const notes = []; let lane = 1; for (let beat = 0; beat < BARS * 4; beat++) { const bar = Math.floor(beat / 4); if (bar % 4 === 3 && beat % 4 === 3) continue; // a rest closes each 4-bar phrase // First 4 bars: one note per beat. Then some off-beat (eighth) notes appear. const steps = bar >= 4 && random() < 0.25 + bar * 0.02 ? [0, 0.5] : [0]; for (const step of steps) { lane = (lane + 1 + Math.floor(random() * 3)) % LANES; // never the same lane twice in a row notes.push({ time: (beat + step) * BEAT, lane, judged: false }); } } return notes; }Sonido con WebAudio
Cada sonido es un oscilador con una envolvente de volumen que se apaga rápido, sin archivos de audio. La canción es una lista de eventos con su tiempo, que son un clic de metrónomo en cada pulso (más fuerte en el primero del compás) y un tono por nota, con una altura distinta por carril. El sonido acompaña, pero no hace falta para jugar.
// Minimal WebAudio: each sound is an oscillator with a short volume envelope. // Nothing here is needed to play: if the audio is muted or blocked, the game still works. const ctx = k.audioCtx; const PITCHES = [262, 330, 392, 523]; // C E G C: one pitch per lane function beep(when, freq, duration, type, volume) { const osc = new OscillatorNode(ctx, { type, frequency: freq }); const gain = ctx.createGain(); gain.gain.setValueAtTime(volume, when); gain.gain.exponentialRampToValueAtTime(0.001, when + duration); osc.connect(gain).connect(ctx.destination); osc.start(when); osc.stop(when + duration); } // The song as a list of timed sounds: a metronome click on every beat // (louder on the first beat of each bar) and a tone for every note. function makeSong(chart) { const events = []; for (let beat = -COUNT_IN; beat < BARS * 4; beat++) { const downbeat = beat % 4 === 0; events.push({ time: beat * BEAT, play: (w) => beep(w, downbeat ? 1200 : 800, 0.05, "square", downbeat ? 0.08 : 0.04) }); } for (const n of chart) events.push({ time: n.time, play: (w) => beep(w, PITCHES[n.lane], 0.18, "triangle", 0.12) }); return events.sort((a, b) => a.time - b.time); }El reloj del audio
Es la clave del género. Si la posición de la canción se calculara sumando dt() en cada fotograma, los pequeños errores se acumularían (los fotogramas no duran lo mismo y la pestaña puede trabarse) y lo que oyes se iría separando de lo que ves. ctx.currentTime es el reloj con el que la tarjeta de sonido reproduce, así que la canción avanza lo que avanzó ese reloj. Además, los sonidos se programan un poco antes (LOOKAHEAD) para un instante exacto del audio, en vez de dispararse «ahora» en cada fotograma. Si el navegador no deja sonar el audio, el juego usa dt() y sigue siendo jugable, aunque sin sonido al que ajustarse.
// WHY THE AUDIO CLOCK: notes must line up with what you hear. Adding up dt() // every frame drifts (frames are uneven, the tab may stall) and the sound // would slowly slip away from the notes. ctx.currentTime is the clock the // sound card itself plays by, so we advance the song by how much IT moved. // If the audio is not running (muted by the browser, no device), we fall back // to dt(): the game stays playable, it just is not locked to any sound. let songTime = -COUNT_IN * BEAT; // negative during the count-in let lastAudio = null; // ctx.currentTime on the previous frame function tickClock() { const running = ctx.state === "running"; songTime += running && lastAudio !== null ? ctx.currentTime - lastAudio : dt(); lastAudio = running ? ctx.currentTime : null; } // Exact song time right now, between two frames. const now = () => (lastAudio === null ? songTime : songTime + ctx.currentTime - lastAudio); // Sounds are scheduled a little ahead, at a precise audio time, instead of // played "now" each frame: that way they do not wobble with the frame rate. const LOOKAHEAD = 0.15; let nextEvent = 0; function scheduleSounds() { while (nextEvent < song.length && song[nextEvent].time < songTime + LOOKAHEAD) { const when = ctx.currentTime + (song[nextEvent].time - songTime); if (ctx.state === "running" && when >= ctx.currentTime) song[nextEvent].play(when); nextEvent++; } }Juicio, combo y puntos
Al pulsar un carril se busca la nota pendiente más cercana y se mide el error, que es el instante de la pulsación menos el de la nota. Hasta 45 ms es «perfecto», hasta 100 ms es «bien», y un golpe demasiado temprano gasta la nota como fallo. Una nota que pasa la línea sin golpe también es fallo. El combo cuenta los aciertos seguidos y multiplica los puntos hasta por cuatro, y un fallo lo devuelve a cero.
// Timing windows in seconds: how far from the note's time a hit may be. const WINDOW = { perfect: 0.045, good: 0.1, early: 0.16 }; const POINTS = { perfect: 300, good: 100, miss: 0 }; const stats = { perfect: 0, good: 0, miss: 0, score: 0, combo: 0, maxCombo: 0 }; let feedback = null; // last judgement shown on screen const flash = [0, 0, 0, 0]; function judge(kind, error) { stats[kind]++; stats.combo = kind === "miss" ? 0 : stats.combo + 1; stats.maxCombo = Math.max(stats.maxCombo, stats.combo); stats.score += POINTS[kind] * multiplier(); feedback = { kind, error, timer: 0.6 }; } // The combo multiplies the points: x1, x2 after 10 hits in a row… up to x4. const multiplier = () => Math.min(4, 1 + Math.floor(stats.combo / 10)); function hitLane(lane) { flash[lane] = 0.12; const time = now(); // The closest note still waiting in this lane. let best = null; for (const n of chart) { if (n.judged || n.lane !== lane || Math.abs(n.time - time) > WINDOW.early) continue; if (!best || Math.abs(n.time - time) < Math.abs(best.time - time)) best = n; } if (!best) return; // pressing with no note nearby is not punished best.judged = true; const error = time - best.time; // negative = early, positive = late judge(Math.abs(error) <= WINDOW.perfect ? "perfect" : Math.abs(error) <= WINDOW.good ? "good" : "miss", error); }Entrada
Cada botón virtual golpea su carril. Con el ratón o el dedo se calcula el carril a partir de la coordenada x del toque. Las dos vías llaman a la misma función, así el juicio es idéntico.
for (let i = 0; i < LANES; i++) onButtonPress("lane" + i, () => hitLane(i)); // Mouse or finger: tap the lane itself. onMousePress(() => { const lane = Math.floor((mousePos().x - LEFT) / LANE_W); if (lane >= 0 && lane < LANES) hitLane(lane); });Dibujar las notas
Las notas no son objetos que se mueven solos. En cada fotograma se dibujan donde dice el reloj, porque su altura depende solo de cuánto falta para su momento (tiempo de la nota menos tiempo de la canción). Por eso nunca se desincronizan, aunque el juego vaya a menos fotogramas por segundo.
// Notes are not game objects: each frame we draw them where the clock says. // y depends only on (note time − song time), so they can never drift from the music. onDraw(() => { for (let i = 0; i < LANES; i++) { const x = LEFT + i * LANE_W; drawRect({ pos: vec2(x, 0), width: LANE_W - 4, height: 360, color: rgb(40, 34, 72), opacity: 0.8 }); drawRect({ pos: vec2(x, HIT_Y - 14), width: LANE_W - 4, height: 28, radius: 6, color: LANE_COLORS[i], opacity: 0.25 + flash[i] * 5 }); drawText({ text: KEYS[i], pos: vec2(x + LANE_W / 2 - 2, HIT_Y + 30), size: 16, font: FONT, anchor: "center", color: rgb(200, 200, 230) }); } drawRect({ pos: vec2(LEFT, HIT_Y - 1), width: LANES * LANE_W - 4, height: 2, color: rgb(255, 255, 255) }); for (const n of chart) { if (n.judged) continue; const y = HIT_Y - (n.time - songTime) * NOTE_SPEED; if (y < -20 || y > 380) continue; drawRect({ pos: vec2(LEFT + n.lane * LANE_W + 4, y - 9), width: LANE_W - 12, height: 18, radius: 5, color: LANE_COLORS[n.lane] }); }Marcador y juicio en pantalla
Se muestran los puntos, el combo con su multiplicador, la cuenta atrás de los primeros cuatro pulsos y el último juicio con su error en milisegundos, antes o tarde. Ver ese número enseña qué significan las ventanas y si sueles adelantarte.
drawText({ text: t({ es: "Puntos", en: "Score" }) + `\n${stats.score}`, pos: vec2(20, 20), size: 18, font: FONT }); drawText({ text: t({ es: "Combo", en: "Combo" }) + `\n${stats.combo} x${multiplier()}`, pos: vec2(500, 20), size: 18, font: FONT }); // Count-in: 4, 3, 2, 1 on the metronome beats before the first note. if (songTime < 0) drawText({ text: String(Math.ceil(-songTime / BEAT)), pos: vec2(320, 150), size: 48, font: FONT, anchor: "center" }); if (feedback && feedback.timer > 0) { drawRect({ pos: vec2(320, 175), width: 220, height: 64, radius: 10, anchor: "center", color: rgb(10, 8, 24), opacity: 0.75 * Math.min(1, feedback.timer * 3) }); const { kind, error, timer } = feedback; // Showing the error in milliseconds teaches what the windows mean. const when = t(error < 0 ? { es: "antes", en: "early" } : { es: "tarde", en: "late" }); const detail = error === null ? "" : `\n${Math.round(Math.abs(error) * 1000)} ms ${when}`; drawText({ text: t(JUDGE_TEXT[kind]) + detail, pos: vec2(320, 170), size: 26, font: FONT, anchor: "center", align: "center", color: JUDGE_COLOR[kind], opacity: Math.min(1, timer * 3) }); }Escenas
El juego tiene tres escenas (title, game y end). Al empezar se reanuda el audio, porque los navegadores solo dejan sonar después de un clic o una tecla. Al final se muestran la precisión, el reparto de juicios y el combo máximo.
function screen(title, subtitle, next) { add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]); // Browsers only allow sound after a click or a key press, so we resume it here. const startGame = () => (ctx.resume(), next()); onButtonPress("start", startGame); onClick(startGame); } scene("title", () => screen( t({ es: "Ritmo", en: "Rhythm" }), t({ es: "Pulsa D F J K (o las flechas) cuando cada nota cruce la línea blanca, o toca su carril.\nCuanto más cerca del pulso, más puntos. Espacio o clic para empezar.", en: "Press D F J K (or the arrows) as each note crosses the white line, or tap its lane.\nThe closer to the beat, the more points. Space or click to start.", }), () => go("game"), ), ); scene("end", (stats, total) => { const accuracy = Math.round(((stats.perfect + stats.good * 0.5) / total) * 100); screen( t({ es: `${stats.score} puntos`, en: `${stats.score} points` }), t({ es: `Perfecto ${stats.perfect} · Bien ${stats.good} · Fallo ${stats.miss}\nPrecisión ${accuracy}% · Combo máximo ${stats.maxCombo}\nEspacio o clic para repetir.`, en: `Perfect ${stats.perfect} · Good ${stats.good} · Miss ${stats.miss}\nAccuracy ${accuracy}% · Max combo ${stats.maxCombo}\nSpace or click to play again.`, }), () => go("game"), ); }); go("title");
Qué simplificamos
- La canción es un metrónomo con un tono por nota, sin archivos de audio. Un juego real carga una grabación y sincroniza el chart con ella.
- El chart se genera al azar sobre el pulso (con semilla fija, así siempre es igual). En un juego real lo escribe a mano un diseñador que escucha la canción.
- No hay notas largas que se mantienen, ni medidor de vida, ni calibración del desfase entre audio y pantalla.
- El juego no castiga pulsar cuando no hay ninguna nota cerca, y con el dedo solo se puede tocar un carril a la vez.
Para modificarla
- Añade una constante de desfase (offset) que se sume al tiempo de cada pulsación y una pantalla para calibrarla pulsando al ritmo del metrónomo.
- Añade notas largas que haya que mantener pulsadas hasta su final, y piensa cómo juzgar cuándo se suelta.
- Añade un medidor de vida que baje con cada fallo y termine la canción si se vacía.
- Escribe a mano el chart de una melodía conocida (una lista de tiempos y carriles) y compárala con la generada.
Código completo
// Rhythm 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: [22, 18, 44],
// One virtual button per lane, plus one to start.
buttons: {
lane0: { keyboard: ["d", "left"] }, lane1: { keyboard: ["f", "down"] },
lane2: { keyboard: ["j", "up"] }, lane3: { keyboard: ["k", "right"] },
start: { keyboard: ["space", "enter"] },
},
});
const BPM = 112;
const BEAT = 60 / BPM; // seconds per beat
const BARS = 16; // song length: 16 bars of 4 beats
const COUNT_IN = 4; // metronome beats before the first note
const LANES = 4;
const LANE_W = 72;
const LEFT = (640 - LANES * LANE_W) / 2;
const HIT_Y = 300; // the hit line
const NOTE_SPEED = 320; // px per second: how early you see a note coming
const KEYS = ["D", "F", "J", "K"];
const LANE_COLORS = [rgb(255, 99, 132), rgb(255, 196, 61), rgb(80, 220, 160), rgb(90, 160, 255)];
const JUDGE_TEXT = { perfect: { es: "¡Perfecto!", en: "Perfect!" }, good: { es: "Bien", en: "Good" }, miss: { es: "Fallo", en: "Miss" } };
const JUDGE_COLOR = { perfect: rgb(120, 255, 200), good: rgb(255, 230, 120), miss: rgb(255, 110, 110) };
// #endregion
// #region chart
// The chart is the list of notes: when (in song seconds) and in which lane.
// We generate it from the beat grid with a seeded random, so it is always the same song.
function makeChart() {
let seed = 2024;
const random = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const notes = [];
let lane = 1;
for (let beat = 0; beat < BARS * 4; beat++) {
const bar = Math.floor(beat / 4);
if (bar % 4 === 3 && beat % 4 === 3) continue; // a rest closes each 4-bar phrase
// First 4 bars: one note per beat. Then some off-beat (eighth) notes appear.
const steps = bar >= 4 && random() < 0.25 + bar * 0.02 ? [0, 0.5] : [0];
for (const step of steps) {
lane = (lane + 1 + Math.floor(random() * 3)) % LANES; // never the same lane twice in a row
notes.push({ time: (beat + step) * BEAT, lane, judged: false });
}
}
return notes;
}
// #endregion
// #region audio
// Minimal WebAudio: each sound is an oscillator with a short volume envelope.
// Nothing here is needed to play: if the audio is muted or blocked, the game still works.
const ctx = k.audioCtx;
const PITCHES = [262, 330, 392, 523]; // C E G C: one pitch per lane
function beep(when, freq, duration, type, volume) {
const osc = new OscillatorNode(ctx, { type, frequency: freq });
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, when);
gain.gain.exponentialRampToValueAtTime(0.001, when + duration);
osc.connect(gain).connect(ctx.destination);
osc.start(when);
osc.stop(when + duration);
}
// The song as a list of timed sounds: a metronome click on every beat
// (louder on the first beat of each bar) and a tone for every note.
function makeSong(chart) {
const events = [];
for (let beat = -COUNT_IN; beat < BARS * 4; beat++) {
const downbeat = beat % 4 === 0;
events.push({ time: beat * BEAT, play: (w) => beep(w, downbeat ? 1200 : 800, 0.05, "square", downbeat ? 0.08 : 0.04) });
}
for (const n of chart) events.push({ time: n.time, play: (w) => beep(w, PITCHES[n.lane], 0.18, "triangle", 0.12) });
return events.sort((a, b) => a.time - b.time);
}
// #endregion
scene("game", () => {
const chart = makeChart();
const song = makeSong(chart);
const end = chart[chart.length - 1].time + 1.5;
// #region clock
// WHY THE AUDIO CLOCK: notes must line up with what you hear. Adding up dt()
// every frame drifts (frames are uneven, the tab may stall) and the sound
// would slowly slip away from the notes. ctx.currentTime is the clock the
// sound card itself plays by, so we advance the song by how much IT moved.
// If the audio is not running (muted by the browser, no device), we fall back
// to dt(): the game stays playable, it just is not locked to any sound.
let songTime = -COUNT_IN * BEAT; // negative during the count-in
let lastAudio = null; // ctx.currentTime on the previous frame
function tickClock() {
const running = ctx.state === "running";
songTime += running && lastAudio !== null ? ctx.currentTime - lastAudio : dt();
lastAudio = running ? ctx.currentTime : null;
}
// Exact song time right now, between two frames.
const now = () => (lastAudio === null ? songTime : songTime + ctx.currentTime - lastAudio);
// Sounds are scheduled a little ahead, at a precise audio time, instead of
// played "now" each frame: that way they do not wobble with the frame rate.
const LOOKAHEAD = 0.15;
let nextEvent = 0;
function scheduleSounds() {
while (nextEvent < song.length && song[nextEvent].time < songTime + LOOKAHEAD) {
const when = ctx.currentTime + (song[nextEvent].time - songTime);
if (ctx.state === "running" && when >= ctx.currentTime) song[nextEvent].play(when);
nextEvent++;
}
}
// #endregion
// #region judge
// Timing windows in seconds: how far from the note's time a hit may be.
const WINDOW = { perfect: 0.045, good: 0.1, early: 0.16 };
const POINTS = { perfect: 300, good: 100, miss: 0 };
const stats = { perfect: 0, good: 0, miss: 0, score: 0, combo: 0, maxCombo: 0 };
let feedback = null; // last judgement shown on screen
const flash = [0, 0, 0, 0];
function judge(kind, error) {
stats[kind]++;
stats.combo = kind === "miss" ? 0 : stats.combo + 1;
stats.maxCombo = Math.max(stats.maxCombo, stats.combo);
stats.score += POINTS[kind] * multiplier();
feedback = { kind, error, timer: 0.6 };
}
// The combo multiplies the points: x1, x2 after 10 hits in a row… up to x4.
const multiplier = () => Math.min(4, 1 + Math.floor(stats.combo / 10));
function hitLane(lane) {
flash[lane] = 0.12;
const time = now();
// The closest note still waiting in this lane.
let best = null;
for (const n of chart) {
if (n.judged || n.lane !== lane || Math.abs(n.time - time) > WINDOW.early) continue;
if (!best || Math.abs(n.time - time) < Math.abs(best.time - time)) best = n;
}
if (!best) return; // pressing with no note nearby is not punished
best.judged = true;
const error = time - best.time; // negative = early, positive = late
judge(Math.abs(error) <= WINDOW.perfect ? "perfect" : Math.abs(error) <= WINDOW.good ? "good" : "miss", error);
}
// #endregion
// #region input
for (let i = 0; i < LANES; i++) onButtonPress("lane" + i, () => hitLane(i));
// Mouse or finger: tap the lane itself.
onMousePress(() => {
const lane = Math.floor((mousePos().x - LEFT) / LANE_W);
if (lane >= 0 && lane < LANES) hitLane(lane);
});
// #endregion
onUpdate(() => {
tickClock();
scheduleSounds();
// A note that went past the line without being hit is a miss.
for (const n of chart) {
if (!n.judged && songTime - n.time > WINDOW.good) {
n.judged = true;
judge("miss", null);
}
}
for (let i = 0; i < LANES; i++) flash[i] = Math.max(0, flash[i] - dt());
if (feedback) feedback.timer -= dt();
if (songTime > end) go("end", stats, chart.length);
});
// #region notes
// Notes are not game objects: each frame we draw them where the clock says.
// y depends only on (note time − song time), so they can never drift from the music.
onDraw(() => {
for (let i = 0; i < LANES; i++) {
const x = LEFT + i * LANE_W;
drawRect({ pos: vec2(x, 0), width: LANE_W - 4, height: 360, color: rgb(40, 34, 72), opacity: 0.8 });
drawRect({ pos: vec2(x, HIT_Y - 14), width: LANE_W - 4, height: 28, radius: 6, color: LANE_COLORS[i], opacity: 0.25 + flash[i] * 5 });
drawText({ text: KEYS[i], pos: vec2(x + LANE_W / 2 - 2, HIT_Y + 30), size: 16, font: FONT, anchor: "center", color: rgb(200, 200, 230) });
}
drawRect({ pos: vec2(LEFT, HIT_Y - 1), width: LANES * LANE_W - 4, height: 2, color: rgb(255, 255, 255) });
for (const n of chart) {
if (n.judged) continue;
const y = HIT_Y - (n.time - songTime) * NOTE_SPEED;
if (y < -20 || y > 380) continue;
drawRect({ pos: vec2(LEFT + n.lane * LANE_W + 4, y - 9), width: LANE_W - 12, height: 18, radius: 5, color: LANE_COLORS[n.lane] });
}
// #endregion
// #region hud
drawText({ text: t({ es: "Puntos", en: "Score" }) + `\n${stats.score}`, pos: vec2(20, 20), size: 18, font: FONT });
drawText({ text: t({ es: "Combo", en: "Combo" }) + `\n${stats.combo} x${multiplier()}`, pos: vec2(500, 20), size: 18, font: FONT });
// Count-in: 4, 3, 2, 1 on the metronome beats before the first note.
if (songTime < 0) drawText({ text: String(Math.ceil(-songTime / BEAT)), pos: vec2(320, 150), size: 48, font: FONT, anchor: "center" });
if (feedback && feedback.timer > 0) {
drawRect({ pos: vec2(320, 175), width: 220, height: 64, radius: 10, anchor: "center", color: rgb(10, 8, 24), opacity: 0.75 * Math.min(1, feedback.timer * 3) });
const { kind, error, timer } = feedback;
// Showing the error in milliseconds teaches what the windows mean.
const when = t(error < 0 ? { es: "antes", en: "early" } : { es: "tarde", en: "late" });
const detail = error === null ? "" : `\n${Math.round(Math.abs(error) * 1000)} ms ${when}`;
drawText({ text: t(JUDGE_TEXT[kind]) + detail, pos: vec2(320, 170), size: 26, font: FONT, anchor: "center", align: "center", color: JUDGE_COLOR[kind], opacity: Math.min(1, timer * 3) });
}
// #endregion
});
});
// #region scenes
function screen(title, subtitle, next) {
add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
// Browsers only allow sound after a click or a key press, so we resume it here.
const startGame = () => (ctx.resume(), next());
onButtonPress("start", startGame);
onClick(startGame);
}
scene("title", () =>
screen(
t({ es: "Ritmo", en: "Rhythm" }),
t({
es: "Pulsa D F J K (o las flechas) cuando cada nota cruce la línea blanca, o toca su carril.\nCuanto más cerca del pulso, más puntos. Espacio o clic para empezar.",
en: "Press D F J K (or the arrows) as each note crosses the white line, or tap its lane.\nThe closer to the beat, the more points. Space or click to start.",
}),
() => go("game"),
),
);
scene("end", (stats, total) => {
const accuracy = Math.round(((stats.perfect + stats.good * 0.5) / total) * 100);
screen(
t({ es: `${stats.score} puntos`, en: `${stats.score} points` }),
t({
es: `Perfecto ${stats.perfect} · Bien ${stats.good} · Fallo ${stats.miss}\nPrecisión ${accuracy}% · Combo máximo ${stats.maxCombo}\nEspacio o clic para repetir.`,
en: `Perfect ${stats.perfect} · Good ${stats.good} · Miss ${stats.miss}\nAccuracy ${accuracy}% · Max combo ${stats.maxCombo}\nSpace or click to play again.`,
}),
() => go("game"),
);
});
go("title");
// #endregionRetos de diseño
- Sincronizar sonido, imagen y entrada. El audio, la pantalla y el mando tienen cada uno su retraso, así que el juego debe medir el tiempo con el reloj del audio y dejar calibrar el desfase.
- Escribir charts que se sientan parte de la música, con las notas sobre lo que suena, y graduar la dificultad entre canciones y dentro de cada una.
- Elegir ventanas de juicio justas. Si son muy estrechas el juego frustra, y si son muy anchas deja de premiar la precisión.
- Conseguir la música, que hay que componer o licenciar, y que el juego siga siendo jugable para quien no oye bien, con señales visuales claras.
Historia
- 1996OrigenPaRappa the Rapper
PaRappa the Rapper (1996) se considera el primer juego de ritmo propiamente dicho: pulsas los botones en el orden en que aparecen. Antes, Dance Aerobics (1987) ya se basaba en el ritmo.
Rhythm game (Wikipedia) - 1997Beatmania
Beatmania triunfa en los arcades japoneses y da nombre a la división Bemani de Konami.
Rhythm game (Wikipedia) - 1998Dance Dance Revolution
Dance Dance Revolution lleva el ritmo a los pies, con una alfombra de flechas y juicios de Perfect a Miss, y populariza los juegos musicales que se juegan pisando.
Dance Dance Revolution (1998 video game) (Wikipedia) - 2005Guitar Hero
Guitar Hero populariza en Occidente el ritmo con mando en forma de guitarra.
Rhythm game (Wikipedia) - 2007osu!
Dean Herbert publica osu!, un juego gratuito para PC basado en Osu! Tatakae! Ouendan, cuyas canciones crea la propia comunidad.
osu! (Wikipedia) - 2015Crypt of the NecroDancer
Crypt of the NecroDancer cruza el ritmo con el roguelike, porque solo puedes moverte y atacar al compás de la música.
Crypt of the NecroDancer (Wikipedia) - 2018Beat Saber
Beat Saber lleva el género a la realidad virtual, donde cortas bloques con dos sables al ritmo de la música, y al salir se vuelve el juego de realidad virtual más vendido y mejor valorado de Steam.
Rhythm game (Wikipedia) - 2020Friday Night Funkin'
Friday Night Funkin' nace en la game jam Ludum Dare 47 y se publica gratis y jugable en el navegador en itch.io y Newgrounds; además, su código es abierto.
Friday Night Funkin' (Wikipedia)
Juegos de referencia
1996
PaRappa the Rapper
Un perro rapero repite, pulsando botones a tiempo, los versos que le canta cada maestro. Cada canción se califica de «Awful» a «Cool», y se considera el primer juego de ritmo propiamente dicho.
1997
Beatmania
Haces de DJ con una consola de cinco teclas y un plato giratorio; las notas bajan por columnas y cada una suena al pulsarla. Su éxito dio nombre a Bemani, la división musical de Konami.
1998
Dance Dance Revolution
Pisas cuatro flechas de una alfombra cuando las flechas que suben por la pantalla llegan a la zona de paso. Cada paso se juzga de «Perfect» a «Miss» y un medidor de baile decide si terminas la canción.
2005
Guitar Hero
Tocas canciones de rock con un mando en forma de guitarra, con cinco botones de traste y una barra para rasguear, siguiendo las notas que avanzan por un mástil.
2007
osu!
Haces clic o tocas círculos que aparecen al ritmo de la música, en canciones que crea la propia comunidad. Es gratuito y su versión nueva, osu!lazer, es de código abierto; su modo osu!mania es de carriles verticales, como la demo.
2015
Crypt of the NecroDancer
Un roguelike de mazmorras generadas al azar en el que solo puedes moverte o atacar al compás de la música; perder el pulso te deja expuesto a los monstruos.
2018
Beat Saber
En realidad virtual, cortas con dos sables de luz los bloques que vienen hacia ti al ritmo de la música, en la dirección que indica cada uno, y esquivas bombas y muros.
2019
A Dance of Fire and Ice
Un juego de ritmo de un solo botón; dos planetas giran uno alrededor del otro y pulsas en el compás exacto para que avancen por un camino lleno de curvas.
2020
Friday Night Funkin'
Duelos de canto en los que el rival canta un patrón de flechas y tú lo repites pulsando las flechas a tiempo. Nació en una game jam y es de código abierto; sus autores lo publican jugable en el navegador.
Ejercicio
Juega la demo y fíjate en los milisegundos que muestra cada juicio. Cambia en el código la ventana de «perfecto» a 20 y luego a 80 ms y discute cuál se siente más justa. Después cambia el BPM y la velocidad de las notas y explica por qué las notas siguen cayendo a tiempo. Por último, escribe a mano un chart de 4 compases para una melodía que conozcas y reemplaza el generado.