Clone
import Phaser from "phaser"
import nipplejs from "nipplejs"
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: 0x06b6d4, sigma: 0x8b5cf6, delta: 0xf59e0b, omega: 0xef4444,
theta: 0x22c55e, lambda: 0xec4899, phi: 0x3b82f6, psi: 0xf97316,
epsilon: 0x14b8a6, zeta: 0xa855f7
}
const STATIONS = [
{ x: 130, y: 130, game: "pi_memory", label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: 0x06b6d4 },
{ x: 670, y: 130, game: "monte_carlo", label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: 0x8b5cf6 },
{ x: 130, y: 460, game: "slice_the_pi", label: "Slice\nthe Pi", icon: "\u{1FA93}", color: 0xf59e0b },
{ x: 670, y: 460, game: "pi_trivia", label: "Pi Trivia\nBlitz", icon: "\u{1F4A1}", color: 0xec4899 },
{ x: 400, y: 300, game: "projectile_pi", label: "Projectile\nPi", icon: "\u{1F680}", color: 0x22c55e },
]
export class HubScene extends Phaser.Scene {
constructor() {
super({ key: "HubScene" })
this.otherPlayers = {}
this.otherBubbles = {}
this.moveSpeed = 200
this.joystickVector = { x: 0, y: 0 }
this.nearStation = null
}
create() {
this.hubChannel = this.registry.get("hubChannel")
this.presence = this.registry.get("presence")
this.drawRoom()
this.createStations()
this.createPlayer()
this.setupControls()
this.setupPresence()
// Title
this.add.text(400, 30, "Pi Station", {
fontSize: "24px", fontFamily: "monospace", color: "#a78bfa",
}).setOrigin(0.5)
// Floating pi particles
this.createParticles()
}
drawRoom() {
const g = this.add.graphics()
g.fillStyle(0x0a0a2e)
g.fillRect(0, 0, 800, 600)
// Grid
g.lineStyle(1, 0x1e1b4b, 0.3)
for (let x = 0; x <= 800; x += 40) {
g.moveTo(x, 0); g.lineTo(x, 600)
}
for (let y = 0; y <= 600; y += 40) {
g.moveTo(0, y); g.lineTo(800, y)
}
g.strokePath()
// 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
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" watermark
this.add.text(400, 300, "3.14", {
fontSize: "120px", fontFamily: "monospace", color: "#1e1b4b",
}).setOrigin(0.5).setAlpha(0.15)
}
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 => {
// Outer glow ring
const glow = this.add.circle(station.x, station.y, 55, station.color, 0.08)
this.tweens.add({
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, {
fontSize: "24px"
}).setOrigin(0.5)
// Label
this.add.text(station.x, station.y + 50, station.label, {
fontSize: "11px", fontFamily: "monospace", color: "#e2e8f0",
align: "center",
}).setOrigin(0.5)
// Larger interactive zone
const zone = this.add.zone(station.x, station.y, 100, 100).setInteractive()
zone.on("pointerdown", () => {
if (window.piStation._currentGame) return
const dist = Phaser.Math.Distance.Between(
this.player.x, this.player.y, station.x, station.y
)
if (dist < 150) {
window.piStation.openMiniGame(station.game)
}
})
})
}
createPlayer() {
const x = 400, y = 300
const avatarKey = window.PLAYER_AVATAR
const color = AVATAR_COLORS[avatarKey] || 0x06b6d4
this.player = this.add.container(x, y)
const shadow = this.add.ellipse(0, 12, 30, 10, 0x000000, 0.3)
const body = this.add.circle(0, 0, 18, color)
body.setStrokeStyle(2, 0xffffff, 0.5)
const symbol = this.add.text(0, 0, AVATAR_SYMBOLS[avatarKey] || "?", {
fontSize: "18px", fontFamily: "serif", color: "#ffffff",
}).setOrigin(0.5)
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 },
}).setOrigin(0.5)
this.player.add([shadow, body, symbol, nameTag])
this.player.setDepth(10)
// 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() {
this.cursors = this.input.keyboard.createCursorKeys()
this.wasd = this.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A,
right: Phaser.Input.Keyboard.KeyCodes.D,
})
// Space to enter nearby station
this.input.keyboard.on("keydown-SPACE", () => {
if (window.piStation._currentGame) return
if (document.activeElement === document.getElementById("chat-input")) return
if (this.nearStation) {
window.piStation.openMiniGame(this.nearStation.game)
}
})
// Mobile joystick
if ("ontouchstart" in window) {
const joystickZone = document.createElement("div")
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({
zone: joystickZone,
mode: "dynamic",
color: "rgba(99,102,241,0.5)",
size: 100,
})
manager.on("move", (_evt, data) => {
const force = Math.min(data.force, 2) / 2
this.joystickVector.x = Math.cos(data.angle.radian) * force
this.joystickVector.y = -Math.sin(data.angle.radian) * force
})
manager.on("end", () => {
this.joystickVector.x = 0
this.joystickVector.y = 0
})
}
this.lastBroadcast = 0
this.broadcastInterval = 50
}
setupPresence() {
this.presence.onSync(() => {
const presences = {}
this.presence.list((id, { metas: [meta] }) => {
presences[id] = meta
})
// Remove players who left
Object.keys(this.otherPlayers).forEach(id => {
if (!presences[id]) {
this.otherPlayers[id].destroy()
delete this.otherPlayers[id]
if (this.otherBubbles[id]) {
this.otherBubbles[id].destroy()
delete this.otherBubbles[id]
}
}
})
// Update or add players
Object.entries(presences).forEach(([id, meta]) => {
if (id === window.PLAYER_ID) return
if (this.otherPlayers[id]) {
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 {
this.otherPlayers[id] = this.createOtherPlayer(meta)
}
})
})
}
createOtherPlayer(meta) {
const color = AVATAR_COLORS[meta.avatar_key] || 0x888888
const container = this.add.container(meta.x, meta.y)
const shadow = this.add.ellipse(0, 12, 30, 10, 0x000000, 0.3)
const body = this.add.circle(0, 0, 18, color, 0.7)
body.setStrokeStyle(2, 0xffffff, 0.3)
const symbol = this.add.text(0, 0, AVATAR_SYMBOLS[meta.avatar_key] || "?", {
fontSize: "18px", fontFamily: "serif", color: "#ffffff",
}).setOrigin(0.5).setAlpha(0.9)
const nameTag = this.add.text(0, -30, meta.name, {
fontSize: "10px", fontFamily: "monospace", color: "#cccccc",
backgroundColor: "rgba(0,0,0,0.4)", padding: { x: 4, y: 2 },
}).setOrigin(0.5)
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
}
// 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()
}
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
let vx = 0, vy = 0
// 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
if (Math.abs(this.joystickVector.x) > 0.1 || Math.abs(this.joystickVector.y) > 0.1) {
vx = this.joystickVector.x
vy = this.joystickVector.y
}
// Normalize
if (vx !== 0 && vy !== 0) {
const len = Math.sqrt(vx * vx + vy * vy)
vx /= len
vy /= len
}
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)
// 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 < 150 && dist < closestDist) {
nearStation = station
closestDist = dist
}
})
// 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
if (time - this.lastBroadcast > this.broadcastInterval && (vx !== 0 || vy !== 0)) {
this.hubChannel.push("move", {
x: Math.round(this.player.x),
y: Math.round(this.player.y)
})
this.lastBroadcast = time
}
// Y-sort depth
this.children.list
.filter(c => c.type === "Container")
.forEach(c => c.setDepth(c.y))
}
}