Created
October 10, 2022 13:24
-
-
Save lmiller1990/4c7294f028542df3659a4a1fc11de8b5 to your computer and use it in GitHub Desktop.
Simple Rhythm Engine
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<script src="/index.js"></script> | |
<title>Demo</title> | |
<style> | |
#gameplay { | |
position: relative; | |
} | |
#target { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100px; | |
height: 25px; | |
border: 2px solid; | |
} | |
.note { | |
position: absolute; | |
width: 100px; | |
height: 25px; | |
background: darkcyan; | |
} | |
#timing { | |
position: absolute; | |
left: 100px; | |
list-style: none; | |
} | |
</style> | |
</head> | |
<body> | |
<p>Rhythm engine demo.</p> | |
<ul> | |
<li>Click <b>Start</b> to start.</li> | |
<li>Press <b>J</b> to hit notes when they overlap the rectangle.</li> | |
</ul> | |
<div> | |
<button onclick="start()">Start</button> | |
<hr> | |
</div> | |
<div id="gameplay"> | |
<div id="target"></div> | |
<ul id="timing"> | |
</ul> | |
</div> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @ts-check | |
/** | |
* @param {string} url | |
* @returns {Promise<() => { audioContext: AudioContext, startTime?: number }>} | |
*/ | |
async function fetchAudio(url) { | |
const audioContext = new window.AudioContext(); | |
const res = await window.fetch(url); | |
const buf = await res.arrayBuffer(); | |
const buffer = await audioContext.decodeAudioData(buf); | |
const gainNode = audioContext.createGain(); | |
gainNode.gain.value = 1.0; | |
gainNode.connect(audioContext.destination); | |
return () => { | |
const source = audioContext.createBufferSource(); | |
source.buffer = buffer; | |
source.connect(gainNode); | |
source.start(0); | |
const startTime = audioContext.getOutputTimestamp().performanceTime; | |
return { audioContext, startTime }; | |
}; | |
} | |
/** | |
* A simple "chart" | |
* 4th note at 135 bpm | |
* 60/135 = 0.444... | |
* A note every 0.444ms | |
* | |
* @returns {Array<{ el: HTMLDivElement; ms: number, timing?: number }>} | |
*/ | |
function createChart() { | |
const offsetMs = 60; | |
const _4th = 60 / 135; | |
const notes = []; | |
const $gameplay = document.querySelector("#gameplay"); | |
for (let i = 1; i < 16; i++) { | |
const el = document.createElement("div"); | |
el.className = "note"; | |
$gameplay.appendChild(el); | |
notes.push({ ms: i * _4th * 1000 + offsetMs, el }); | |
} | |
console.log(JSON.stringify(notes, null, 2)) | |
return notes; | |
} | |
/** | |
* @param {Array<{ el: HTMLDivElement; ms: number, timing?: number }>} chart | |
* @param {AudioContext} audioContext | |
* @param {number} startTime | |
* @param {InputManager} inputManager | |
*/ | |
function gameLoop(chart, audioContext, startTime, inputManager) { | |
const elapsed = audioContext.getOutputTimestamp().performanceTime - startTime; | |
inputManager.process(chart); | |
// calculate new position based on playback | |
for (const note of chart) { | |
note.el.style.top = `${note.ms - elapsed}px`; | |
} | |
inputManager.clear(); | |
requestAnimationFrame(() => | |
gameLoop(chart, audioContext, startTime, inputManager) | |
); | |
} | |
class InputManager { | |
/** @type {number | undefined} */ | |
#input; | |
/** | |
* @param {number} t0 | |
*/ | |
constructor(t0) { | |
window.addEventListener("keydown", (event) => { | |
if (event.code !== "KeyJ") { | |
return; | |
} | |
this.#input = event.timeStamp - t0; | |
}); | |
} | |
clear() { | |
this.#input = undefined; | |
} | |
/** | |
* @param {Array<{ el: HTMLDivElement, ms: number, timing?: number }>} notes | |
*/ | |
process(notes) { | |
if (!this.#input) { | |
return; | |
} | |
const $ul = document.querySelector("#timing"); | |
if (!$ul) { | |
return; | |
} | |
$ul.innerHTML = ""; | |
for (let i = 0; i < notes.length; i++) { | |
const note = notes[i]; | |
if (Math.abs(note.ms - this.#input) < 100) { | |
// close enough to be considered hit! | |
note.timing = note.ms - this.#input; | |
} | |
const $li = document.createElement("li"); | |
const content = note.timing ? `${note.timing.toFixed(0)}ms` : "-"; | |
const id = i.toString().padStart(2, "0"); | |
$li.textContent = `Note #${id}: ${content}`; | |
$ul.appendChild($li); | |
} | |
} | |
} | |
async function start() { | |
const play = await fetchAudio("/135bpm.wav"); | |
const chart = createChart(); | |
const { startTime, audioContext } = play(); | |
const inputManager = new InputManager(startTime); | |
gameLoop(chart, audioContext, startTime, inputManager); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment