Spaces:
Running
Running
| /** | |
| * 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(); | |