apirrone
Settle at neutral before accepting input; remove cheat-y sequence strip
7ac5e77
Raw
History Blame Contribute Delete
10.9 kB
/**
* 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();