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()">×</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"> |
| π Station | |
| </div> | |
| - | <p class="text-purple-300 text-xl">Pi Day 2026 · Live Leaderboard</p> |
| + | <p class="text-purple-300 text-lg">Pi Day 2026 · 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> | |