Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
"dependencies": {
"@kickoff/shared": "*",
"colyseus.js": "^0.16.5",
"pixi.js": "^8.14.3"
"pixi.js": "^8.14.3",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"tsx": "^4.20.6",
"typescript": "^5.3.3",
"vite": "^5.0.8",
Expand Down
29 changes: 28 additions & 1 deletion client/src/scenes/MultiplayerScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AIManager } from '@/ai'
import { sceneRouter } from '@/utils/SceneRouter'
import type { Room } from 'colyseus.js'
import { PixiSceneManager } from '@/utils/PixiSceneManager'
import { WaitingOverlay } from '@/utils/WaitingOverlay'

/**
* Multiplayer Game Scene (PixiJS)
Expand All @@ -26,6 +27,7 @@ export class MultiplayerScene extends BaseGameScene {
private aiEnabled: boolean = true
private lastControlledPlayerId?: string
private lastMovementWasNonZero: boolean = false
private waitingOverlay?: WaitingOverlay
// Dead reckoning state — track last known server positions + velocities so we can
// extrapolate between patches and eliminate visual stalls during network jitter
private lastBallServerX: number = 0
Expand Down Expand Up @@ -191,7 +193,9 @@ export class MultiplayerScene extends BaseGameScene {
this.colorInitialized = false
this.positionInitialized = false
this.returningToMenu = false


this.hideWaitingOverlay()

if (this.aiManager) {
this.aiManager = undefined
}
Expand Down Expand Up @@ -388,6 +392,13 @@ export class MultiplayerScene extends BaseGameScene {

this.setupNetworkListeners()
this.networkManager.checkExistingPlayers()

// Show QR code overlay while waiting for an opponent
const state = this.networkManager.getState()
if (roomId !== 'Unknown' && state?.phase === 'waiting') {
const joinUrl = `${window.location.origin}${window.location.pathname}#multiplayer?id=${roomId}`
this.waitingOverlay = new WaitingOverlay(joinUrl, this.cameraManager.getGameContainer())
}
} catch (error) {
console.error('❌ Multiplayer connection failed:', error)
this.isMultiplayer = false
Expand Down Expand Up @@ -416,6 +427,10 @@ export class MultiplayerScene extends BaseGameScene {
}
})

this.networkManager.on('matchStart', () => {
this.hideWaitingOverlay()
})

this.networkManager.on('playerJoin', (player: any) => {
try {
console.log('👤 Remote player joined:', player.id, player.team)
Expand Down Expand Up @@ -445,6 +460,11 @@ export class MultiplayerScene extends BaseGameScene {
const state = this.networkManager?.getState() as any
if (!state?.players) return

// Hide waiting overlay when match starts (fallback for matchStart event)
if (this.waitingOverlay && state.phase === 'playing') {
this.hideWaitingOverlay()
}

// Create sprites for any new players
state.players.forEach((player: any, playerId: string) => {
if (!this.players.has(playerId)) {
Expand Down Expand Up @@ -531,6 +551,13 @@ export class MultiplayerScene extends BaseGameScene {
}
}

private hideWaitingOverlay(): void {
if (this.waitingOverlay) {
this.waitingOverlay.destroy()
this.waitingOverlay = undefined
}
}

private returnToMenu(message: string): void {
if (this.returningToMenu) {
return
Expand Down
129 changes: 129 additions & 0 deletions client/src/utils/WaitingOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Container, Graphics, Text } from 'pixi.js'
import { GAME_CONFIG } from '@shared/types'
import QRCode from 'qrcode'

/**
* Overlay displayed on the field while waiting for a second player to join.
* Shows a QR code (rendered via PixiJS Graphics) encoding the join URL,
* plus "Waiting for opponent..." text with a subtle pulse animation.
*/
export class WaitingOverlay {
readonly container: Container
private titleText: Text
private animationFrame: number = 0
private destroyed: boolean = false

constructor(joinUrl: string, parent: Container) {
this.container = new Container()
this.container.zIndex = 500

const centerX = GAME_CONFIG.FIELD_WIDTH / 2
const centerY = GAME_CONFIG.FIELD_HEIGHT / 2

// Semi-transparent backdrop
const backdrop = new Graphics()
backdrop.roundRect(centerX - 200, centerY - 210, 400, 420, 16)
backdrop.fill({ color: 0x000000, alpha: 0.75 })
this.container.addChild(backdrop)

// Title text: "Waiting for opponent..."
this.titleText = new Text({
text: 'Waiting for opponent...',
style: {
fontSize: 26,
fill: '#ffffff',
fontFamily: 'Arial, sans-serif',
fontWeight: 'bold',
},
})
this.titleText.anchor.set(0.5)
this.titleText.position.set(centerX, centerY - 175)
this.container.addChild(this.titleText)

// Generate and render QR code
this.renderQRCode(joinUrl, centerX, centerY)

// Instruction text: "Scan to join"
const instructionText = new Text({
text: 'Scan to join',
style: {
fontSize: 20,
fill: '#aaaaaa',
fontFamily: 'Arial, sans-serif',
},
})
instructionText.anchor.set(0.5)
instructionText.position.set(centerX, centerY + 175)
this.container.addChild(instructionText)

parent.addChild(this.container)

// Start pulse animation
this.animate()
}

private renderQRCode(url: string, centerX: number, centerY: number): void {
try {
const qr = QRCode.create(url, { errorCorrectionLevel: 'M' })
const modules = qr.modules
const moduleCount = modules.size

// Target ~250 game units wide for the QR code
const qrSize = 250
const cellSize = qrSize / moduleCount
const startX = centerX - qrSize / 2
const startY = centerY - qrSize / 2

const graphics = new Graphics()

// White background for QR code (with quiet zone)
const padding = cellSize * 2
graphics.roundRect(
startX - padding,
startY - padding,
qrSize + padding * 2,
qrSize + padding * 2,
8,
)
graphics.fill(0xffffff)

// Draw dark modules
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (modules.get(row, col)) {
graphics.rect(
startX + col * cellSize,
startY + row * cellSize,
cellSize,
cellSize,
)
graphics.fill(0x000000)
}
}
}

this.container.addChild(graphics)
} catch (error) {
console.error('[WaitingOverlay] Failed to generate QR code:', error)
}
}

private animate = (): void => {
if (this.destroyed) return

this.animationFrame++
// Subtle alpha pulse: oscillate between 0.6 and 1.0
const alpha = 0.8 + Math.sin(this.animationFrame * 0.05) * 0.2
this.titleText.alpha = alpha

requestAnimationFrame(this.animate)
}

destroy(): void {
this.destroyed = true
if (this.container.parent) {
this.container.parent.removeChild(this.container)
}
this.container.destroy({ children: true })
}
}
Loading