Add mobile polish, chat, sound effects, and live spectate hub

Torey Heinz committed Mar 14, 2026
commit e08d3d7dbc03275db68946f6569d250fc058af6b
Showing 9 changed files with 673 additions and 174 deletions
assets/js/app.js +153 -1
@@ @@ -25,11 +25,163 @@ import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/pi_day"
import topbar from "../vendor/topbar"
+ const AVATAR_SYMBOLS = {
+ pi: "\u03C0", sigma: "\u03A3", delta: "\u0394", omega: "\u03A9",
+ theta: "\u03B8", lambda: "\u03BB", phi: "\u03C6", psi: "\u03C8",
+ epsilon: "\u03B5", zeta: "\u03B6"
+ }
+
+ const AVATAR_COLORS = {
+ pi: "#06b6d4", sigma: "#8b5cf6", delta: "#f59e0b", omega: "#ef4444",
+ theta: "#22c55e", lambda: "#ec4899", phi: "#3b82f6", psi: "#f97316",
+ epsilon: "#14b8a6", zeta: "#a855f7"
+ }
+
+ 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" },
+ ]
+
+ const SpectateHub = {
+ mounted() {
+ const canvas = this.el.querySelector("canvas")
+ const ctx = canvas.getContext("2d")
+ this.players = []
+
+ const resize = () => {
+ const rect = this.el.getBoundingClientRect()
+ canvas.width = 800
+ canvas.height = 600
+ }
+ resize()
+ window.addEventListener("resize", resize)
+
+ this.handleEvent("hub_players", ({ players }) => {
+ this.players = players
+ this.draw(ctx, canvas)
+ })
+
+ // Initial draw
+ this.draw(ctx, canvas)
+ },
+
+ draw(ctx, canvas) {
+ const W = 800, H = 600
+ ctx.clearRect(0, 0, W, H)
+
+ // Background
+ ctx.fillStyle = "#0a0a2e"
+ ctx.fillRect(0, 0, W, H)
+
+ // Grid
+ ctx.strokeStyle = "rgba(30, 27, 75, 0.3)"
+ ctx.lineWidth = 1
+ for (let x = 0; x <= W; x += 40) {
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
+ }
+ for (let y = 0; y <= H; y += 40) {
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke()
+ }
+
+ // Border
+ ctx.strokeStyle = "rgba(99, 102, 241, 0.3)"
+ ctx.lineWidth = 2
+ ctx.strokeRect(20, 20, 760, 560)
+
+ // "3.14" watermark
+ ctx.fillStyle = "rgba(30, 27, 75, 0.3)"
+ ctx.font = "bold 120px monospace"
+ ctx.textAlign = "center"
+ ctx.textBaseline = "middle"
+ ctx.fillText("3.14", 400, 300)
+
+ // Stations
+ STATIONS.forEach(s => {
+ ctx.beginPath()
+ ctx.arc(s.x, s.y, 35, 0, Math.PI * 2)
+ ctx.fillStyle = s.color + "99"
+ ctx.fill()
+ ctx.strokeStyle = s.color
+ ctx.lineWidth = 2
+ ctx.stroke()
+
+ ctx.font = "24px serif"
+ ctx.textAlign = "center"
+ ctx.textBaseline = "middle"
+ ctx.fillStyle = "white"
+ ctx.fillText(s.icon, s.x, s.y - 3)
+
+ ctx.font = "11px monospace"
+ ctx.fillStyle = "#e2e8f0"
+ const lines = s.label.split("\n")
+ lines.forEach((line, i) => {
+ ctx.fillText(line, s.x, s.y + 48 + i * 14)
+ })
+ })
+
+ // Title
+ ctx.font = "bold 24px monospace"
+ ctx.fillStyle = "#a78bfa"
+ ctx.textAlign = "center"
+ ctx.fillText("Pi Station", 400, 30)
+
+ // Players
+ this.players.forEach(p => {
+ const color = AVATAR_COLORS[p.avatar_key] || "#888888"
+ const symbol = AVATAR_SYMBOLS[p.avatar_key] || "?"
+
+ // Shadow
+ ctx.beginPath()
+ ctx.ellipse(p.x, p.y + 12, 15, 5, 0, 0, Math.PI * 2)
+ ctx.fillStyle = "rgba(0,0,0,0.3)"
+ ctx.fill()
+
+ // Body
+ ctx.beginPath()
+ ctx.arc(p.x, p.y, 18, 0, Math.PI * 2)
+ ctx.fillStyle = color
+ ctx.fill()
+ ctx.strokeStyle = "rgba(255,255,255,0.4)"
+ ctx.lineWidth = 2
+ ctx.stroke()
+
+ // Symbol
+ ctx.font = "18px serif"
+ ctx.fillStyle = "white"
+ ctx.textAlign = "center"
+ ctx.textBaseline = "middle"
+ ctx.fillText(symbol, p.x, p.y)
+
+ // Name
+ ctx.font = "11px monospace"
+ const nameWidth = ctx.measureText(p.name).width
+ ctx.fillStyle = "rgba(0,0,0,0.5)"
+ ctx.fillRect(p.x - nameWidth / 2 - 4, p.y - 35, nameWidth + 8, 16)
+ ctx.fillStyle = "white"
+ ctx.fillText(p.name, p.x, p.y - 27)
+
+ // Status badge
+ if (p.status && p.status !== "hub") {
+ ctx.font = "9px monospace"
+ ctx.fillStyle = "#22d3ee"
+ ctx.fillText("Playing: " + p.status, p.x, p.y + 28)
+ }
+ })
+
+ // Player count
+ ctx.font = "12px monospace"
+ ctx.fillStyle = "#a78bfa"
+ ctx.textAlign = "left"
+ ctx.fillText("Online: " + this.players.length, 30, 580)
+ }
+ }
+
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
- hooks: {...colocatedHooks},
+ hooks: {...colocatedHooks, SpectateHub},
})
// Show progress bar on live navigation and form submits
assets/js/game.js +55 -5
@@ @@ -4,6 +4,7 @@ import { HubScene } from "./game/HubScene"
import { PiMemoryGame } from "./game/PiMemoryGame"
import { MonteCarloGame } from "./game/MonteCarloGame"
import { SliceThePiGame } from "./game/SliceThePiGame"
+ import { SoundFX } from "./game/SoundFX"
// --- Phoenix Socket Connection ---
const socket = new Socket("/game_socket", {
@@ @@ -54,15 +55,18 @@ hubChannel.join()
.receive("ok", () => console.log("Joined hub!"))
.receive("error", (resp) => console.error("Unable to join hub", resp))
- // --- Mini-game Manager (exposed globally for HTML overlay) ---
+ // --- Mini-game Manager ---
window.piStation = {
openMiniGame(gameType) {
const overlay = document.getElementById("mini-game-overlay")
const content = document.getElementById("mini-game-content")
overlay.classList.add("active")
- // Notify hub we're in a game
+ // Hide station prompt
+ document.getElementById("station-prompt").classList.remove("visible")
+
hubChannel.push("enter_game", { game: gameType })
+ SoundFX.countdown()
const channel = miniChannel(gameType)
@@ @@ -86,7 +90,6 @@ window.piStation = {
const overlay = document.getElementById("mini-game-overlay")
overlay.classList.remove("active")
- // Clear content safely
const content = document.getElementById("mini-game-content")
while (content.firstChild) content.removeChild(content.firstChild)
@@ @@ -99,11 +102,42 @@ window.piStation = {
window.piStation._currentGame = null
},
+ // Chat
+ sendChat(message) {
+ if (message) {
+ hubChannel.push("chat", { message })
+ }
+ },
+
+ // Station prompt โ€” called from HubScene
+ showStationPrompt(station) {
+ const el = document.getElementById("station-prompt")
+ if (station) {
+ el.textContent = `Tap to play ${station.label.replace("\n", " ")}!`
+ el.classList.add("visible")
+ el.onclick = () => window.piStation.openMiniGame(station.game)
+ } else {
+ el.classList.remove("visible")
+ el.onclick = null
+ }
+ },
+
_currentChannel: null,
_currentGame: null,
}
- // --- Players List UI (using safe DOM methods) ---
+ // --- Chat messages from server ---
+ hubChannel.on("chat", ({ player_id, name, message }) => {
+ SoundFX.chat()
+
+ // Pass to Phaser scene for bubble rendering
+ const scene = game.scene.getScene("HubScene")
+ if (scene && scene.showRemoteChat) {
+ scene.showRemoteChat(player_id, name, message)
+ }
+ })
+
+ // --- Players List UI ---
const playersListEl = document.getElementById("players-list")
const avatarSymbols = {
@@ @@ -112,17 +146,30 @@ const avatarSymbols = {
epsilon: "\u03B5", zeta: "\u03B6"
}
+ let prevPlayerCount = 0
+
function updatePlayersList() {
const players = []
presence.list((id, { metas: [meta] }) => {
players.push({ id, ...meta })
})
+ // Play join sound when new player appears
+ if (players.length > prevPlayerCount && prevPlayerCount > 0) {
+ SoundFX.join()
+ }
+ prevPlayerCount = players.length
+
players.sort((a, b) => (b.score || 0) - (a.score || 0))
- // Clear existing entries safely
while (playersListEl.firstChild) playersListEl.removeChild(playersListEl.firstChild)
+ // Header
+ const header = document.createElement("div")
+ header.style.cssText = "color:#a78bfa;font-size:0.6rem;padding:0.2rem 0.4rem;font-weight:bold;"
+ header.textContent = `Online: ${players.length}`
+ playersListEl.appendChild(header)
+
players.forEach(p => {
const entry = document.createElement("div")
entry.className = "player-entry"
@@ @@ -146,3 +193,6 @@ function updatePlayersList() {
}
presence.onSync(() => updatePlayersList())
+
+ // Export SoundFX globally for mini-games
+ window.SoundFX = SoundFX
assets/js/game/HubScene.js +158 -92
@@ @@ -13,7 +13,6 @@ const AVATAR_COLORS = {
epsilon: 0x14b8a6, zeta: 0xa855f7
}
- // Game station definitions
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 },
@@ @@ -24,8 +23,10 @@ export class HubScene extends Phaser.Scene {
constructor() {
super({ key: "HubScene" })
this.otherPlayers = {}
+ this.otherBubbles = {}
this.moveSpeed = 200
this.joystickVector = { x: 0, y: 0 }
+ this.nearStation = null
}
create() {
@@ @@ -37,28 +38,23 @@ export class HubScene extends Phaser.Scene {
this.createPlayer()
this.setupControls()
this.setupPresence()
- this.setupChat()
- // Title text
+ // Title
this.add.text(400, 30, "Pi Station", {
fontSize: "24px", fontFamily: "monospace", color: "#a78bfa",
}).setOrigin(0.5)
- // Instructions
- this.add.text(400, 570, "Walk to a station and tap it to play!", {
- fontSize: "14px", fontFamily: "monospace", color: "#6366f1",
- }).setOrigin(0.5)
+ // Floating pi particles
+ this.createParticles()
}
drawRoom() {
- // Floor grid
const g = this.add.graphics()
- // Dark background
g.fillStyle(0x0a0a2e)
g.fillRect(0, 0, 800, 600)
- // Grid lines
+ // Grid
g.lineStyle(1, 0x1e1b4b, 0.3)
for (let x = 0; x <= 800; x += 40) {
g.moveTo(x, 0); g.lineTo(x, 600)
@@ @@ -68,40 +64,64 @@ export class HubScene extends Phaser.Scene {
}
g.strokePath()
- // Border
- g.lineStyle(3, 0x6366f1, 0.5)
+ // Border with glow effect
+ g.lineStyle(2, 0x6366f1, 0.3)
g.strokeRect(20, 20, 760, 560)
+ g.lineStyle(1, 0x818cf8, 0.15)
+ g.strokeRect(18, 18, 764, 564)
- // Pi decorations scattered around
- const piPositions = [
- [80, 500], [720, 80], [720, 500], [250, 300], [550, 300]
- ]
+ // Pi decorations
+ const piPositions = [[80, 500], [720, 80], [720, 500], [250, 300], [550, 300]]
piPositions.forEach(([x, y]) => {
this.add.text(x, y, "\u03C0", {
fontSize: "40px", color: "#1e1b4b",
}).setOrigin(0.5).setAlpha(0.3)
})
- // "3.14" large watermark
+ // "3.14" watermark
this.add.text(400, 300, "3.14", {
fontSize: "120px", fontFamily: "monospace", color: "#1e1b4b",
}).setOrigin(0.5).setAlpha(0.15)
}
- createStations() {
- this.stationZones = []
+ createParticles() {
+ // Floating math symbols as ambient particles
+ const symbols = ["\u03C0", "\u2211", "\u222B", "\u221E", "e", "\u2202", "\u2207"]
+ for (let i = 0; i < 8; i++) {
+ const sym = this.add.text(
+ Phaser.Math.Between(50, 750),
+ Phaser.Math.Between(50, 550),
+ Phaser.Math.RND.pick(symbols),
+ { fontSize: "16px", color: "#312e81" }
+ ).setOrigin(0.5).setAlpha(0.2).setDepth(0)
+ this.tweens.add({
+ targets: sym,
+ y: sym.y - 30,
+ alpha: { from: 0.15, to: 0.3 },
+ duration: Phaser.Math.Between(3000, 6000),
+ yoyo: true,
+ repeat: -1,
+ delay: Phaser.Math.Between(0, 3000),
+ })
+ }
+ }
+
+ createStations() {
STATIONS.forEach(station => {
- // Glow circle
- const glow = this.add.circle(station.x, station.y, 50, station.color, 0.15)
+ // Outer glow ring
+ const glow = this.add.circle(station.x, station.y, 55, station.color, 0.08)
this.tweens.add({
- targets: glow, alpha: { from: 0.1, to: 0.3 },
+ targets: glow,
+ scaleX: 1.2, scaleY: 1.2,
+ alpha: { from: 0.08, to: 0.2 },
duration: 1500, yoyo: true, repeat: -1,
})
// Station circle
const circle = this.add.circle(station.x, station.y, 35, station.color, 0.6)
circle.setStrokeStyle(2, station.color)
+ station._circle = circle
// Icon
this.add.text(station.x, station.y - 5, station.icon, {
@@ @@ -114,18 +134,16 @@ export class HubScene extends Phaser.Scene {
align: "center",
}).setOrigin(0.5)
- // Interactive zone
- const zone = this.add.zone(station.x, station.y, 80, 80).setInteractive()
+ // Larger interactive zone
+ const zone = this.add.zone(station.x, station.y, 100, 100).setInteractive()
zone.on("pointerdown", () => {
const dist = Phaser.Math.Distance.Between(
this.player.x, this.player.y, station.x, station.y
)
- if (dist < 120) {
+ if (dist < 150) {
window.piStation.openMiniGame(station.game)
}
})
-
- this.stationZones.push({ zone, station })
})
}
@@ @@ -134,22 +152,16 @@ export class HubScene extends Phaser.Scene {
const avatarKey = window.PLAYER_AVATAR
const color = AVATAR_COLORS[avatarKey] || 0x06b6d4
- // Player container
this.player = this.add.container(x, y)
- // Shadow
const shadow = this.add.ellipse(0, 12, 30, 10, 0x000000, 0.3)
-
- // Body circle
const body = this.add.circle(0, 0, 18, color)
body.setStrokeStyle(2, 0xffffff, 0.5)
- // Avatar symbol
const symbol = this.add.text(0, 0, AVATAR_SYMBOLS[avatarKey] || "?", {
fontSize: "18px", fontFamily: "serif", color: "#ffffff",
}).setOrigin(0.5)
- // Name tag
const nameTag = this.add.text(0, -30, window.PLAYER_NAME, {
fontSize: "11px", fontFamily: "monospace", color: "#ffffff",
backgroundColor: "rgba(0,0,0,0.5)", padding: { x: 4, y: 2 },
@@ @@ -158,22 +170,26 @@ export class HubScene extends Phaser.Scene {
this.player.add([shadow, body, symbol, nameTag])
this.player.setDepth(10)
- // Chat bubble (hidden by default)
- this.chatBubble = this.add.text(0, -50, "", {
- fontSize: "10px", fontFamily: "monospace", color: "#ffffff",
- backgroundColor: "rgba(99,102,241,0.8)", padding: { x: 6, y: 3 },
- wordWrap: { width: 120 },
- }).setOrigin(0.5).setVisible(false)
- this.player.add(this.chatBubble)
-
- // Proximity highlight for stations
- this.proximityText = this.add.text(400, 530, "", {
- fontSize: "16px", fontFamily: "monospace", color: "#22d3ee",
- }).setOrigin(0.5).setDepth(20)
+ // Chat bubble for local player
+ this.myChatBubble = this.add.text(0, -50, "", {
+ fontSize: "11px", fontFamily: "monospace", color: "#ffffff",
+ backgroundColor: "rgba(99,102,241,0.85)", padding: { x: 8, y: 4 },
+ wordWrap: { width: 140 },
+ }).setOrigin(0.5).setVisible(false).setDepth(100)
+ this.player.add(this.myChatBubble)
+
+ // Idle bobbing animation
+ this.tweens.add({
+ targets: body,
+ y: -2,
+ duration: 800,
+ yoyo: true,
+ repeat: -1,
+ ease: "Sine.easeInOut",
+ })
}
setupControls() {
- // Keyboard
this.cursors = this.input.keyboard.createCursorKeys()
this.wasd = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
@@ @@ -185,7 +201,8 @@ export class HubScene extends Phaser.Scene {
// Mobile joystick
if ("ontouchstart" in window) {
const joystickZone = document.createElement("div")
- joystickZone.style.cssText = "position:fixed;bottom:0;left:0;width:50%;height:40%;z-index:40;"
+ joystickZone.style.cssText = "position:fixed;bottom:0;left:0;width:50%;height:40%;z-index:40;pointer-events:auto;"
+ joystickZone.id = "joystick-zone"
document.body.appendChild(joystickZone)
const manager = nipplejs.create({
@@ @@ -207,9 +224,8 @@ export class HubScene extends Phaser.Scene {
})
}
- // Throttled position broadcast
this.lastBroadcast = 0
- this.broadcastInterval = 50 // ms
+ this.broadcastInterval = 50
}
setupPresence() {
@@ @@ -224,6 +240,10 @@ export class HubScene extends Phaser.Scene {
if (!presences[id]) {
this.otherPlayers[id].destroy()
delete this.otherPlayers[id]
+ if (this.otherBubbles[id]) {
+ this.otherBubbles[id].destroy()
+ delete this.otherBubbles[id]
+ }
}
})
@@ @@ -232,14 +252,19 @@ export class HubScene extends Phaser.Scene {
if (id === window.PLAYER_ID) return
if (this.otherPlayers[id]) {
- // Smoothly move existing player
this.tweens.add({
targets: this.otherPlayers[id],
x: meta.x, y: meta.y,
duration: 100, ease: "Linear",
})
+
+ // Update status text
+ const statusObj = this.otherPlayers[id].getAt(4)
+ if (statusObj) {
+ const label = meta.status !== "hub" ? `Playing ${meta.status}` : ""
+ statusObj.setText(label)
+ }
} else {
- // Create new player
this.otherPlayers[id] = this.createOtherPlayer(meta)
}
})
@@ @@ -263,88 +288,129 @@ export class HubScene extends Phaser.Scene {
backgroundColor: "rgba(0,0,0,0.4)", padding: { x: 4, y: 2 },
}).setOrigin(0.5)
- // Status indicator
- const statusText = meta.status !== "hub" ? meta.status : ""
- const status = this.add.text(0, 25, statusText ? `Playing: ${statusText}` : "", {
+ const statusLabel = meta.status !== "hub" ? `Playing ${meta.status}` : ""
+ const status = this.add.text(0, 25, statusLabel, {
fontSize: "8px", fontFamily: "monospace", color: "#22d3ee",
}).setOrigin(0.5)
container.add([shadow, body, symbol, nameTag, status])
container.setDepth(5)
+ // Bobbing animation
+ this.tweens.add({
+ targets: body,
+ y: -2,
+ duration: 800 + Math.random() * 400,
+ yoyo: true,
+ repeat: -1,
+ ease: "Sine.easeInOut",
+ })
+
return container
}
- setupChat() {
- this.hubChannel.on("chat", ({ player_id, name, message }) => {
- if (player_id === window.PLAYER_ID) {
- this.showChatBubble(this.player, message, this.chatBubble)
- } else if (this.otherPlayers[player_id]) {
- const bubble = this.add.text(
- this.otherPlayers[player_id].x,
- this.otherPlayers[player_id].y - 50,
- message,
- {
- fontSize: "10px", fontFamily: "monospace", color: "#ffffff",
- backgroundColor: "rgba(99,102,241,0.8)", padding: { x: 6, y: 3 },
- wordWrap: { width: 120 },
- }
- ).setOrigin(0.5).setDepth(20)
-
- this.time.delayedCall(3000, () => bubble.destroy())
+ // Called from game.js when chat message arrives
+ showRemoteChat(playerId, name, message) {
+ if (playerId === window.PLAYER_ID) {
+ // Own message
+ this.myChatBubble.setText(message).setVisible(true)
+ if (this._myChatTimer) this._myChatTimer.remove()
+ this._myChatTimer = this.time.delayedCall(4000, () => this.myChatBubble.setVisible(false))
+ } else if (this.otherPlayers[playerId]) {
+ // Other player's message
+ if (this.otherBubbles[playerId]) {
+ this.otherBubbles[playerId].destroy()
}
- })
- }
- showChatBubble(container, message, bubble) {
- bubble.setText(message).setVisible(true)
- this.time.delayedCall(3000, () => bubble.setVisible(false))
+ const container = this.otherPlayers[playerId]
+ const bubble = this.add.text(container.x, container.y - 50, message, {
+ fontSize: "10px", fontFamily: "monospace", color: "#ffffff",
+ backgroundColor: "rgba(99,102,241,0.85)", padding: { x: 8, y: 4 },
+ wordWrap: { width: 140 },
+ }).setOrigin(0.5).setDepth(200)
+
+ this.otherBubbles[playerId] = bubble
+
+ // Fade out after 4 seconds
+ this.time.delayedCall(4000, () => {
+ if (this.otherBubbles[playerId] === bubble) {
+ bubble.destroy()
+ delete this.otherBubbles[playerId]
+ }
+ })
+ }
}
update(time) {
if (!this.player) return
- // Movement
let vx = 0, vy = 0
- // Keyboard
- if (this.cursors.left.isDown || this.wasd.left.isDown) vx = -1
- if (this.cursors.right.isDown || this.wasd.right.isDown) vx = 1
- if (this.cursors.up.isDown || this.wasd.up.isDown) vy = -1
- if (this.cursors.down.isDown || this.wasd.down.isDown) vy = 1
+ // Keyboard (only if chat input is not focused)
+ const chatFocused = document.activeElement === document.getElementById("chat-input")
+ if (!chatFocused) {
+ if (this.cursors.left.isDown || this.wasd.left.isDown) vx = -1
+ if (this.cursors.right.isDown || this.wasd.right.isDown) vx = 1
+ if (this.cursors.up.isDown || this.wasd.up.isDown) vy = -1
+ if (this.cursors.down.isDown || this.wasd.down.isDown) vy = 1
+ }
- // Joystick override
+ // Joystick
if (Math.abs(this.joystickVector.x) > 0.1 || Math.abs(this.joystickVector.y) > 0.1) {
vx = this.joystickVector.x
vy = this.joystickVector.y
}
- // Normalize diagonal movement
+ // Normalize
if (vx !== 0 && vy !== 0) {
const len = Math.sqrt(vx * vx + vy * vy)
vx /= len
vy /= len
}
- // Apply movement
const speed = this.moveSpeed * (1 / 60)
this.player.x = Phaser.Math.Clamp(this.player.x + vx * speed, 40, 760)
this.player.y = Phaser.Math.Clamp(this.player.y + vy * speed, 40, 560)
- // Check station proximity
+ // Station proximity โ€” use HTML prompt instead of Phaser text (more tappable on mobile)
let nearStation = null
+ let closestDist = Infinity
STATIONS.forEach(station => {
const dist = Phaser.Math.Distance.Between(
this.player.x, this.player.y, station.x, station.y
)
- if (dist < 120) nearStation = station
+ if (dist < 150 && dist < closestDist) {
+ nearStation = station
+ closestDist = dist
+ }
})
- this.proximityText.setText(
- nearStation ? `Tap ${nearStation.icon} to play ${nearStation.label.replace("\n", " ")}!` : ""
- )
+ // Update station visual feedback
+ if (nearStation !== this.nearStation) {
+ this.nearStation = nearStation
+ window.piStation.showStationPrompt(nearStation)
+
+ // Pulse the station circle when near
+ STATIONS.forEach(station => {
+ if (station._circle) {
+ if (station === nearStation) {
+ station._circle.setStrokeStyle(3, 0xffffff)
+ } else {
+ station._circle.setStrokeStyle(2, station.color)
+ }
+ }
+ })
+ }
+
+ // Update other players' bubble positions
+ Object.entries(this.otherBubbles).forEach(([id, bubble]) => {
+ if (this.otherPlayers[id]) {
+ bubble.x = this.otherPlayers[id].x
+ bubble.y = this.otherPlayers[id].y - 50
+ }
+ })
- // Broadcast position (throttled)
+ // Broadcast position
if (time - this.lastBroadcast > this.broadcastInterval && (vx !== 0 || vy !== 0)) {
this.hubChannel.push("move", {
x: Math.round(this.player.x),
@@ @@ -353,7 +419,7 @@ export class HubScene extends Phaser.Scene {
this.lastBroadcast = time
}
- // Sort players by Y for depth
+ // Y-sort depth
this.children.list
.filter(c => c.type === "Container")
.forEach(c => c.setDepth(c.y))
assets/js/game/MonteCarloGame.js +3 -0
@@ @@ -82,6 +82,8 @@ export const MonteCarloGame = {
if (inside) dartsInCircle++
+ if (window.SoundFX) window.SoundFX.dart()
+
// Draw dart
ctx.beginPath()
ctx.arc(x, y, 3, 0, Math.PI * 2)
@@ @@ -112,6 +114,7 @@ export const MonteCarloGame = {
estimate: parseFloat(finalEstimate.toFixed(6)),
darts: totalDarts
}).receive("ok", ({ score, error: err }) => {
+ if (window.SoundFX) window.SoundFX.score()
while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild)
const result = document.createElement("div")
assets/js/game/PiMemoryGame.js +4 -0
@@ @@ -66,6 +66,7 @@ export const PiMemoryGame = {
if (digit === expected) {
position++
+ if (window.SoundFX) window.SoundFX.correct()
display.textContent = "3." + PI_DIGITS.substring(0, position)
scoreDisplay.textContent = `Digits: ${position}`
@@ @@ -76,6 +77,7 @@ export const PiMemoryGame = {
// Milestone celebrations
if (position === 10 || position === 25 || position === 50 || position === 100) {
+ if (window.SoundFX) window.SoundFX.milestone()
const milestone = document.createElement("div")
milestone.style.cssText = "color: #22d3ee; font-size: 1.5rem; animation: pulse 0.5s;"
milestone.textContent = `${position} digits!`
@@ @@ -84,6 +86,7 @@ export const PiMemoryGame = {
}
} else {
// Wrong digit โ€” game over
+ if (window.SoundFX) window.SoundFX.wrong()
gameOver = true
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
@@ @@ -103,6 +106,7 @@ export const PiMemoryGame = {
// Submit score
channel.push("pi_game_over", { score: position })
.receive("ok", () => {
+ if (window.SoundFX) window.SoundFX.score()
while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild)
const result = document.createElement("div")
assets/js/game/SliceThePiGame.js +7 -1
@@ @@ -93,7 +93,10 @@ export const SliceThePiGame = {
timeLeft--
timerEl.textContent = timeLeft
- if (timeLeft <= 3) timerEl.style.color = "#ef4444"
+ if (timeLeft <= 3) {
+ timerEl.style.color = "#ef4444"
+ if (window.SoundFX) window.SoundFX.tick()
+ }
else if (timeLeft <= 5) timerEl.style.color = "#f59e0b"
if (timeLeft <= 0) {
@@ @@ -127,8 +130,10 @@ export const SliceThePiGame = {
if (isCorrect) {
streak++
correctCount++
+ if (window.SoundFX) window.SoundFX.correct()
} else {
streak = 0
+ if (window.SoundFX) window.SoundFX.wrong()
}
channel.push("slice_answer", {
@@ @@ -180,6 +185,7 @@ export const SliceThePiGame = {
correct: correctCount,
total: TOTAL_QUESTIONS
}).receive("ok", () => {
+ if (window.SoundFX) window.SoundFX.score()
while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild)
const result = document.createElement("div")
assets/js/game/SoundFX.js +107 -0
@@ @@ -0,0 +1,107 @@
+ // Synthesized sound effects using Web Audio API โ€” no files needed
+ let audioCtx = null
+
+ function getCtx() {
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)()
+ return audioCtx
+ }
+
+ function resumeAudio() {
+ const ctx = getCtx()
+ if (ctx.state === "suspended") ctx.resume()
+ }
+
+ // Unlock audio on first touch/click
+ document.addEventListener("touchstart", resumeAudio, { once: true })
+ document.addEventListener("click", resumeAudio, { once: true })
+
+ function playTone(freq, duration, type = "sine", volume = 0.15) {
+ const ctx = getCtx()
+ const osc = ctx.createOscillator()
+ const gain = ctx.createGain()
+ osc.type = type
+ osc.frequency.value = freq
+ gain.gain.setValueAtTime(volume, ctx.currentTime)
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
+ osc.connect(gain)
+ gain.connect(ctx.destination)
+ osc.start()
+ osc.stop(ctx.currentTime + duration)
+ }
+
+ export const SoundFX = {
+ // Correct answer / digit โ€” rising happy tone
+ correct() {
+ playTone(523, 0.1, "sine", 0.12)
+ setTimeout(() => playTone(659, 0.1, "sine", 0.12), 60)
+ setTimeout(() => playTone(784, 0.15, "sine", 0.1), 120)
+ },
+
+ // Wrong answer โ€” descending sad tone
+ wrong() {
+ playTone(330, 0.15, "square", 0.08)
+ setTimeout(() => playTone(262, 0.25, "square", 0.06), 100)
+ },
+
+ // Button tap โ€” short click
+ tap() {
+ playTone(880, 0.04, "sine", 0.08)
+ },
+
+ // Score recorded โ€” achievement chime
+ score() {
+ playTone(523, 0.1, "sine", 0.12)
+ setTimeout(() => playTone(659, 0.1, "sine", 0.12), 100)
+ setTimeout(() => playTone(784, 0.1, "sine", 0.12), 200)
+ setTimeout(() => playTone(1047, 0.3, "sine", 0.1), 300)
+ },
+
+ // Game start โ€” countdown beep
+ countdown() {
+ playTone(440, 0.1, "sine", 0.1)
+ },
+
+ // Milestone โ€” big achievement
+ milestone() {
+ const notes = [523, 659, 784, 1047, 784, 1047]
+ notes.forEach((freq, i) => {
+ setTimeout(() => playTone(freq, 0.12, "sine", 0.1), i * 80)
+ })
+ },
+
+ // Chat message received
+ chat() {
+ playTone(1200, 0.05, "sine", 0.06)
+ setTimeout(() => playTone(1500, 0.05, "sine", 0.05), 40)
+ },
+
+ // Player joined
+ join() {
+ playTone(440, 0.08, "triangle", 0.08)
+ setTimeout(() => playTone(554, 0.08, "triangle", 0.08), 80)
+ setTimeout(() => playTone(659, 0.12, "triangle", 0.07), 160)
+ },
+
+ // Dart throw
+ dart() {
+ const ctx = getCtx()
+ const bufferSize = ctx.sampleRate * 0.05
+ const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate)
+ const data = buffer.getChannelData(0)
+ for (let i = 0; i < bufferSize; i++) {
+ data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize)
+ }
+ const noise = ctx.createBufferSource()
+ noise.buffer = buffer
+ const gain = ctx.createGain()
+ gain.gain.setValueAtTime(0.06, ctx.currentTime)
+ noise.connect(gain)
+ gain.connect(ctx.destination)
+ noise.start()
+ },
+
+ // Timer tick (last 3 seconds)
+ tick() {
+ playTone(1000, 0.05, "square", 0.06)
+ },
+ }
pi_day_web/controllers/game_html/play.html.heex b/lib/pi_day_web/controllers/game_html/play.html.heex +134 -48
@@ @@ -2,45 +2,43 @@
<html lang="en">
<head>
<meta charset="utf-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
+ <meta name="apple-mobile-web-app-capable" content="yes" />
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>Pi Station</title>
- <link rel="stylesheet" href={~p"/assets/css/app.css"} />
<style>
- * { margin: 0; padding: 0; box-sizing: border-box; }
- html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a1a; touch-action: none; }
+ * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
+ html, body {
+ width: 100%; height: 100%; overflow: hidden; background: #0a0a1a;
+ touch-action: none; -webkit-touch-callout: none; -webkit-user-select: none;
+ user-select: none; overscroll-behavior: none;
+ position: fixed; inset: 0;
+ }
#game-container { width: 100%; height: 100%; position: relative; }
#game-canvas { width: 100%; height: 100%; display: block; }
/* Mini-game overlay */
#mini-game-overlay {
- display: none;
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.9);
- z-index: 100;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 1rem;
+ display: none; position: fixed; inset: 0;
+ background: rgba(0,0,0,0.92); z-index: 100;
+ flex-direction: column; align-items: center;
+ justify-content: flex-start; padding: 1rem;
+ overflow-y: auto; -webkit-overflow-scrolling: touch;
}
#mini-game-overlay.active { display: flex; }
#mini-game-content {
- width: 100%;
- max-width: 500px;
- max-height: 90vh;
- overflow-y: auto;
- color: white;
- text-align: center;
+ width: 100%; max-width: 500px; color: white;
+ text-align: center; padding-bottom: 2rem; margin-top: 2rem;
}
- .mg-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 1rem; }
+ .mg-title { font-size: 1.5rem; font-weight: bold; margin-bottom: 0.5rem; }
.mg-subtitle { font-size: 0.9rem; color: #a78bfa; margin-bottom: 1.5rem; }
.mg-close {
- position: absolute; top: 1rem; right: 1rem;
- background: rgba(255,255,255,0.1); border: none; color: white;
- width: 40px; height: 40px; border-radius: 50%; font-size: 1.5rem;
- cursor: pointer;
+ position: fixed; top: 0.75rem; right: 0.75rem;
+ background: rgba(255,255,255,0.15); border: none; color: white;
+ width: 44px; height: 44px; border-radius: 50%; font-size: 1.5rem;
+ cursor: pointer; z-index: 110;
}
.mg-btn {
display: inline-block; padding: 0.75rem 2rem;
@@ @@ -48,59 +46,109 @@
color: white; border: none; border-radius: 0.75rem;
font-size: 1.1rem; font-weight: bold; cursor: pointer;
margin: 0.5rem; transition: transform 0.1s;
+ -webkit-tap-highlight-color: transparent;
}
.mg-btn:active { transform: scale(0.95); }
.mg-btn.secondary { background: rgba(255,255,255,0.15); }
- /* Pi Memory specific */
+ /* Pi Memory */
.pi-display {
- font-family: monospace; font-size: 2rem; letter-spacing: 0.2em;
- color: #22d3ee; margin: 1rem 0; min-height: 3rem;
- word-break: break-all; line-height: 1.5;
+ font-family: monospace; font-size: 1.8rem; letter-spacing: 0.15em;
+ color: #22d3ee; margin: 0.75rem 0; min-height: 2.5rem;
+ word-break: break-all; line-height: 1.4;
}
- .pi-numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; max-width: 300px; margin: 1rem auto; }
+ .pi-numpad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; max-width: 280px; margin: 0.75rem auto; }
.pi-numpad button {
- padding: 1rem; font-size: 1.5rem; font-weight: bold;
+ padding: 0.9rem; font-size: 1.4rem; font-weight: bold;
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);
border-radius: 0.75rem; color: white; cursor: pointer;
- transition: background 0.1s;
+ -webkit-tap-highlight-color: transparent;
}
.pi-numpad button:active { background: rgba(139, 92, 246, 0.5); }
.pi-numpad .zero { grid-column: span 3; }
- /* Monte Carlo specific */
- #mc-canvas { border-radius: 0.75rem; border: 2px solid rgba(255,255,255,0.2); touch-action: none; }
- .mc-stats { display: flex; gap: 1rem; justify-content: center; margin: 1rem 0; flex-wrap: wrap; }
- .mc-stat { background: rgba(255,255,255,0.1); padding: 0.5rem 1rem; border-radius: 0.5rem; }
- .mc-stat-value { font-size: 1.3rem; font-weight: bold; color: #22d3ee; }
- .mc-stat-label { font-size: 0.7rem; color: #a78bfa; }
+ /* Monte Carlo */
+ #mc-canvas { border-radius: 0.75rem; border: 2px solid rgba(255,255,255,0.2); touch-action: none; max-width: 100%; }
+ .mc-stats { display: flex; gap: 0.75rem; justify-content: center; margin: 0.75rem 0; flex-wrap: wrap; }
+ .mc-stat { background: rgba(255,255,255,0.1); padding: 0.4rem 0.75rem; border-radius: 0.5rem; }
+ .mc-stat-value { font-size: 1.2rem; font-weight: bold; color: #22d3ee; }
+ .mc-stat-label { font-size: 0.65rem; color: #a78bfa; }
- /* Slice the Pi specific */
- .slice-question { font-size: 1.2rem; margin: 1rem 0; color: #e2e8f0; min-height: 3rem; }
- .slice-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; max-width: 400px; margin: 1rem auto; }
+ /* Slice the Pi */
+ .slice-question { font-size: 1.1rem; margin: 0.75rem 0; color: #e2e8f0; min-height: 2.5rem; }
+ .slice-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; max-width: 400px; margin: 0.75rem auto; }
.slice-choice {
- padding: 1rem; font-size: 1.1rem;
+ padding: 0.9rem; font-size: 1.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;
+ -webkit-tap-highlight-color: transparent;
}
.slice-choice.correct { background: rgba(34, 197, 94, 0.4); border-color: #22c55e; }
.slice-choice.wrong { background: rgba(239, 68, 68, 0.4); border-color: #ef4444; }
- .slice-timer { font-size: 3rem; font-weight: bold; color: #22d3ee; }
- .slice-score-display { font-size: 1.2rem; color: #a78bfa; margin: 0.5rem 0; }
+ .slice-timer { font-size: 2.5rem; font-weight: bold; color: #22d3ee; }
+ .slice-score-display { font-size: 1rem; color: #a78bfa; margin: 0.3rem 0; }
/* Players sidebar */
#players-list {
position: fixed; top: 0.5rem; right: 0.5rem;
background: rgba(0,0,0,0.7); border-radius: 0.75rem;
- padding: 0.5rem; max-width: 150px; z-index: 50;
- max-height: 50vh; overflow-y: auto;
+ padding: 0.5rem; max-width: 140px; z-index: 50;
+ max-height: 40vh; overflow-y: auto;
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.player-entry {
display: flex; align-items: center; gap: 0.3rem;
- padding: 0.25rem 0.5rem; color: white; font-size: 0.7rem;
+ padding: 0.2rem 0.4rem; color: white; font-size: 0.65rem;
+ }
+ .player-avatar { font-size: 0.9rem; }
+ .player-score { color: #22d3ee; margin-left: auto; font-size: 0.55rem; }
+
+ /* Chat input */
+ #chat-bar {
+ position: fixed; bottom: 0; left: 0; right: 0;
+ z-index: 60; display: flex; gap: 0.5rem;
+ padding: 0.5rem; background: rgba(0,0,0,0.6);
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
+ transform: translateY(100%); transition: transform 0.2s;
+ }
+ #chat-bar.visible { transform: translateY(0); }
+ #chat-bar input {
+ flex: 1; padding: 0.6rem 0.75rem; border-radius: 0.75rem;
+ border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1);
+ color: white; font-size: 0.9rem; outline: none;
+ }
+ #chat-bar input::placeholder { color: rgba(255,255,255,0.4); }
+ #chat-bar button {
+ padding: 0.6rem 1rem; border-radius: 0.75rem;
+ border: none; background: linear-gradient(135deg, #06b6d4, #8b5cf6);
+ color: white; font-weight: bold; font-size: 0.9rem; cursor: pointer;
+ }
+
+ /* Chat toggle button */
+ #chat-toggle {
+ position: fixed; bottom: 0.75rem; left: 0.75rem; z-index: 55;
+ width: 44px; height: 44px; border-radius: 50%;
+ background: rgba(99,102,241,0.6); border: none; color: white;
+ font-size: 1.2rem; cursor: pointer;
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
+ }
+
+ /* Station prompt */
+ #station-prompt {
+ position: fixed; bottom: 4rem; left: 50%; transform: translateX(-50%);
+ z-index: 45; padding: 0.6rem 1.5rem; border-radius: 1rem;
+ background: linear-gradient(135deg, rgba(6,182,212,0.8), rgba(139,92,246,0.8));
+ color: white; font-weight: bold; font-size: 1rem;
+ cursor: pointer; display: none; white-space: nowrap;
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
+ animation: promptBounce 1.5s ease-in-out infinite;
+ -webkit-tap-highlight-color: transparent;
+ }
+ #station-prompt.visible { display: block; }
+ @keyframes promptBounce {
+ 0%, 100% { transform: translateX(-50%) translateY(0); }
+ 50% { transform: translateX(-50%) translateY(-5px); }
}
- .player-avatar { font-size: 1rem; }
- .player-score { color: #22d3ee; margin-left: auto; font-size: 0.6rem; }
</style>
</head>
<body>
@@ @@ -110,6 +158,15 @@
<div id="players-list"></div>
+ <button id="chat-toggle" onclick="toggleChat()">๐Ÿ’ฌ</button>
+
+ <div id="chat-bar">
+ <input id="chat-input" type="text" placeholder="Say something..." maxlength="100" autocomplete="off" />
+ <button onclick="sendChat()">Send</button>
+ </div>
+
+ <div id="station-prompt"></div>
+
<div id="mini-game-overlay">
<button class="mg-close" onclick="window.piStation.closeMiniGame()">&times;</button>
<div id="mini-game-content"></div>
@@ @@ -120,6 +177,35 @@
window.PLAYER_ID = "<%= @player.id %>";
window.PLAYER_NAME = "<%= @player.name %>";
window.PLAYER_AVATAR = "<%= @player.avatar_key %>";
+
+ // Chat toggle
+ let chatVisible = false;
+ function toggleChat() {
+ chatVisible = !chatVisible;
+ document.getElementById('chat-bar').classList.toggle('visible', chatVisible);
+ if (chatVisible) document.getElementById('chat-input').focus();
+ }
+ function sendChat() {
+ const input = document.getElementById('chat-input');
+ const msg = input.value.trim();
+ if (msg && window.piStation && window.piStation.sendChat) {
+ window.piStation.sendChat(msg);
+ input.value = '';
+ }
+ }
+ document.getElementById('chat-input').addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') sendChat();
+ e.stopPropagation(); // Don't let WASD keys move player while typing
+ });
+ document.getElementById('chat-input').addEventListener('keyup', (e) => e.stopPropagation());
+ document.getElementById('chat-input').addEventListener('keypress', (e) => e.stopPropagation());
+
+ // Prevent iOS rubber-band scrolling
+ document.addEventListener('touchmove', (e) => {
+ if (!e.target.closest('#mini-game-overlay') && !e.target.closest('#chat-bar')) {
+ e.preventDefault();
+ }
+ }, { passive: false });
</script>
<script src={~p"/assets/js/game.js"}></script>
</body>
pi_day_web/live/spectate_live.ex b/lib/pi_day_web/live/spectate_live.ex +52 -27
@@ @@ -2,12 +2,16 @@ defmodule PiDayWeb.SpectateLive do
use PiDayWeb, :live_view
alias PiDay.Game
+ alias PiDayWeb.Presence
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(PiDay.PubSub, "leaderboard")
- :timer.send_interval(5000, self(), :refresh_leaderboard)
+ # Subscribe to presence diffs for the hub
+ PiDayWeb.Endpoint.subscribe("game:hub")
+ :timer.send_interval(3000, self(), :refresh_leaderboard)
+ :timer.send_interval(200, self(), :push_presence)
end
{:ok,
@@ @@ -31,6 +35,16 @@ defmodule PiDayWeb.SpectateLive do
)}
end
+ def handle_info(:push_presence, socket) do
+ players =
+ Presence.list("game:hub")
+ |> Enum.map(fn {id, %{metas: [meta | _]}} ->
+ %{id: id, name: meta.name, avatar_key: meta.avatar_key, x: meta.x, y: meta.y, status: meta.status}
+ end)
+
+ {:noreply, push_event(socket, "hub_players", %{players: players})}
+ end
+
def handle_info({:score_updated, _score}, socket) do
{:noreply,
assign(socket,
@@ @@ -41,47 +55,60 @@ defmodule PiDayWeb.SpectateLive do
)}
end
+ # Ignore presence diffs โ€” we poll with push_presence instead
+ def handle_info(%Phoenix.Socket.Broadcast{}, socket), do: {:noreply, socket}
+
@impl true
def render(assigns) do
~H"""
- <div class="min-h-screen bg-gradient-to-b from-indigo-950 via-purple-950 to-black text-white p-6">
- <div class="max-w-6xl mx-auto">
+ <div class="min-h-screen bg-gradient-to-b from-indigo-950 via-purple-950 to-black text-white p-4 lg:p-6">
+ <div class="max-w-7xl mx-auto">
<!-- Header -->
- <div class="text-center mb-8">
- <div class="text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400 mb-2">
+ <div class="text-center mb-6">
+ <div class="text-5xl lg:text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400 mb-1">
&pi; Station
</div>
- <p class="text-purple-300 text-xl">Pi Day 2026 &middot; Live Leaderboard</p>
+ <p class="text-purple-300 text-lg">Pi Day 2026 &middot; Live Leaderboard</p>
</div>
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ <div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
+ <!-- Live Hub View -->
+ <div class="xl:col-span-2 bg-white/5 backdrop-blur rounded-2xl p-4 border border-white/10">
+ <h2 class="text-xl font-bold text-cyan-400 mb-3">Live Hub</h2>
+ <div id="spectate-hub" phx-hook="SpectateHub" class="w-full aspect-[4/3] rounded-xl overflow-hidden bg-[#0a0a2e]">
+ <canvas id="spectate-canvas" class="w-full h-full block"></canvas>
+ </div>
+ </div>
+
<!-- Overall Leaderboard -->
- <div class="lg:col-span-2 bg-white/5 backdrop-blur rounded-2xl p-6 border border-white/10">
- <h2 class="text-2xl font-bold text-cyan-400 mb-4">Overall Rankings</h2>
+ <div class="bg-white/5 backdrop-blur rounded-2xl p-4 lg:p-6 border border-white/10">
+ <h2 class="text-xl font-bold text-cyan-400 mb-3">Rankings</h2>
<div class="space-y-2">
<%= for {player, idx} <- Enum.with_index(@leaderboard) do %>
- <div class={"flex items-center gap-4 p-3 rounded-xl #{if idx < 3, do: "bg-gradient-to-r from-yellow-500/10 to-transparent", else: "bg-white/5"}"}>
- <div class={"text-2xl font-bold w-10 text-center #{rank_color(idx)}"}>
+ <div class={"flex items-center gap-3 p-2 rounded-xl #{if idx < 3, do: "bg-gradient-to-r from-yellow-500/10 to-transparent", else: "bg-white/5"}"}>
+ <div class={"text-xl font-bold w-8 text-center #{rank_color(idx)}"}>
#{idx + 1}
</div>
- <div class="text-2xl"><%= avatar_symbol(player.avatar_key) %></div>
- <div class="flex-1 text-lg font-medium"><%= player.name %></div>
- <div class="text-2xl font-bold text-cyan-400"><%= player.total_score %></div>
+ <div class="text-xl"><%= avatar_symbol(player.avatar_key) %></div>
+ <div class="flex-1 font-medium truncate"><%= player.name %></div>
+ <div class="text-xl font-bold text-cyan-400"><%= player.total_score %></div>
</div>
<% end %>
<%= if @leaderboard == [] do %>
- <p class="text-purple-400 text-center py-8">No scores yet โ€” join the game!</p>
+ <p class="text-purple-400 text-center py-6">No scores yet โ€” join the game!</p>
<% end %>
</div>
</div>
+ </div>
- <!-- Mini-game leaderboards -->
+ <!-- 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} />
<.mini_leaderboard title="Slice the Pi" icon="๐Ÿ”ช" scores={@slice_top} />
</div>
- <div class="text-center mt-8 text-purple-400/50 text-sm font-mono">
+ <div class="text-center mt-6 text-purple-400/50 text-sm font-mono">
3.14159265358979323846264338327950288419716939937510...
</div>
</div>
@@ @@ -94,9 +121,7 @@ defmodule PiDayWeb.SpectateLive do
defp rank_color(2), do: "text-amber-600"
defp rank_color(_), do: "text-purple-400"
- defp avatar_symbol(key) do
- PiDayWeb.PageHTML.avatar_symbol(key)
- end
+ defp avatar_symbol(key), do: PiDayWeb.PageHTML.avatar_symbol(key)
attr :title, :string, required: true
attr :icon, :string, required: true
@@ @@ -104,18 +129,18 @@ defmodule PiDayWeb.SpectateLive do
defp mini_leaderboard(assigns) do
~H"""
- <div class="bg-white/5 backdrop-blur rounded-2xl p-6 border border-white/10">
- <h3 class="text-xl font-bold text-purple-300 mb-3"><%= @icon %> <%= @title %></h3>
- <div class="space-y-2">
+ <div class="bg-white/5 backdrop-blur rounded-2xl p-4 border border-white/10">
+ <h3 class="text-lg font-bold text-purple-300 mb-2"><%= @icon %> <%= @title %></h3>
+ <div class="space-y-1">
<%= for {score, idx} <- Enum.with_index(@scores) do %>
- <div class="flex items-center gap-3 p-2 bg-white/5 rounded-lg">
- <span class="text-purple-400 font-bold w-6"><%= idx + 1 %>.</span>
- <span class="flex-1"><%= score.player.name %></span>
+ <div class="flex items-center gap-2 p-2 bg-white/5 rounded-lg text-sm">
+ <span class="text-purple-400 font-bold w-5"><%= idx + 1 %>.</span>
+ <span class="flex-1 truncate"><%= score.player.name %></span>
<span class="text-cyan-400 font-bold"><%= score.score %></span>
</div>
<% end %>
<%= if @scores == [] do %>
- <p class="text-purple-400/50 text-center py-4 text-sm">No scores yet</p>
+ <p class="text-purple-400/50 text-center py-3 text-sm">No scores yet</p>
<% end %>
</div>
</div>