/** * Reachy Mini Simon — app entry. * * gate → HF sign-in + robot picker * game → main view (status, board, sequence strip) * * Per-difficulty input is read from the SDK "state" event (bumped to * ~20 Hz so head tilts feel responsive). Per-difficulty selection at * the start happens via the left antenna while it's set to torque off * (setMotorTorque(false, ["left_antenna"])); the right antenna pulled * past 0.4 rad starts the game. */ import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.2/js/reachy-mini.js"; import { state, render, setRenderImpl } from "./lib/state.js"; import { sleep, Direction } from "./lib/util.js"; import { createAudio } from "./lib/audio.js"; import { createMotor } from "./lib/motor.js"; import { createGame } from "./lib/game.js"; import { createInputDetector, createAntennaButton } from "./lib/input.js"; import { createGate } from "./views/gate.js"; import { createGameView } from "./views/game.js"; import { fetchLeaderboard, saveScore } from "./lib/leaderboard.js"; const robot = new ReachyMini({ appName: "reachy_mini_simon" }); const audio = createAudio({ robot }); const motor = createMotor({ robot }); const game = createGame(); const detector = createInputDetector(); const ui = createGameView(); // ─── Render dispatch ──────────────────────────────────────────────── const root = document.getElementById("app"); const { GateView } = createGate({ robot, onPickedRobot: () => { startSession().catch((e) => console.error(e)); }, }); function render_() { root.innerHTML = ""; if (state.view === "gate") { root.appendChild(GateView()); } else { root.appendChild(ui.GameView()); ui.setDifficulty(state.difficulty); ui.setScore(state.round, state.bestScore); ui.setStatus(...statusFor(state.gameState)); ui.renderSequenceStrip(state.sequence, state.playerInput.length); ui.renderLeaderboard([], state.difficulty, robot.username); refreshLeaderboard(); } } async function refreshLeaderboard() { const d = state.difficulty; const entries = await fetchLeaderboard(d); // Guard against difficulty changing while we were fetching. if (state.difficulty === d) { ui.renderLeaderboard(entries, d, robot.username); } } setRenderImpl(render_); function statusFor(s) { switch (s) { case "selecting": return ["🎚️", "Push LEFT antenna to cycle difficulty · push RIGHT to start", ""]; case "showing": return ["👀", "Watch the sequence…", ""]; case "input": return ["🎯", `Your turn — ${state.playerInput.length}/${state.sequence.length}`, ""]; case "wrong": return ["❌", "Wrong! Game over.", "err"]; case "win": return ["✨", "Correct!", "ok"]; case "celebrating": return ["🎉", "New record!", "ok"]; default: return ["⏳", "…", ""]; } } // Pushed by setStatus calls outside of render. function setStatus(gameState, extraText) { state.gameState = gameState; const [icon, text, tone] = statusFor(gameState); ui.setStatus(icon, extraText ?? text, tone); } // ─── Game lifecycle ───────────────────────────────────────────────── let stop = false; async function startSession() { // Audio setup (decode all WAVs, route mixer onto WebRTC sender). await audio.preload(); await audio.attachToRobot(); try { robot.setAudioMuted(false); } catch {} // Bump state refresh so input detection at 20 Hz is feasible. if (robot._stateRefreshInterval) clearInterval(robot._stateRefreshInterval); robot._stateRefreshInterval = setInterval(() => robot.requestState(), 50); robot.addEventListener("sessionStopped", () => { stop = true; state.view = "gate"; render(); }); render(); // Wake to neutral; leave left antenna free for difficulty selection. await motor.wakeAndPrepareForSelection(); setStatus("selecting"); // Main loop. while (!stop) { try { await runOneGame(); } catch (e) { console.error("game loop error:", e); } } } async function runOneGame() { // ─── Difficulty selection ─────────────────────────────────────── game.reset(); state.sequence = []; state.playerInput = []; state.round = 0; let twitchAt = performance.now() + 3000; setStatus("selecting"); ui.setScore(0, state.bestScore); // Both antennas now act as discrete buttons: left cycles // difficulty (1 → 2 → 3 → 1), right starts the game. const cycleBtn = createAntennaButton(); const startBtn = createAntennaButton(); // Initialise UI with the current difficulty. game.setDifficulty(state.difficulty); detector.setDifficulty(state.difficulty); ui.setDifficulty(state.difficulty); audio.playSound(`difficulty${state.difficulty}`); while (!stop) { const rs = robot.robotState || {}; const antennas = Array.isArray(rs.antennas) ? rs.antennas : [0, 0]; const left = antennas[1] || 0; const right = antennas[0] || 0; if (cycleBtn.update(left)) { const d = (state.difficulty % 3) + 1; state.difficulty = d; game.setDifficulty(d); detector.setDifficulty(d); ui.setDifficulty(d); audio.playSound(`difficulty${d}`); refreshLeaderboard(); } if (startBtn.update(right)) break; // Periodic right-antenna ready twitch. if (performance.now() > twitchAt) { twitchAt = performance.now() + 3000; motor.rightAntennaTwitch().catch(() => {}); } await sleep(50); } if (stop) return; // ─── Game start ───────────────────────────────────────────────── motor.lockLeftAntennaForGame(); motor.goto(motor.NEUTRAL_TARGET, 0.5); await sleep(550); await audio.playSound("start_game"); await sleep(300); // ─── Round loop ───────────────────────────────────────────────── while (!stop) { game.addToSequence(); state.sequence = game.sequence; state.playerInput = []; state.round = game.round; ui.setScore(state.round, state.bestScore); ui.renderSequenceStrip(state.sequence, 0); // Show sequence setStatus("showing"); for (const dir of game.sequence) { if (stop) return; ui.flashTile(dir); audio.playSound(dir); await motor.showDirection(dir); } // Wait for the head/body/antennas to physically return to // neutral before accepting input — otherwise residual tilt from // the last animation gets registered as the player's first move // and they lose without having touched anything. await motor.settleAtNeutral(); // Wait for input setStatus("input"); ui.renderSequenceStrip(state.sequence, 0); detector.reset(); const correct = await waitForPlayerInput(); if (!correct) { await onGameOver(); return; } // Round complete setStatus("win"); audio.playSound("success"); await sleep(700); } } // Returns true if the full sequence was reproduced correctly, false on // a mismatch or session stop. async function waitForPlayerInput() { return new Promise((resolve) => { const handler = (e) => { const rs = e.detail || {}; const dir = detector.update(rs); if (!dir) return; const idx = state.playerInput.length; const expected = state.sequence[idx]; state.playerInput.push(dir); ui.flashTile(dir); audio.playSound(dir); ui.renderSequenceStrip(state.sequence, state.playerInput.length); ui.setStatus(...statusFor("input")); // refresh "(n/m)" text if (dir !== expected) { robot.removeEventListener("state", handler); resolve(false); return; } if (state.playerInput.length === state.sequence.length) { robot.removeEventListener("state", handler); resolve(true); } }; robot.addEventListener("state", handler); }); } async function onGameOver() { setStatus("wrong"); await audio.playSound("game_over"); await sleep(400); const beat = game.isNewRecord(); const finalRound = game.round; if (beat) { game.commitBest(); state.bestScore = game.bestScore; setStatus("celebrating"); ui.setScore(state.round, state.bestScore); for (const dir of Object.values(Direction)) { ui.flashTile(dir); await sleep(80); } await sleep(400); } // Submit score regardless of whether it beat local best — backend // de-dupes to keep one best entry per (user, difficulty). if (finalRound > 0 && robot.username) { ui.setLeaderboardStatus("Saving…"); const entries = await saveScore({ robot, difficulty: state.difficulty, score: finalRound, displayName: robot.username, avatarUrl: "", }); if (entries) { ui.setLeaderboardStatus("✓ Saved", "ok"); ui.renderLeaderboard(entries, state.difficulty, robot.username); } else { ui.setLeaderboardStatus("Could not save", "err"); } } // Return to selection mode: release left antenna again. motor.goto(motor.NEUTRAL_TARGET, 1.0); await sleep(1100); motor.releaseLeftAntenna(); } // ─── Bootstrap ────────────────────────────────────────────────────── async function bootstrap() { window.addEventListener("pagehide", () => { try { audio.detachFromRobot(); } catch {} }); try { const authed = await robot.authenticate(); state.authChecking = false; if (authed) { await robot.connect(); robot.addEventListener("robotsChanged", render); render(); return; } } catch (e) { console.warn("auto-connect failed:", e); } render(); } bootstrap();