Add Pi Trivia Blitz, Projectile Pi games + expand math questions
Torey Heinz
committed Mar 14, 2026
commit 3d4756e6693d4fa4fa0cad231dcb5f88dc4aa176
Showing 9
changed files with
821 additions
and 64 deletions
assets/js/app.js
+5
-3
| @@ | @@ -38,9 +38,11 @@ const AVATAR_COLORS = { |
| } | |
| const STATIONS = [ | |
| - | { x: 150, y: 150, label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: "#06b6d4" }, |
| - | { x: 650, y: 150, label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: "#8b5cf6" }, |
| - | { x: 400, y: 450, label: "Slice\nthe Pi", icon: "\u{1FA93}", color: "#f59e0b" }, |
| + | { x: 130, y: 130, label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: "#06b6d4" }, |
| + | { x: 670, y: 130, label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: "#8b5cf6" }, |
| + | { x: 130, y: 460, label: "Slice\nthe Pi", icon: "\u{1FA93}", color: "#f59e0b" }, |
| + | { x: 670, y: 460, label: "Pi Trivia\nBlitz", icon: "\u{1F4A1}", color: "#ec4899" }, |
| + | { x: 400, y: 300, label: "Projectile\nPi", icon: "\u{1F680}", color: "#22c55e" }, |
| ] | |
| const SpectateHub = { | |
assets/js/game.js
+11
-1
| @@ | @@ -4,6 +4,8 @@ import { HubScene } from "./game/HubScene" |
| import { PiMemoryGame } from "./game/PiMemoryGame" | |
| import { MonteCarloGame } from "./game/MonteCarloGame" | |
| import { SliceThePiGame } from "./game/SliceThePiGame" | |
| + | import { TriviaGame } from "./game/TriviaGame" |
| + | import { ProjectileGame } from "./game/ProjectileGame" |
| import { SoundFX } from "./game/SoundFX" | |
| // --- Phoenix Socket Connection --- | |
| @@ | @@ -80,6 +82,12 @@ window.piStation = { |
| case "slice_the_pi": | |
| SliceThePiGame.start(content, channel) | |
| break | |
| + | case "pi_trivia": |
| + | TriviaGame.start(content, channel) |
| + | break |
| + | case "projectile_pi": |
| + | ProjectileGame.start(content, channel) |
| + | break |
| } | |
| window.piStation._currentChannel = channel | |
| @@ | @@ -112,8 +120,10 @@ window.piStation = { |
| // Station prompt โ called from HubScene | |
| showStationPrompt(station) { | |
| const el = document.getElementById("station-prompt") | |
| + | const isMobile = "ontouchstart" in window |
| if (station) { | |
| - | el.textContent = `Tap to play ${station.label.replace("\n", " ")}!` |
| + | const action = isMobile ? "Tap" : "Press SPACE" |
| + | el.textContent = `${action} to play ${station.label.replace("\n", " ")}!` |
| el.classList.add("visible") | |
| el.onclick = () => window.piStation.openMiniGame(station.game) | |
| } else { | |
assets/js/game/HubScene.js
+13
-3
| @@ | @@ -14,9 +14,11 @@ const AVATAR_COLORS = { |
| } | |
| const STATIONS = [ | |
| - | { x: 150, y: 150, game: "pi_memory", label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: 0x06b6d4 }, |
| - | { x: 650, y: 150, game: "monte_carlo", label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: 0x8b5cf6 }, |
| - | { x: 400, y: 450, game: "slice_the_pi", label: "Slice\nthe Pi", icon: "\u{1FA93}", color: 0xf59e0b }, |
| + | { x: 130, y: 130, game: "pi_memory", label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: 0x06b6d4 }, |
| + | { x: 670, y: 130, game: "monte_carlo", label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: 0x8b5cf6 }, |
| + | { x: 130, y: 460, game: "slice_the_pi", label: "Slice\nthe Pi", icon: "\u{1FA93}", color: 0xf59e0b }, |
| + | { x: 670, y: 460, game: "pi_trivia", label: "Pi Trivia\nBlitz", icon: "\u{1F4A1}", color: 0xec4899 }, |
| + | { x: 400, y: 300, game: "projectile_pi", label: "Projectile\nPi", icon: "\u{1F680}", color: 0x22c55e }, |
| ] | |
| export class HubScene extends Phaser.Scene { | |
| @@ | @@ -198,6 +200,14 @@ export class HubScene extends Phaser.Scene { |
| right: Phaser.Input.Keyboard.KeyCodes.D, | |
| }) | |
| + | // Space to enter nearby station |
| + | this.input.keyboard.on("keydown-SPACE", () => { |
| + | if (document.activeElement === document.getElementById("chat-input")) return |
| + | if (this.nearStation) { |
| + | window.piStation.openMiniGame(this.nearStation.game) |
| + | } |
| + | }) |
| + | |
| // Mobile joystick | |
| if ("ontouchstart" in window) { | |
| const joystickZone = document.createElement("div") | |
assets/js/game/ProjectileGame.js
+334
-0
| @@ | @@ -0,0 +1,334 @@ |
| + | const GRAVITY = 9.81 |
| + | const MAX_ATTEMPTS = 5 |
| + | const CANVAS_W = 450 |
| + | const CANVAS_H = 250 |
| + | const SCALE = 4 // pixels per meter |
| + | |
| + | export const ProjectileGame = { |
| + | start(container, channel) { |
| + | while (container.firstChild) container.removeChild(container.firstChild) |
| + | |
| + | let attempts = 0 |
| + | let bestError = Infinity |
| + | let bestScore = 0 |
| + | let targetX = 0 |
| + | let angle = 45 |
| + | let velocity = 20 |
| + | let animating = false |
| + | let totalScore = 0 |
| + | let round = 0 |
| + | const TOTAL_ROUNDS = 3 |
| + | |
| + | const title = document.createElement("div") |
| + | title.className = "mg-title" |
| + | title.textContent = "Projectile Pi" |
| + | |
| + | const subtitle = document.createElement("div") |
| + | subtitle.className = "mg-subtitle" |
| + | subtitle.textContent = "Launch to hit the target! Adjust angle and velocity." |
| + | |
| + | const canvas = document.createElement("canvas") |
| + | canvas.width = CANVAS_W |
| + | canvas.height = CANVAS_H |
| + | canvas.style.cssText = "border-radius:0.75rem;border:2px solid rgba(255,255,255,0.2);max-width:100%;background:#0f0a2e;" |
| + | const ctx = canvas.getContext("2d") |
| + | |
| + | const statsEl = document.createElement("div") |
| + | statsEl.style.cssText = "color:#a78bfa;font-size:0.85rem;margin:0.5rem 0;min-height:1.5rem;" |
| + | |
| + | // Angle control |
| + | const angleRow = createSlider("Angle", 5, 85, 45, "\u00B0", (v) => { angle = v; drawScene() }) |
| + | // Velocity control |
| + | const velRow = createSlider("Velocity", 5, 50, 20, " m/s", (v) => { velocity = v; drawScene() }) |
| + | |
| + | const launchBtn = document.createElement("button") |
| + | launchBtn.className = "mg-btn" |
| + | launchBtn.textContent = "Launch!" |
| + | launchBtn.addEventListener("click", () => launch()) |
| + | |
| + | const feedbackEl = document.createElement("div") |
| + | feedbackEl.style.cssText = "min-height: 2rem; margin: 0.5rem 0; font-size: 1rem;" |
| + | |
| + | const resultArea = document.createElement("div") |
| + | resultArea.style.cssText = "margin-top: 0.5rem;" |
| + | |
| + | container.appendChild(title) |
| + | container.appendChild(subtitle) |
| + | container.appendChild(canvas) |
| + | container.appendChild(statsEl) |
| + | container.appendChild(angleRow.container) |
| + | container.appendChild(velRow.container) |
| + | container.appendChild(launchBtn) |
| + | container.appendChild(feedbackEl) |
| + | container.appendChild(resultArea) |
| + | |
| + | function startRound() { |
| + | round++ |
| + | attempts = 0 |
| + | bestError = Infinity |
| + | bestScore = 0 |
| + | feedbackEl.textContent = "" |
| + | launchBtn.style.display = "" |
| + | |
| + | channel.push("projectile_get_target", {}).receive("ok", (data) => { |
| + | targetX = data.target_x |
| + | statsEl.textContent = `Round ${round}/${TOTAL_ROUNDS} | Target: ${targetX}m (\u2248 ${(targetX / Math.PI).toFixed(0)}\u03C0) | Attempts: ${attempts}/${MAX_ATTEMPTS}` |
| + | drawScene() |
| + | }) |
| + | } |
| + | |
| + | function drawScene(trail) { |
| + | ctx.clearRect(0, 0, CANVAS_W, CANVAS_H) |
| + | |
| + | // Sky gradient |
| + | const grad = ctx.createLinearGradient(0, 0, 0, CANVAS_H) |
| + | grad.addColorStop(0, "#0a0a2e") |
| + | grad.addColorStop(1, "#1a1040") |
| + | ctx.fillStyle = grad |
| + | ctx.fillRect(0, 0, CANVAS_W, CANVAS_H) |
| + | |
| + | // Ground |
| + | ctx.fillStyle = "#1a3a1a" |
| + | ctx.fillRect(0, CANVAS_H - 20, CANVAS_W, 20) |
| + | |
| + | // Grid markers |
| + | ctx.font = "9px monospace" |
| + | ctx.fillStyle = "#555" |
| + | ctx.textAlign = "center" |
| + | for (let m = 20; m <= 120; m += 20) { |
| + | const px = m * SCALE |
| + | if (px < CANVAS_W) { |
| + | ctx.fillText(`${m}m`, px, CANVAS_H - 5) |
| + | ctx.strokeStyle = "rgba(255,255,255,0.05)" |
| + | ctx.beginPath() |
| + | ctx.moveTo(px, 0) |
| + | ctx.lineTo(px, CANVAS_H - 20) |
| + | ctx.stroke() |
| + | } |
| + | } |
| + | |
| + | // Target |
| + | if (targetX > 0) { |
| + | const tx = targetX * SCALE |
| + | ctx.fillStyle = "#ef4444" |
| + | ctx.fillRect(tx - 3, CANVAS_H - 35, 6, 15) |
| + | // Flag |
| + | ctx.fillStyle = "#ef4444" |
| + | ctx.beginPath() |
| + | ctx.moveTo(tx + 3, CANVAS_H - 35) |
| + | ctx.lineTo(tx + 18, CANVAS_H - 28) |
| + | ctx.lineTo(tx + 3, CANVAS_H - 21) |
| + | ctx.fill() |
| + | // Label |
| + | ctx.font = "10px monospace" |
| + | ctx.fillStyle = "#ef4444" |
| + | ctx.textAlign = "center" |
| + | ctx.fillText(`${targetX}m`, tx, CANVAS_H - 40) |
| + | } |
| + | |
| + | // Launcher |
| + | const launchX = 15 |
| + | const launchY = CANVAS_H - 20 |
| + | ctx.save() |
| + | ctx.translate(launchX, launchY) |
| + | ctx.rotate(-angle * Math.PI / 180) |
| + | ctx.fillStyle = "#06b6d4" |
| + | ctx.fillRect(0, -3, 20, 6) |
| + | ctx.restore() |
| + | ctx.beginPath() |
| + | ctx.arc(launchX, launchY, 6, 0, Math.PI * 2) |
| + | ctx.fillStyle = "#06b6d4" |
| + | ctx.fill() |
| + | |
| + | // Trajectory preview (dotted) |
| + | ctx.setLineDash([3, 4]) |
| + | ctx.strokeStyle = "rgba(6, 182, 212, 0.3)" |
| + | ctx.beginPath() |
| + | const vx = velocity * Math.cos(angle * Math.PI / 180) |
| + | const vy = velocity * Math.sin(angle * Math.PI / 180) |
| + | for (let t = 0; t < 8; t += 0.1) { |
| + | const px = launchX + vx * t * SCALE |
| + | const py = launchY - (vy * t - 0.5 * GRAVITY * t * t) * SCALE |
| + | if (t === 0) ctx.moveTo(px, py) |
| + | else ctx.lineTo(px, py) |
| + | if (py > launchY) break |
| + | } |
| + | ctx.stroke() |
| + | ctx.setLineDash([]) |
| + | |
| + | // Draw trails from previous attempts |
| + | if (trail) { |
| + | ctx.strokeStyle = "rgba(139, 92, 246, 0.6)" |
| + | ctx.lineWidth = 2 |
| + | ctx.beginPath() |
| + | trail.forEach((p, i) => { |
| + | if (i === 0) ctx.moveTo(p.x, p.y) |
| + | else ctx.lineTo(p.x, p.y) |
| + | }) |
| + | ctx.stroke() |
| + | ctx.lineWidth = 1 |
| + | |
| + | // Impact point |
| + | const last = trail[trail.length - 1] |
| + | ctx.beginPath() |
| + | ctx.arc(last.x, last.y, 4, 0, Math.PI * 2) |
| + | ctx.fillStyle = "#8b5cf6" |
| + | ctx.fill() |
| + | } |
| + | } |
| + | |
| + | function launch() { |
| + | if (animating || attempts >= MAX_ATTEMPTS) return |
| + | animating = true |
| + | attempts++ |
| + | if (window.SoundFX) window.SoundFX.dart() |
| + | |
| + | const launchX = 15 |
| + | const launchY = CANVAS_H - 20 |
| + | const vx = velocity * Math.cos(angle * Math.PI / 180) |
| + | const vy = velocity * Math.sin(angle * Math.PI / 180) |
| + | |
| + | const trail = [] |
| + | let t = 0 |
| + | const dt = 0.03 |
| + | |
| + | function animate() { |
| + | t += dt |
| + | const px = launchX + vx * t * SCALE |
| + | const py = launchY - (vy * t - 0.5 * GRAVITY * t * t) * SCALE |
| + | trail.push({ x: px, y: py }) |
| + | |
| + | drawScene(trail) |
| + | |
| + | // Draw projectile |
| + | ctx.beginPath() |
| + | ctx.arc(px, py, 5, 0, Math.PI * 2) |
| + | ctx.fillStyle = "#f59e0b" |
| + | ctx.fill() |
| + | ctx.strokeStyle = "#fff" |
| + | ctx.lineWidth = 1 |
| + | ctx.stroke() |
| + | |
| + | if (py < launchY && px < CANVAS_W) { |
| + | requestAnimationFrame(animate) |
| + | } else { |
| + | // Landed |
| + | animating = false |
| + | const landingM = (px - launchX) / SCALE |
| + | const error = Math.abs(landingM - targetX) |
| + | const pts = Math.max(0, Math.round(500 - error * 50)) |
| + | |
| + | if (error < bestError) { |
| + | bestError = error |
| + | bestScore = pts |
| + | } |
| + | |
| + | statsEl.textContent = `Round ${round}/${TOTAL_ROUNDS} | Target: ${targetX}m | Attempts: ${attempts}/${MAX_ATTEMPTS} | Landing: ${landingM.toFixed(1)}m` |
| + | |
| + | if (error < 1) { |
| + | feedbackEl.style.color = "#22c55e" |
| + | feedbackEl.textContent = `Bullseye! ${landingM.toFixed(1)}m (error: ${error.toFixed(2)}m) +${pts}pts` |
| + | if (window.SoundFX) window.SoundFX.milestone() |
| + | } else if (error < 5) { |
| + | feedbackEl.style.color = "#f59e0b" |
| + | feedbackEl.textContent = `Close! ${landingM.toFixed(1)}m (error: ${error.toFixed(2)}m) +${pts}pts` |
| + | if (window.SoundFX) window.SoundFX.correct() |
| + | } else { |
| + | feedbackEl.style.color = "#ef4444" |
| + | feedbackEl.textContent = `${landingM.toFixed(1)}m โ ${error.toFixed(1)}m off! +${pts}pts` |
| + | if (window.SoundFX) window.SoundFX.wrong() |
| + | } |
| + | |
| + | if (attempts >= MAX_ATTEMPTS) { |
| + | totalScore += bestScore |
| + | setTimeout(() => { |
| + | if (round >= TOTAL_ROUNDS) { |
| + | endGame() |
| + | } else { |
| + | feedbackEl.textContent += ` | Best this round: ${bestScore}pts. Next round...` |
| + | setTimeout(startRound, 1500) |
| + | } |
| + | }, 1000) |
| + | } |
| + | } |
| + | } |
| + | |
| + | requestAnimationFrame(animate) |
| + | } |
| + | |
| + | function endGame() { |
| + | launchBtn.style.display = "none" |
| + | |
| + | channel.push("projectile_submit", { |
| + | score: totalScore, |
| + | target_x: targetX, |
| + | best_error: parseFloat(bestError.toFixed(2)) |
| + | }).receive("ok", () => { |
| + | if (window.SoundFX) window.SoundFX.score() |
| + | |
| + | while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild) |
| + | |
| + | const result = document.createElement("div") |
| + | result.style.cssText = "font-size: 1.5rem; color: #22d3ee; margin-bottom: 0.5rem;" |
| + | result.textContent = `Total Score: ${totalScore}` |
| + | |
| + | const playAgain = document.createElement("button") |
| + | playAgain.className = "mg-btn" |
| + | playAgain.textContent = "Play Again" |
| + | playAgain.addEventListener("click", () => ProjectileGame.start(container, channel)) |
| + | |
| + | const close = document.createElement("button") |
| + | close.className = "mg-btn secondary" |
| + | close.textContent = "Back to Hub" |
| + | close.addEventListener("click", () => window.piStation.closeMiniGame()) |
| + | |
| + | resultArea.appendChild(result) |
| + | resultArea.appendChild(playAgain) |
| + | resultArea.appendChild(close) |
| + | }) |
| + | } |
| + | |
| + | channel.on("projectile_score", ({ name, score: s }) => { |
| + | if (name === window.PLAYER_NAME) return |
| + | const el = document.createElement("div") |
| + | el.style.cssText = "color: #22d3ee; font-size: 0.8rem; margin-top: 0.25rem;" |
| + | el.textContent = `${name} scored ${s} points!` |
| + | resultArea.appendChild(el) |
| + | setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 5000) |
| + | }) |
| + | |
| + | startRound() |
| + | } |
| + | } |
| + | |
| + | function createSlider(label, min, max, initial, unit, onChange) { |
| + | const container = document.createElement("div") |
| + | container.style.cssText = "display:flex;align-items:center;gap:0.75rem;margin:0.4rem auto;max-width:400px;color:white;" |
| + | |
| + | const labelEl = document.createElement("span") |
| + | labelEl.style.cssText = "width:60px;font-size:0.85rem;text-align:right;" |
| + | labelEl.textContent = label |
| + | |
| + | const slider = document.createElement("input") |
| + | slider.type = "range" |
| + | slider.min = min |
| + | slider.max = max |
| + | slider.value = initial |
| + | slider.style.cssText = "flex:1;accent-color:#8b5cf6;height:24px;" |
| + | |
| + | const valueEl = document.createElement("span") |
| + | valueEl.style.cssText = "width:65px;font-size:0.9rem;color:#22d3ee;font-weight:bold;" |
| + | valueEl.textContent = `${initial}${unit}` |
| + | |
| + | slider.addEventListener("input", () => { |
| + | const v = parseInt(slider.value) |
| + | valueEl.textContent = `${v}${unit}` |
| + | onChange(v) |
| + | }) |
| + | |
| + | container.appendChild(labelEl) |
| + | container.appendChild(slider) |
| + | container.appendChild(valueEl) |
| + | |
| + | return { container, slider, valueEl } |
| + | } |
assets/js/game/TriviaGame.js
+219
-0
| @@ | @@ -0,0 +1,219 @@ |
| + | const TOTAL_QUESTIONS = 10 |
| + | const ROUND_TIME = 15 |
| + | |
| + | export const TriviaGame = { |
| + | start(container, channel) { |
| + | while (container.firstChild) container.removeChild(container.firstChild) |
| + | |
| + | let score = 0 |
| + | let streak = 0 |
| + | let correctCount = 0 |
| + | let questionNum = 0 |
| + | let timerInterval = null |
| + | let questionStartTime = null |
| + | let answering = false |
| + | |
| + | const title = document.createElement("div") |
| + | title.className = "mg-title" |
| + | title.textContent = "Pi Trivia Blitz" |
| + | |
| + | const subtitle = document.createElement("div") |
| + | subtitle.className = "mg-subtitle" |
| + | subtitle.textContent = "Math, science & Pi history! Speed gives bonus points." |
| + | |
| + | const timerEl = document.createElement("div") |
| + | timerEl.className = "slice-timer" |
| + | |
| + | const progressEl = document.createElement("div") |
| + | progressEl.style.cssText = "color: #a78bfa; font-size: 0.9rem; margin: 0.25rem 0;" |
| + | |
| + | const scoreEl = document.createElement("div") |
| + | scoreEl.className = "slice-score-display" |
| + | scoreEl.textContent = "Score: 0 | Streak: 0" |
| + | |
| + | const questionEl = document.createElement("div") |
| + | questionEl.className = "slice-question" |
| + | questionEl.style.fontSize = "1.1rem" |
| + | questionEl.style.minHeight = "3rem" |
| + | |
| + | const choicesEl = document.createElement("div") |
| + | choicesEl.style.cssText = "display:grid;grid-template-columns:1fr;gap:0.5rem;max-width:450px;margin:0.75rem auto;" |
| + | |
| + | const feedbackEl = document.createElement("div") |
| + | feedbackEl.style.cssText = "min-height: 2rem; margin: 0.5rem 0; font-size: 1rem;" |
| + | |
| + | const resultArea = document.createElement("div") |
| + | resultArea.style.cssText = "margin-top: 1rem;" |
| + | |
| + | container.appendChild(title) |
| + | container.appendChild(subtitle) |
| + | container.appendChild(timerEl) |
| + | container.appendChild(progressEl) |
| + | container.appendChild(scoreEl) |
| + | container.appendChild(questionEl) |
| + | container.appendChild(choicesEl) |
| + | container.appendChild(feedbackEl) |
| + | container.appendChild(resultArea) |
| + | |
| + | function nextQuestion() { |
| + | if (questionNum >= TOTAL_QUESTIONS) { endGame(); return } |
| + | |
| + | answering = true |
| + | questionNum++ |
| + | progressEl.textContent = `Question ${questionNum} / ${TOTAL_QUESTIONS}` |
| + | feedbackEl.textContent = "" |
| + | |
| + | channel.push("trivia_get_question", {}).receive("ok", (problem) => { |
| + | questionEl.textContent = problem.question |
| + | questionStartTime = Date.now() |
| + | |
| + | while (choicesEl.firstChild) choicesEl.removeChild(choicesEl.firstChild) |
| + | |
| + | problem.choices.forEach(choice => { |
| + | const btn = document.createElement("button") |
| + | btn.className = "slice-choice" |
| + | btn.style.cssText = "padding:0.75rem 1rem;font-size:1rem;background:rgba(255,255,255,0.1);border:2px solid rgba(255,255,255,0.2);border-radius:0.75rem;color:white;cursor:pointer;text-align:left;-webkit-tap-highlight-color:transparent;" |
| + | btn.textContent = choice |
| + | btn.addEventListener("click", () => handleAnswer(choice, problem.answer, btn)) |
| + | choicesEl.appendChild(btn) |
| + | }) |
| + | |
| + | let timeLeft = ROUND_TIME |
| + | timerEl.textContent = timeLeft |
| + | timerEl.style.color = "#22d3ee" |
| + | |
| + | if (timerInterval) clearInterval(timerInterval) |
| + | timerInterval = setInterval(() => { |
| + | timeLeft-- |
| + | timerEl.textContent = timeLeft |
| + | if (timeLeft <= 3) { |
| + | timerEl.style.color = "#ef4444" |
| + | if (window.SoundFX) window.SoundFX.tick() |
| + | } else if (timeLeft <= 5) { |
| + | timerEl.style.color = "#f59e0b" |
| + | } |
| + | if (timeLeft <= 0) { clearInterval(timerInterval); handleTimeout(problem.answer) } |
| + | }, 1000) |
| + | }) |
| + | } |
| + | |
| + | function handleAnswer(chosen, correct, btnEl) { |
| + | if (!answering) return |
| + | answering = false |
| + | clearInterval(timerInterval) |
| + | |
| + | const timeMs = Date.now() - questionStartTime |
| + | const isCorrect = chosen === correct |
| + | |
| + | const buttons = choicesEl.querySelectorAll("button") |
| + | buttons.forEach(btn => { |
| + | if (btn.textContent === correct) { |
| + | btn.style.background = "rgba(34, 197, 94, 0.4)" |
| + | btn.style.borderColor = "#22c55e" |
| + | } else if (btn === btnEl && !isCorrect) { |
| + | btn.style.background = "rgba(239, 68, 68, 0.4)" |
| + | btn.style.borderColor = "#ef4444" |
| + | } |
| + | btn.style.pointerEvents = "none" |
| + | }) |
| + | |
| + | if (isCorrect) { |
| + | streak++ |
| + | correctCount++ |
| + | if (window.SoundFX) window.SoundFX.correct() |
| + | } else { |
| + | streak = 0 |
| + | if (window.SoundFX) window.SoundFX.wrong() |
| + | } |
| + | |
| + | channel.push("trivia_answer", { correct: isCorrect, time_ms: timeMs, streak }).receive("ok", ({ points }) => { |
| + | score += points |
| + | scoreEl.textContent = `Score: ${score} | Streak: ${streak}` |
| + | if (isCorrect) { |
| + | feedbackEl.style.color = "#22c55e" |
| + | feedbackEl.textContent = `Correct! +${points} pts (${(timeMs / 1000).toFixed(1)}s)` |
| + | } else { |
| + | feedbackEl.style.color = "#ef4444" |
| + | feedbackEl.textContent = `Wrong! Answer: ${correct}` |
| + | } |
| + | setTimeout(nextQuestion, 2000) |
| + | }) |
| + | } |
| + | |
| + | function handleTimeout(correct) { |
| + | answering = false |
| + | streak = 0 |
| + | if (window.SoundFX) window.SoundFX.wrong() |
| + | feedbackEl.style.color = "#ef4444" |
| + | feedbackEl.textContent = `Time's up! Answer: ${correct}` |
| + | scoreEl.textContent = `Score: ${score} | Streak: ${streak}` |
| + | |
| + | const buttons = choicesEl.querySelectorAll("button") |
| + | buttons.forEach(btn => { |
| + | if (btn.textContent === correct) { |
| + | btn.style.background = "rgba(34, 197, 94, 0.4)" |
| + | btn.style.borderColor = "#22c55e" |
| + | } |
| + | btn.style.pointerEvents = "none" |
| + | }) |
| + | |
| + | channel.push("trivia_answer", { correct: false, time_ms: ROUND_TIME * 1000, streak: 0 }) |
| + | setTimeout(nextQuestion, 2000) |
| + | } |
| + | |
| + | function endGame() { |
| + | clearInterval(timerInterval) |
| + | questionEl.textContent = "" |
| + | while (choicesEl.firstChild) choicesEl.removeChild(choicesEl.firstChild) |
| + | timerEl.textContent = "" |
| + | |
| + | if (window.SoundFX) window.SoundFX.score() |
| + | |
| + | channel.push("trivia_game_over", { score, correct: correctCount, total: TOTAL_QUESTIONS }).receive("ok", () => { |
| + | while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild) |
| + | |
| + | const result = document.createElement("div") |
| + | result.style.cssText = "font-size: 1.5rem; color: #22d3ee; margin-bottom: 0.5rem;" |
| + | result.textContent = `Final Score: ${score}` |
| + | |
| + | const details = document.createElement("div") |
| + | details.style.cssText = "color: #a78bfa; margin-bottom: 1rem;" |
| + | details.textContent = `${correctCount} / ${TOTAL_QUESTIONS} correct` |
| + | |
| + | const playAgain = document.createElement("button") |
| + | playAgain.className = "mg-btn" |
| + | playAgain.textContent = "Play Again" |
| + | playAgain.addEventListener("click", () => TriviaGame.start(container, channel)) |
| + | |
| + | const close = document.createElement("button") |
| + | close.className = "mg-btn secondary" |
| + | close.textContent = "Back to Hub" |
| + | close.addEventListener("click", () => window.piStation.closeMiniGame()) |
| + | |
| + | resultArea.appendChild(result) |
| + | resultArea.appendChild(details) |
| + | resultArea.appendChild(playAgain) |
| + | resultArea.appendChild(close) |
| + | }) |
| + | } |
| + | |
| + | channel.on("trivia_correct", ({ name, streak: s }) => { |
| + | if (name === window.PLAYER_NAME) return |
| + | const el = document.createElement("div") |
| + | el.style.cssText = "color: #a78bfa; font-size: 0.75rem;" |
| + | el.textContent = `${name} got one right! (streak: ${s})` |
| + | feedbackEl.appendChild(el) |
| + | setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 3000) |
| + | }) |
| + | |
| + | channel.on("trivia_score", ({ name, score: s }) => { |
| + | const el = document.createElement("div") |
| + | el.style.cssText = "color: #22d3ee; font-size: 0.8rem; margin-top: 0.25rem;" |
| + | el.textContent = `${name} finished with ${s} points!` |
| + | resultArea.appendChild(el) |
| + | setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 5000) |
| + | }) |
| + | |
| + | nextQuestion() |
| + | } |
| + | } |
pi_day/game.ex b/lib/pi_day/game.ex
+147
-37
| @@ | @@ -77,70 +77,180 @@ defmodule PiDay.Game do |
| # --- Math problems for Slice the Pi --- | |
| + | @problem_types [ |
| + | :circumference, :area, :arc_length, :sector_area, |
| + | :sphere_volume, :sphere_surface, :cylinder_volume, |
| + | :radians, :trig_value, :diameter_from_area |
| + | ] |
| + | |
| def generate_math_problem do | |
| - | type = Enum.random([:circumference, :area, :arc_length, :sector_area]) |
| + | type = Enum.random(@problem_types) |
| generate_problem(type) | |
| end | |
| defp generate_problem(:circumference) do | |
| r = Enum.random(1..20) | |
| answer = Float.round(2 * :math.pi() * r, 2) | |
| - | wrong = generate_wrong_answers(answer) |
| - | |
| - | %{ |
| - | question: "What is the circumference of a circle with radius #{r}?", |
| - | answer: answer, |
| - | choices: Enum.shuffle([answer | wrong]), |
| - | type: :circumference |
| - | } |
| + | make_problem("Circumference of a circle with radius #{r}?", answer) |
| end | |
| defp generate_problem(:area) do | |
| r = Enum.random(1..15) | |
| answer = Float.round(:math.pi() * r * r, 2) | |
| - | wrong = generate_wrong_answers(answer) |
| - | |
| - | %{ |
| - | question: "What is the area of a circle with radius #{r}?", |
| - | answer: answer, |
| - | choices: Enum.shuffle([answer | wrong]), |
| - | type: :area |
| - | } |
| + | make_problem("Area of a circle with radius #{r}?", answer) |
| end | |
| defp generate_problem(:arc_length) do | |
| r = Enum.random(2..12) | |
| angle = Enum.random([30, 45, 60, 90, 120, 180]) | |
| answer = Float.round(2 * :math.pi() * r * (angle / 360), 2) | |
| - | wrong = generate_wrong_answers(answer) |
| - | |
| - | %{ |
| - | question: "Arc length: radius=#{r}, central angle=#{angle} degrees?", |
| - | answer: answer, |
| - | choices: Enum.shuffle([answer | wrong]), |
| - | type: :arc_length |
| - | } |
| + | make_problem("Arc length: radius=#{r}, angle=#{angle}\u00B0?", answer) |
| end | |
| defp generate_problem(:sector_area) do | |
| r = Enum.random(2..10) | |
| angle = Enum.random([30, 45, 60, 90, 120, 180]) | |
| answer = Float.round(:math.pi() * r * r * (angle / 360), 2) | |
| - | wrong = generate_wrong_answers(answer) |
| + | make_problem("Sector area: radius=#{r}, angle=#{angle}\u00B0?", answer) |
| + | end |
| + | |
| + | defp generate_problem(:sphere_volume) do |
| + | r = Enum.random(1..8) |
| + | answer = Float.round(4 / 3 * :math.pi() * r * r * r, 2) |
| + | make_problem("Volume of a sphere with radius #{r}?", answer) |
| + | end |
| + | |
| + | defp generate_problem(:sphere_surface) do |
| + | r = Enum.random(1..10) |
| + | answer = Float.round(4 * :math.pi() * r * r, 2) |
| + | make_problem("Surface area of a sphere with radius #{r}?", answer) |
| + | end |
| + | |
| + | defp generate_problem(:cylinder_volume) do |
| + | r = Enum.random(1..8) |
| + | h = Enum.random(2..10) |
| + | answer = Float.round(:math.pi() * r * r * h, 2) |
| + | make_problem("Volume of cylinder: radius=#{r}, height=#{h}?", answer) |
| + | end |
| + | |
| + | defp generate_problem(:radians) do |
| + | {deg, rad_num, rad_den} = Enum.random([{30, 1, 6}, {45, 1, 4}, {60, 1, 3}, {90, 1, 2}, {120, 2, 3}, {180, 1, 1}, {270, 3, 2}, {360, 2, 1}]) |
| + | answer = Float.round(:math.pi() * rad_num / rad_den, 2) |
| + | make_problem("Convert #{deg}\u00B0 to radians?", answer) |
| + | end |
| + | |
| + | defp generate_problem(:trig_value) do |
| + | {label, value} = |
| + | Enum.random([ |
| + | {"sin(30\u00B0)", 0.5}, |
| + | {"cos(60\u00B0)", 0.5}, |
| + | {"sin(90\u00B0)", 1.0}, |
| + | {"cos(0\u00B0)", 1.0}, |
| + | {"sin(45\u00B0)", 0.71}, |
| + | {"cos(45\u00B0)", 0.71}, |
| + | {"sin(0\u00B0)", 0.0}, |
| + | {"cos(90\u00B0)", 0.0}, |
| + | {"tan(45\u00B0)", 1.0}, |
| + | {"sin(60\u00B0)", 0.87}, |
| + | {"cos(30\u00B0)", 0.87} |
| + | ]) |
| + | |
| + | answer = Float.round(value, 2) |
| + | wrong = generate_trig_wrong(answer) |
| + | %{question: "What is #{label}?", answer: answer, choices: Enum.shuffle([answer | wrong])} |
| + | end |
| - | %{ |
| - | question: "Sector area: radius=#{r}, angle=#{angle} degrees?", |
| - | answer: answer, |
| - | choices: Enum.shuffle([answer | wrong]), |
| - | type: :sector_area |
| - | } |
| + | defp generate_problem(:diameter_from_area) do |
| + | d = Enum.random(2..16) |
| + | area = Float.round(:math.pi() * (d / 2) * (d / 2), 2) |
| + | answer = d * 1.0 |
| + | wrong = [d + 2.0, d - 1.0, d * 1.5] |> Enum.map(&Float.round(&1, 2)) |
| + | %{question: "Circle has area #{area}. What is the diameter?", answer: answer, choices: Enum.shuffle([answer | wrong])} |
| + | end |
| + | |
| + | defp make_problem(question, answer) do |
| + | %{question: question, answer: answer, choices: Enum.shuffle([answer | generate_wrong_answers(answer)])} |
| + | end |
| + | |
| + | defp generate_wrong_answers(correct) when correct == 0.0 do |
| + | [0.5, 1.0, -1.0] |
| end | |
| defp generate_wrong_answers(correct) do | |
| - | [ |
| - | Float.round(correct * 0.5, 2), |
| - | Float.round(correct * 1.5, 2), |
| - | Float.round(correct + :math.pi(), 2) |
| - | ] |
| + | offsets = [0.5, 1.5, 1.0 + :math.pi() / correct] |
| + | Enum.map(offsets, fn mult -> Float.round(correct * mult, 2) end) |
| + | |> Enum.uniq() |
| + | |> Enum.reject(&(&1 == correct)) |
| + | |> Enum.take(3) |
| + | |> case do |
| + | list when length(list) < 3 -> |
| + | list ++ Enum.map(1..(3 - length(list)), fn i -> Float.round(correct + i * 1.11, 2) end) |
| + | |
| + | list -> |
| + | list |
| + | end |
| + | end |
| + | |
| + | defp generate_trig_wrong(correct) do |
| + | candidates = [0.0, 0.25, 0.5, 0.71, 0.87, 1.0, 1.41, 1.73, -0.5, -1.0] |
| + | candidates |
| + | |> Enum.reject(&(&1 == correct)) |
| + | |> Enum.shuffle() |
| + | |> Enum.take(3) |
| + | end |
| + | |
| + | # --- Trivia Questions --- |
| + | |
| + | @trivia [ |
| + | %{q: "What is the 3rd digit of Pi after the decimal?", a: "1", choices: ["1", "4", "5", "9"]}, |
| + | %{q: "Who first proved that Pi is irrational?", a: "Johann Lambert", choices: ["Johann Lambert", "Leonhard Euler", "Isaac Newton", "Archimedes"]}, |
| + | %{q: "What is Euler's identity: e^(i\u03C0) + 1 = ?", a: "0", choices: ["0", "1", "\u03C0", "i"]}, |
| + | %{q: "Pi Day (3/14) is also whose birthday?", a: "Albert Einstein", choices: ["Albert Einstein", "Isaac Newton", "Stephen Hawking", "Nikola Tesla"]}, |
| + | %{q: "Approximately how many digits of Pi have been computed?", a: "Over 100 trillion", choices: ["Over 100 trillion", "About 1 billion", "About 10 billion", "Over 1 quadrillion"]}, |
| + | %{q: "What ancient civilization first estimated Pi?", a: "Babylonians", choices: ["Babylonians", "Romans", "Chinese", "Aztecs"]}, |
| + | %{q: "The ratio of a circle's circumference to its diameter is:", a: "Pi", choices: ["Pi", "2\u03C0", "Tau", "Phi"]}, |
| + | %{q: "What is Tau (\u03C4)?", a: "2\u03C0", choices: ["2\u03C0", "\u03C0/2", "\u03C0\u00B2", "\u221A\u03C0"]}, |
| + | %{q: "The Golden Ratio (Phi) is approximately:", a: "1.618", choices: ["1.618", "3.14", "2.718", "1.414"]}, |
| + | %{q: "What is e (Euler's number) approximately?", a: "2.718", choices: ["2.718", "3.14", "1.618", "2.236"]}, |
| + | %{q: "What shape has the largest area for a given perimeter?", a: "Circle", choices: ["Circle", "Square", "Equilateral triangle", "Regular hexagon"]}, |
| + | %{q: "How many radians in a full circle?", a: "2\u03C0", choices: ["2\u03C0", "\u03C0", "360", "4\u03C0"]}, |
| + | %{q: "What is i\u00B2 (imaginary unit squared)?", a: "-1", choices: ["-1", "1", "i", "0"]}, |
| + | %{q: "Archimedes estimated Pi by inscribing polygons with how many sides?", a: "96", choices: ["96", "12", "36", "360"]}, |
| + | %{q: "What is the sum of angles in a triangle (degrees)?", a: "180", choices: ["180", "360", "90", "270"]}, |
| + | %{q: "What is \u221A2 approximately?", a: "1.414", choices: ["1.414", "1.618", "1.732", "1.234"]}, |
| + | %{q: "The number Pi is:", a: "Irrational and transcendental", choices: ["Irrational and transcendental", "Rational", "Irrational but algebraic", "Imaginary"]}, |
| + | %{q: "What is the formula for a circle's area?", a: "\u03C0r\u00B2", choices: ["\u03C0r\u00B2", "2\u03C0r", "\u03C0d", "\u03C0r\u00B3"]}, |
| + | %{q: "sin\u00B2(x) + cos\u00B2(x) = ?", a: "1", choices: ["1", "0", "\u03C0", "2"]}, |
| + | %{q: "What is 0! (zero factorial)?", a: "1", choices: ["1", "0", "Undefined", "\u221E"]}, |
| + | %{q: "The Pythagorean theorem states: a\u00B2 + b\u00B2 = ?", a: "c\u00B2", choices: ["c\u00B2", "ab", "2c", "(a+b)\u00B2"]}, |
| + | %{q: "What is the derivative of sin(x)?", a: "cos(x)", choices: ["cos(x)", "-sin(x)", "tan(x)", "-cos(x)"]}, |
| + | %{q: "What mathematical constant appears in the normal distribution?", a: "Both Pi and e", choices: ["Both Pi and e", "Only Pi", "Only e", "Neither"]}, |
| + | %{q: "How is Pi defined geometrically?", a: "Circumference \u00F7 Diameter", choices: ["Circumference \u00F7 Diameter", "Area \u00F7 Radius", "Diameter \u00F7 Radius", "Perimeter \u00F7 Side"]}, |
| + | %{q: "Who used the symbol \u03C0 for the first time for this constant?", a: "William Jones", choices: ["William Jones", "Leonhard Euler", "Isaac Newton", "Carl Gauss"]}, |
| + | %{q: "What is the integral of 1/x?", a: "ln|x| + C", choices: ["ln|x| + C", "x\u00B2 + C", "1/x\u00B2 + C", "e^x + C"]}, |
| + | %{q: "What is the volume of a sphere formula?", a: "(4/3)\u03C0r\u00B3", choices: ["(4/3)\u03C0r\u00B3", "4\u03C0r\u00B2", "\u03C0r\u00B2h", "2\u03C0r\u00B3"]}, |
| + | %{q: "The Fibonacci sequence approaches what ratio?", a: "The Golden Ratio", choices: ["The Golden Ratio", "Pi", "e", "The Silver Ratio"]}, |
| + | %{q: "What is the billionth digit of Pi?", a: "9", choices: ["9", "1", "7", "3"]}, |
| + | %{q: "Buffon's Needle experiment can estimate:", a: "Pi", choices: ["Pi", "e", "The Golden Ratio", "Gravity"]} |
| + | ] |
| + | |
| + | def get_trivia_question do |
| + | q = Enum.random(@trivia) |
| + | %{question: q.q, answer: q.a, choices: Enum.shuffle(q.choices)} |
| + | end |
| + | |
| + | # --- Projectile Pi --- |
| + | |
| + | def generate_projectile_target do |
| + | # Target at Pi-related distances |
| + | target_x = Enum.random([ |
| + | Float.round(:math.pi() * 10, 1), |
| + | Float.round(:math.pi() * 20, 1), |
| + | Float.round(:math.pi() * 15, 1), |
| + | Float.round(:math.pi() * 25, 1), |
| + | Float.round(:math.pi() * 30, 1) |
| + | ]) |
| + | |
| + | %{target_x: target_x, gravity: 9.81} |
| end | |
| end | |
pi_day/game/score.ex b/lib/pi_day/game/score.ex
+1
-1
| @@ | @@ -15,7 +15,7 @@ defmodule PiDay.Game.Score do |
| timestamps(type: :utc_datetime, updated_at: false) | |
| end | |
| - | @game_types ~w(pi_memory monte_carlo slice_the_pi) |
| + | @game_types ~w(pi_memory monte_carlo slice_the_pi pi_trivia projectile_pi) |
| def changeset(score, attrs) do | |
| score | |
pi_day_web/channels/mini_game_channel.ex b/lib/pi_day_web/channels/mini_game_channel.ex
+70
-1
| @@ | @@ -3,8 +3,10 @@ defmodule PiDayWeb.MiniGameChannel do |
| alias PiDay.Game | |
| + | @valid_games ~w(pi_memory monte_carlo slice_the_pi pi_trivia projectile_pi) |
| + | |
| @impl true | |
| - | def join("game:mini:" <> game_type, _payload, socket) when game_type in ~w(pi_memory monte_carlo slice_the_pi) do |
| + | def join("game:mini:" <> game_type, _payload, socket) when game_type in @valid_games do |
| socket = assign(socket, :game_type, game_type) | |
| {:ok, socket} | |
| end | |
| @@ | @@ -111,4 +113,71 @@ defmodule PiDayWeb.MiniGameChannel do |
| {:reply, {:ok, %{recorded: true}}, socket} | |
| end | |
| + | |
| + | # --- Pi Trivia Blitz --- |
| + | |
| + | def handle_in("trivia_get_question", _payload, socket) do |
| + | question = Game.get_trivia_question() |
| + | {:reply, {:ok, question}, socket} |
| + | end |
| + | |
| + | def handle_in("trivia_answer", %{"correct" => correct, "time_ms" => time_ms, "streak" => streak}, socket) do |
| + | player = socket.assigns.player |
| + | points = if correct, do: max(10, 150 - div(time_ms, 100)) + streak * 15, else: 0 |
| + | |
| + | if correct do |
| + | broadcast!(socket, "trivia_correct", %{ |
| + | player_id: player.id, |
| + | name: player.name, |
| + | streak: streak |
| + | }) |
| + | end |
| + | |
| + | {:reply, {:ok, %{points: points}}, socket} |
| + | end |
| + | |
| + | def handle_in("trivia_game_over", %{"score" => score, "correct" => correct, "total" => total}, socket) do |
| + | player = socket.assigns.player |
| + | |
| + | Game.record_score(%{ |
| + | player_id: player.id, |
| + | game_type: "pi_trivia", |
| + | score: score, |
| + | metadata: %{correct: correct, total: total} |
| + | }) |
| + | |
| + | broadcast!(socket, "trivia_score", %{ |
| + | player_id: player.id, |
| + | name: player.name, |
| + | score: score |
| + | }) |
| + | |
| + | {:reply, {:ok, %{recorded: true}}, socket} |
| + | end |
| + | |
| + | # --- Projectile Pi --- |
| + | |
| + | def handle_in("projectile_get_target", _payload, socket) do |
| + | target = Game.generate_projectile_target() |
| + | {:reply, {:ok, target}, socket} |
| + | end |
| + | |
| + | def handle_in("projectile_submit", %{"score" => score, "target_x" => target_x, "best_error" => best_error}, socket) do |
| + | player = socket.assigns.player |
| + | |
| + | Game.record_score(%{ |
| + | player_id: player.id, |
| + | game_type: "projectile_pi", |
| + | score: score, |
| + | metadata: %{target_x: target_x, best_error: best_error} |
| + | }) |
| + | |
| + | broadcast!(socket, "projectile_score", %{ |
| + | player_id: player.id, |
| + | name: player.name, |
| + | score: score |
| + | }) |
| + | |
| + | {:reply, {:ok, %{recorded: true}}, socket} |
| + | end |
| end | |
pi_day_web/live/spectate_live.ex b/lib/pi_day_web/live/spectate_live.ex
+21
-18
| @@ | @@ -20,19 +20,26 @@ defmodule PiDayWeb.SpectateLive do |
| leaderboard: Game.leaderboard(), | |
| pi_top: Game.top_scores("pi_memory", 5), | |
| mc_top: Game.top_scores("monte_carlo", 5), | |
| - | slice_top: Game.top_scores("slice_the_pi", 5) |
| + | slice_top: Game.top_scores("slice_the_pi", 5), |
| + | trivia_top: Game.top_scores("pi_trivia", 5), |
| + | projectile_top: Game.top_scores("projectile_pi", 5) |
| )} | |
| end | |
| + | defp refresh(socket) do |
| + | assign(socket, |
| + | leaderboard: Game.leaderboard(), |
| + | pi_top: Game.top_scores("pi_memory", 5), |
| + | mc_top: Game.top_scores("monte_carlo", 5), |
| + | slice_top: Game.top_scores("slice_the_pi", 5), |
| + | trivia_top: Game.top_scores("pi_trivia", 5), |
| + | projectile_top: Game.top_scores("projectile_pi", 5) |
| + | ) |
| + | end |
| + | |
| @impl true | |
| def handle_info(:refresh_leaderboard, socket) do | |
| - | {:noreply, |
| - | assign(socket, |
| - | leaderboard: Game.leaderboard(), |
| - | pi_top: Game.top_scores("pi_memory", 5), |
| - | mc_top: Game.top_scores("monte_carlo", 5), |
| - | slice_top: Game.top_scores("slice_the_pi", 5) |
| - | )} |
| + | {:noreply, refresh(socket)} |
| end | |
| def handle_info(:push_presence, socket) do | |
| @@ | @@ -46,13 +53,7 @@ defmodule PiDayWeb.SpectateLive do |
| end | |
| def handle_info({:score_updated, _score}, socket) do | |
| - | {:noreply, |
| - | assign(socket, |
| - | leaderboard: Game.leaderboard(), |
| - | pi_top: Game.top_scores("pi_memory", 5), |
| - | mc_top: Game.top_scores("monte_carlo", 5), |
| - | slice_top: Game.top_scores("slice_the_pi", 5) |
| - | )} |
| + | {:noreply, refresh(socket)} |
| end | |
| # Ignore presence diffs โ we poll with push_presence instead | |
| @@ | @@ -102,10 +103,12 @@ defmodule PiDayWeb.SpectateLive do |
| </div> | |
| <!-- Mini-game leaderboards --> | |
| - | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6"> |
| - | <.mini_leaderboard title="Pi Memory Sprint" icon="๐ง " scores={@pi_top} /> |
| - | <.mini_leaderboard title="Monte Carlo Pi" icon="๐ฏ" scores={@mc_top} /> |
| + | <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-6"> |
| + | <.mini_leaderboard title="Pi Memory" icon="๐ง " scores={@pi_top} /> |
| + | <.mini_leaderboard title="Monte Carlo" icon="๐ฏ" scores={@mc_top} /> |
| <.mini_leaderboard title="Slice the Pi" icon="๐ช" scores={@slice_top} /> | |
| + | <.mini_leaderboard title="Pi Trivia" icon="๐ก" scores={@trivia_top} /> |
| + | <.mini_leaderboard title="Projectile Pi" icon="๐" scores={@projectile_top} /> |
| </div> | |
| <div class="text-center mt-6 text-purple-400/50 text-sm font-mono"> | |