diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 2a84bb7..3120da1 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -5,6 +5,16 @@ RUN npm install -g pnpm@9 COPY . . RUN pnpm install --frozen-lockfile RUN pnpm --filter @rps-royale/shared build + +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_CONTRACT_ADDRESS +ARG NEXT_PUBLIC_CHAIN_ID +ARG NEXT_PUBLIC_HARDHAT_RPC_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV NEXT_PUBLIC_CONTRACT_ADDRESS=${NEXT_PUBLIC_CONTRACT_ADDRESS} +ENV NEXT_PUBLIC_CHAIN_ID=${NEXT_PUBLIC_CHAIN_ID} +ENV NEXT_PUBLIC_HARDHAT_RPC_URL=${NEXT_PUBLIC_HARDHAT_RPC_URL} + RUN pnpm --filter @rps-royale/web build ENV NODE_ENV=production EXPOSE 3000 diff --git a/apps/web/src/app/play/PlayClient.tsx b/apps/web/src/app/play/PlayClient.tsx deleted file mode 100644 index 8593b42..0000000 --- a/apps/web/src/app/play/PlayClient.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; -import { initGame } from '@/phaser/Game'; - -export default function PlayClient() { - const containerRef = useRef(null); - - useEffect(() => { - if (!containerRef.current) return; - const game = initGame('phaser-container'); - return () => { - game.destroy(true); - }; - }, []); - - return ( -
-
-
- ); -} diff --git a/apps/web/src/app/play/page.tsx b/apps/web/src/app/play/page.tsx index 60631a1..8a707ae 100644 --- a/apps/web/src/app/play/page.tsx +++ b/apps/web/src/app/play/page.tsx @@ -1,9 +1,63 @@ 'use client'; -import dynamic from 'next/dynamic'; - -const PlayClient = dynamic(() => import('./PlayClient'), { ssr: false }); +import { useEffect, useRef, useState } from 'react'; export default function PlayPage() { - return ; + const containerRef = useRef(null); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + const gameRef = useRef(null); + + useEffect(() => { + let mounted = true; + const init = async () => { + try { + const { initGame } = await import('@/phaser/Game'); + if (!mounted || !containerRef.current) return; + await new Promise((r) => setTimeout(r, 200)); + if (!mounted || !containerRef.current) return; + const game = initGame('phaser-container'); + gameRef.current = game; + setLoaded(true); + } catch (err: any) { + console.error('Failed to init Phaser game:', err); + setError(err?.message || 'Erreur de chargement du jeu'); + } + }; + init(); + return () => { + mounted = false; + if (gameRef.current) { + gameRef.current.destroy(true); + gameRef.current = null; + } + }; + }, []); + + if (error) { + return ( +
+

Erreur de chargement

+

{error}

+
+ ); + } + + return ( +
+ {!loaded && ( +
+

+ Chargement de l'arène... +

+
+ )} +
+ ); } diff --git a/apps/web/src/phaser/audio/AudioManager.ts b/apps/web/src/phaser/audio/AudioManager.ts new file mode 100644 index 0000000..ddc4757 --- /dev/null +++ b/apps/web/src/phaser/audio/AudioManager.ts @@ -0,0 +1,222 @@ +// Procedural audio using Web Audio API — no external files needed +class AudioManager { + private ctx: AudioContext | null = null; + private masterGain: GainNode | null = null; + private ambienceGain: GainNode | null = null; + private sfxGain: GainNode | null = null; + private ambienceOsc: OscillatorNode | null = null; + private ambienceLFO: OscillatorNode | null = null; + + private getCtx(): AudioContext { + if (!this.ctx) { + this.ctx = new AudioContext(); + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.value = 0.6; + this.masterGain.connect(this.ctx.destination); + + this.ambienceGain = this.ctx.createGain(); + this.ambienceGain.gain.value = 0; + this.ambienceGain.connect(this.masterGain); + + this.sfxGain = this.ctx.createGain(); + this.sfxGain.gain.value = 1.0; + this.sfxGain.connect(this.masterGain); + } + return this.ctx; + } + + resume() { + const ctx = this.getCtx(); + if (ctx.state === 'suspended') ctx.resume(); + } + + // Low drone for lobby background + startLobbyAmbience() { + const ctx = this.getCtx(); + if (this.ambienceOsc) return; + this.ambienceOsc = ctx.createOscillator(); + this.ambienceOsc.type = 'sine'; + this.ambienceOsc.frequency.value = 55; + this.ambienceLFO = ctx.createOscillator(); + this.ambienceLFO.type = 'sine'; + this.ambienceLFO.frequency.value = 0.2; + const lfoGain = ctx.createGain(); + lfoGain.gain.value = 8; + this.ambienceLFO.connect(lfoGain); + lfoGain.connect(this.ambienceOsc.frequency); + this.ambienceOsc.connect(this.ambienceGain!); + this.ambienceOsc.start(); + this.ambienceLFO.start(); + this.ambienceGain!.gain.setTargetAtTime(0.25, ctx.currentTime, 0.5); + } + + stopAmbience() { + if (!this.ctx || !this.ambienceGain) return; + this.ambienceGain.gain.setTargetAtTime(0, this.ctx.currentTime, 0.3); + setTimeout(() => { + this.ambienceOsc?.stop(); + this.ambienceLFO?.stop(); + this.ambienceOsc = null; + this.ambienceLFO = null; + }, 500); + } + + // Tension riser for suspense phase + playSuspenseRiser() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(80, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 3.5); + gain.gain.setValueAtTime(0, ctx.currentTime); + gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 1); + gain.gain.linearRampToValueAtTime(0, ctx.currentTime + 3.8); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 4); + + // Heartbeat layer + this.playHeartbeat(3.5, 0.12); + } + + playHeartbeat(durationSec: number, intensity: number) { + const ctx = this.getCtx(); + const start = ctx.currentTime; + const count = Math.floor(durationSec / 0.45); + for (let i = 0; i < count; i++) { + const t = start + i * 0.45; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 60; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(intensity, t + 0.05); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(t); + osc.stop(t + 0.25); + } + } + + // UI click sound + playClick() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(800, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.05); + gain.gain.setValueAtTime(0.2, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.1); + } + + // Commit confirmation sound + playCommit() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.setValueAtTime(200, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(600, ctx.currentTime + 0.15); + gain.gain.setValueAtTime(0.15, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.25); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.3); + } + + // Victory fanfare + playVictory() { + const ctx = this.getCtx(); + const notes = [523.25, 659.25, 783.99, 1046.5]; + notes.forEach((freq, i) => { + const t = ctx.currentTime + i * 0.12; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'triangle'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0, t); + gain.gain.linearRampToValueAtTime(0.18, t + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(t); + osc.stop(t + 0.4); + }); + + // Noise burst + const bufferSize = ctx.sampleRate * 0.3; + 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; + } + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(0.1, ctx.currentTime); + noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); + noise.connect(noiseGain); + noiseGain.connect(this.sfxGain!); + noise.start(); + } + + // Defeat sound + playDefeat() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(300, ctx.currentTime); + osc.frequency.exponentialRampToValueAtTime(80, ctx.currentTime + 0.6); + gain.gain.setValueAtTime(0.18, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.7); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.8); + } + + // Draw sound + playDraw() { + const ctx = this.getCtx(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = 440; + gain.gain.setValueAtTime(0.12, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); + osc.connect(gain); + gain.connect(this.sfxGain!); + osc.start(); + osc.stop(ctx.currentTime + 0.5); + } + + // Impact sound when result is shown + playImpact() { + const ctx = this.getCtx(); + const bufferSize = ctx.sampleRate * 0.2; + 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) * Math.exp(-i / (ctx.sampleRate * 0.03)); + } + const src = ctx.createBufferSource(); + src.buffer = buffer; + const gain = ctx.createGain(); + gain.gain.value = 0.25; + src.connect(gain); + gain.connect(this.sfxGain!); + src.start(); + } +} + +export const audio = new AudioManager(); diff --git a/apps/web/src/phaser/objects/AssetLoader.ts b/apps/web/src/phaser/objects/AssetLoader.ts new file mode 100644 index 0000000..9d13c5b --- /dev/null +++ b/apps/web/src/phaser/objects/AssetLoader.ts @@ -0,0 +1,106 @@ +import * as Phaser from 'phaser'; + +export function generateAssets(scene: Phaser.Scene) { + const g = scene.make.graphics({ x: 0, y: 0 }); + + // Rock — golem-like hexagon + g.fillStyle(0x64748b, 1); + g.fillCircle(64, 64, 56); + g.fillStyle(0x475569, 1); + g.fillCircle(64, 64, 44); + g.fillStyle(0x94a3b8, 1); + g.fillCircle(48, 48, 12); + g.fillCircle(80, 48, 12); + g.fillStyle(0x1e293b, 1); + g.fillCircle(48, 48, 6); + g.fillCircle(80, 48, 6); + g.generateTexture('rock', 128, 128); + g.clear(); + + // Paper — flowing scroll / leaf shape + g.fillStyle(0x22c55e, 1); + g.beginPath(); + g.moveTo(64, 8); + g.lineTo(120, 40); + g.lineTo(100, 120); + g.lineTo(28, 120); + g.lineTo(8, 40); + g.closePath(); + g.fillPath(); + g.fillStyle(0x15803d, 1); + g.beginPath(); + g.moveTo(64, 24); + g.lineTo(100, 48); + g.lineTo(88, 104); + g.lineTo(40, 104); + g.lineTo(28, 48); + g.closePath(); + g.fillPath(); + g.generateTexture('paper', 128, 128); + g.clear(); + + // Scissors — blade crossed shape + g.lineStyle(8, 0xef4444, 1); + g.beginPath(); + g.moveTo(20, 20); + g.lineTo(108, 108); + g.strokePath(); + g.beginPath(); + g.moveTo(108, 20); + g.lineTo(20, 108); + g.strokePath(); + g.fillStyle(0xfca5a5, 1); + g.fillCircle(64, 64, 18); + g.fillStyle(0xef4444, 1); + g.fillCircle(64, 64, 10); + g.generateTexture('scissors', 128, 128); + g.clear(); + + // Avatar placeholder (glowing orb) + g.fillStyle(0x06b6d4, 1); + g.fillCircle(64, 64, 60); + g.fillStyle(0x22d3ee, 1); + g.fillCircle(64, 64, 48); + g.fillStyle(0x0f172a, 1); + g.fillCircle(64, 64, 36); + g.generateTexture('avatar', 128, 128); + g.clear(); + + // AI avatar (purple orb) + g.fillStyle(0xa855f7, 1); + g.fillCircle(64, 64, 60); + g.fillStyle(0xc084fc, 1); + g.fillCircle(64, 64, 48); + g.fillStyle(0x0f172a, 1); + g.fillCircle(64, 64, 36); + g.generateTexture('avatarAI', 128, 128); + g.clear(); + + // VS badge + g.fillStyle(0xf59e0b, 1); + g.fillCircle(32, 32, 28); + g.fillStyle(0x78350f, 1); + g.fillCircle(32, 32, 22); + g.generateTexture('vs', 64, 64); + g.clear(); + + // Spark particle + g.fillStyle(0xffffff, 1); + g.fillCircle(4, 4, 4); + g.generateTexture('spark', 8, 8); + g.clear(); + + // Flare particle + g.fillStyle(0xffffff, 1); + g.fillCircle(8, 8, 8); + g.generateTexture('flare', 16, 16); + g.clear(); + + // Ring glow + g.lineStyle(4, 0x22d3ee, 1); + g.strokeCircle(64, 64, 60); + g.generateTexture('ring', 128, 128); + g.clear(); + + g.destroy(); +} diff --git a/apps/web/src/phaser/scenes/ArenaScene.ts b/apps/web/src/phaser/scenes/ArenaScene.ts index c62d423..26ebd70 100644 --- a/apps/web/src/phaser/scenes/ArenaScene.ts +++ b/apps/web/src/phaser/scenes/ArenaScene.ts @@ -3,6 +3,7 @@ import { UIButton } from '../objects/UIButton'; import { socket } from '@/lib/socket'; import { keccak256, solidityPacked, parseEther, BrowserProvider, Contract } from 'ethers'; import { Choice, MatchStatus, RPSArenaAbi } from '@rps-royale/shared'; +import { audio } from '../audio/AudioManager'; interface ArenaData { match: { @@ -26,8 +27,11 @@ export class ArenaScene extends Phaser.Scene { private infoText!: Phaser.GameObjects.Text; private choiceButtons: UIButton[] = []; private actionButton!: UIButton; - private particles!: any; private phase: 'commit' | 'reveal' = 'commit'; + private p1Avatar!: Phaser.GameObjects.Image; + private p2Avatar!: Phaser.GameObjects.Image; + private p1Label!: Phaser.GameObjects.Text; + private p2Label!: Phaser.GameObjects.Text; constructor() { super({ key: 'ArenaScene' }); @@ -54,14 +58,56 @@ export class ArenaScene extends Phaser.Scene { return this.matchId.startsWith('ai_'); } + createCyberpunkBackground(width: number, height: number) { + // Dark base + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + // Grid floor perspective effect using simple lines + const g = this.add.graphics(); + g.lineStyle(1, 0x1e293b, 0.6); + // Horizontal perspective lines + for (let i = 0; i < 12; i++) { + const y = height / 2 + i * 32; + g.lineBetween(0, y, width, y); + } + // Vertical vanishing lines + const cx = width / 2; + for (let i = -10; i <= 10; i++) { + const x = cx + i * 80; + g.lineStyle(1, 0x1e293b, 0.4); + g.lineBetween(x, height / 2, cx + i * 20, height); + } + g.lineStyle(2, 0x06b6d4, 0.8); + g.lineBetween(0, height / 2, width, height / 2); + + // Neon columns + const colColors = [0x06b6d4, 0xa855f7, 0xec4899]; + for (let i = 0; i < 6; i++) { + const x = (i + 0.5) * (width / 6); + const color = colColors[i % 3]; + this.add.rectangle(x, height / 2 - 100, 4, height / 2 - 80, color).setAlpha(0.3); + } + + // Ambient particles + this.add.particles(0, 0, 'spark', { + x: { min: 0, max: width }, + y: { min: 0, max: height }, + quantity: 1, + frequency: 180, + lifespan: 2000, + alpha: { start: 0.25, end: 0 }, + scale: { start: 0.4, end: 0 }, + tint: [0x06b6d4, 0xa855f7], + }); + } + create() { const { width, height } = this.scale; - // Background gradient feel via dark rects - this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + this.createCyberpunkBackground(width, height); this.add - .text(width / 2, 40, `TABLE #${this.matchId.slice(-6)}`, { + .text(width / 2, 36, `TABLE #${this.matchId.slice(-6)}`, { fontSize: '28px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#f8fafc', @@ -70,45 +116,69 @@ export class ArenaScene extends Phaser.Scene { .setOrigin(0.5); this.infoText = this.add - .text(width / 2, 80, `Mise: 0.01 ETH | En attente du commit`, { + .text(width / 2, 76, `Mise: ${this.betAmount} ETH | En attente du commit`, { fontSize: '16px', color: '#94a3b8', }) .setOrigin(0.5); this.statusText = this.add - .text(width / 2, height / 2, 'CHOISIS TON SIGNE', { - fontSize: '32px', + .text(width / 2, height / 2 - 60, 'CHOISIS TON SIGNE', { + fontSize: '36px', color: '#22d3ee', fontStyle: 'bold', }) .setOrigin(0.5); - // Choice buttons + // Player avatars + const p1Name = this.isPlayer1 ? 'Toi' : (this.isAIMatch ? 'IA' : 'Adversaire'); + const p2Name = this.isPlayer1 ? (this.isAIMatch ? 'IA' : 'Adversaire') : 'Toi'; + + this.p1Avatar = this.add.image(140, height / 2 - 40, 'avatar').setScale(1.0); + this.p2Avatar = this.add.image(width - 140, height / 2 - 40, this.isAIMatch ? 'avatarAI' : 'avatar').setScale(1.0); + + this.add.image(140, height / 2 - 40, 'ring').setScale(1.1).setAlpha(0.6); + this.add.image(width - 140, height / 2 - 40, 'ring').setScale(1.1).setAlpha(0.6); + + this.p1Label = this.add.text(140, height / 2 + 50, p1Name, { fontSize: '16px', color: '#cbd5e1' }).setOrigin(0.5); + this.p2Label = this.add.text(width - 140, height / 2 + 50, p2Name, { fontSize: '16px', color: '#cbd5e1' }).setOrigin(0.5); + + // VS badge in center + this.add.image(width / 2, height / 2 - 40, 'vs').setScale(1.0).setAlpha(0.8); + + // Choice buttons with icons const choices = [ - { label: 'PIERRE', value: Choice.Rock }, - { label: 'FEUILLE', value: Choice.Paper }, - { label: 'CISEAUX', value: Choice.Scissors }, + { label: 'PIERRE', value: Choice.Rock, tex: 'rock' }, + { label: 'FEUILLE', value: Choice.Paper, tex: 'paper' }, + { label: 'CISEAUX', value: Choice.Scissors, tex: 'scissors' }, ]; choices.forEach((c, i) => { - const btn = new UIButton(this, width / 2 - 280 + i * 280, height / 2 + 60, c.label, () => { + const x = width / 2 - 280 + i * 280; + const y = height / 2 + 100; + const icon = this.add.image(x, y - 50, c.tex).setScale(0.6).setAlpha(0.8); + const btn = new UIButton(this, x, y, c.label, () => { + audio.playClick(); this.selectChoice(c.value); + choices.forEach((cc, ii) => { + (this.children.getByName(`choiceIcon_${ii}`) as Phaser.GameObjects.Image)?.setAlpha(ii === i ? 1 : 0.3); + }); }); + icon.setName(`choiceIcon_${i}`); this.choiceButtons.push(btn); }); - // Action button (commit / reveal) - this.actionButton = new UIButton(this, width / 2, height / 2 + 160, 'Valider le Commit', () => { - if (this.phase === 'commit') this.sendCommit(); - else if (this.phase === 'reveal') this.sendReveal(); - }); + this.actionButton = new UIButton(this, width / 2, height / 2 + 220, 'Valider le Commit', () => { + if (this.phase === 'commit') { + audio.playCommit(); + this.sendCommit(); + } else if (this.phase === 'reveal') { + audio.playCommit(); + this.sendReveal(); + } + }, 280); this.actionButton.setDisabled(true); - // Particle emitter for atmosphere (initially off) - this.createParticles(); - - // Socket listeners socket.on('match:commitReceived', (data: { matchId: string }) => { if (data.matchId !== this.matchId) return; this.infoText.setText('Un joueur a commité... en attente du second'); @@ -203,15 +273,11 @@ export class ArenaScene extends Phaser.Scene { this.statusText.setText('SUSPENSE').setColor('#a855f7'); this.infoText.setText('Les deux joueurs ont commité. Prépare-toi...'); - // Camera zoom and shake + audio.playSuspenseRiser(); + this.cameras.main.zoomTo(1.1, 2000, 'Sine.easeInOut'); this.cameras.main.shake(3000, 0.005); - // Intense particles - this.particles.setFrequency(30); - this.particles.setLifespan(2000); - - // Flash effect const flash = this.add.rectangle(width / 2, height / 2, width, height, 0xffffff).setAlpha(0); this.tweens.add({ targets: flash, @@ -222,10 +288,9 @@ export class ArenaScene extends Phaser.Scene { onComplete: () => flash.destroy(), }); - // Countdown text let countdown = 4; const countdownText = this.add - .text(width / 2, height / 2 - 100, `${countdown}`, { + .text(width / 2, height / 2 - 120, `${countdown}`, { fontSize: '96px', color: '#f8fafc', fontStyle: '900', @@ -280,25 +345,6 @@ export class ArenaScene extends Phaser.Scene { this.statusText.setText('Révélation envoyée...'); } - createParticles() { - const graphics = this.make.graphics({ x: 0, y: 0 }); - graphics.fillStyle(0xffffff, 1); - graphics.fillCircle(4, 4, 4); - graphics.generateTexture('spark', 8, 8); - graphics.destroy(); - - this.particles = this.add.particles(0, 0, 'spark', { - x: { min: 0, max: this.scale.width }, - y: { min: 0, max: this.scale.height }, - quantity: 1, - frequency: 200, - lifespan: 2000, - alpha: { start: 0.2, end: 0 }, - scale: { start: 0.4, end: 0 }, - tint: [0x06b6d4, 0xa855f7], - }); - } - shutdown() { socket.off('match:commitReceived'); socket.off('match:suspenseStart'); diff --git a/apps/web/src/phaser/scenes/BootScene.ts b/apps/web/src/phaser/scenes/BootScene.ts index 1b980b5..46f920d 100644 --- a/apps/web/src/phaser/scenes/BootScene.ts +++ b/apps/web/src/phaser/scenes/BootScene.ts @@ -1,55 +1,68 @@ import * as Phaser from 'phaser'; import { UIButton } from '../objects/UIButton'; +import { generateAssets } from '../objects/AssetLoader'; +import { audio } from '../audio/AudioManager'; export class BootScene extends Phaser.Scene { constructor() { super({ key: 'BootScene' }); } + preload() { + generateAssets(this); + } + create() { const { width, height } = this.scale; + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + // Ambient particles + this.add.particles(0, 0, 'flare', { + x: { min: 0, max: width }, + y: { min: 0, max: height }, + quantity: 1, + frequency: 80, + lifespan: 4000, + alpha: { start: 0.25, end: 0 }, + scale: { start: 0.6, end: 0 }, + tint: [0x06b6d4, 0xa855f7, 0xec4899], + }); + this.add - .text(width / 2, height / 2 - 80, 'THE ARENA', { - fontSize: '64px', + .text(width / 2, height / 2 - 120, 'THE ARENA', { + fontSize: '80px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#f8fafc', fontStyle: '900', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setShadow(0, 0, '#06b6d4', 20, true, true); this.add - .text(width / 2, height / 2 - 20, 'RPS Royale', { - fontSize: '24px', + .text(width / 2, height / 2 - 40, 'RPS ROYALE', { + fontSize: '28px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#94a3b8', + letterSpacing: 8, }) .setOrigin(0.5); - new UIButton(this, width / 2, height / 2 + 80, 'Entrer dans l\'Arène', () => { + const leftAvatar = this.add.image(width / 2 - 220, height / 2 + 40, 'avatar').setScale(0.9); + const rightAvatar = this.add.image(width / 2 + 220, height / 2 + 40, 'avatarAI').setScale(0.9); + this.tweens.add({ targets: leftAvatar, angle: 360, duration: 20000, repeat: -1, ease: 'Linear' }); + this.tweens.add({ targets: rightAvatar, angle: -360, duration: 20000, repeat: -1, ease: 'Linear' }); + + this.add.image(width / 2, height / 2 + 40, 'vs').setScale(1.2); + + new UIButton(this, width / 2, height / 2 + 180, 'Entrer dans\'Arène', () => { + audio.resume(); + audio.playClick(); + audio.startLobbyAmbience(); this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('LobbyScene'); }); }); - - // Simple particle effect for atmosphere - const particles = this.add.particles(0, 0, 'flare', { - x: { min: 0, max: width }, - y: { min: 0, max: height }, - quantity: 1, - frequency: 100, - lifespan: 3000, - alpha: { start: 0.3, end: 0 }, - scale: { start: 0.5, end: 0 }, - tint: [0x06b6d4, 0xa855f7, 0xec4899], - }); - - // Create a simple flare texture procedurally - const graphics = this.make.graphics({ x: 0, y: 0, }); - graphics.fillStyle(0xffffff, 1); - graphics.fillCircle(8, 8, 8); - graphics.generateTexture('flare', 16, 16); - graphics.destroy(); } } diff --git a/apps/web/src/phaser/scenes/LobbyScene.ts b/apps/web/src/phaser/scenes/LobbyScene.ts index 2441094..2a060fc 100644 --- a/apps/web/src/phaser/scenes/LobbyScene.ts +++ b/apps/web/src/phaser/scenes/LobbyScene.ts @@ -1,6 +1,7 @@ import * as Phaser from 'phaser'; import { UIButton } from '../objects/UIButton'; import { socket } from '@/lib/socket'; +import { audio } from '../audio/AudioManager'; export class LobbyScene extends Phaser.Scene { private tablesText: Phaser.GameObjects.Text[] = []; @@ -13,14 +14,32 @@ export class LobbyScene extends Phaser.Scene { create() { const { width, height } = this.scale; + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + this.add.particles(0, 0, 'spark', { + x: { min: 0, max: width }, + y: { min: 0, max: height }, + quantity: 1, + frequency: 120, + lifespan: 3000, + alpha: { start: 0.2, end: 0 }, + scale: { start: 0.3, end: 0 }, + tint: [0x06b6d4, 0xa855f7], + }); + this.add - .text(width / 2, 60, 'LOBBY', { - fontSize: '40px', + .text(width / 2, 50, 'SALLES DE JEU', { + fontSize: '44px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color: '#f8fafc', fontStyle: '900', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setShadow(0, 0, '#22d3ee', 10, true, true); + + this.add.image(width / 2 - 260, 50, 'rock').setScale(0.35); + this.add.image(width / 2, 50, 'paper').setScale(0.35); + this.add.image(width / 2 + 260, 50, 'scissors').setScale(0.35); this.statusText = this.add .text(width / 2, 120, '', { @@ -29,17 +48,21 @@ export class LobbyScene extends Phaser.Scene { }) .setOrigin(0.5); - new UIButton(this, width / 2 - 140, height / 2 + 80, 'Matchmaking (0.01 ETH)', () => { + new UIButton(this, width / 2 - 200, height - 180, 'Matchmaking 0.01 ETH', () => { + audio.playClick(); this.requestMatch('0.01'); - }); + }, 280); - new UIButton(this, width / 2 + 140, height / 2 + 80, 'Matchmaking (0.05 ETH)', () => { + new UIButton(this, width / 2 + 200, height - 180, 'Matchmaking 0.05 ETH', () => { + audio.playClick(); this.requestMatch('0.05'); - }); + }, 280); - new UIButton(this, width / 2, height / 2 + 160, 'Jouer contre l\'IA', () => { + const aiBtn = new UIButton(this, width / 2, height - 90, 'Jouer contre l\'IA', () => { + audio.playClick(); this.requestAIMatch('0.01'); - }); + }, 320, 64); + aiBtn.setLabel('Jouer contre l\'IA'); socket.emit('lobby:join'); @@ -48,6 +71,7 @@ export class LobbyScene extends Phaser.Scene { }); socket.on('match:found', (match) => { + audio.stopAmbience(); this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('ArenaScene', { match }); @@ -58,7 +82,6 @@ export class LobbyScene extends Phaser.Scene { getAddress(): string { const eth = (window as any).ethereum; if (eth?.selectedAddress) return eth.selectedAddress; - // Generate a guest address if no wallet let guest = (window as any).__guestAddress; if (!guest) { guest = '0x' + Array.from({ length: 40 }, () => Math.floor(Math.random() * 16).toString(16)).join(''); @@ -80,14 +103,13 @@ export class LobbyScene extends Phaser.Scene { } renderTables(tables: any[]) { - // Clear previous this.tablesText.forEach((t) => t.destroy()); this.tablesText = []; const { width } = this.scale; if (tables.length === 0) { const t = this.add - .text(width / 2, 220, 'Aucune table active. Créez-en une !', { fontSize: '16px', color: '#64748b' }) + .text(width / 2, 200, 'Aucune table active. Créez-en une !', { fontSize: '16px', color: '#64748b' }) .setOrigin(0.5); this.tablesText.push(t); return; @@ -97,7 +119,7 @@ export class LobbyScene extends Phaser.Scene { const t = this.add .text( width / 2, - 200 + i * 40, + 180 + i * 44, `Table #${table.id.slice(0, 6)} — Mise: ${table.betAmount} ETH — Joueurs: ${table.players}/2`, { fontSize: '16px', color: '#cbd5e1' } ) diff --git a/apps/web/src/phaser/scenes/ResultScene.ts b/apps/web/src/phaser/scenes/ResultScene.ts index 8f55682..5121293 100644 --- a/apps/web/src/phaser/scenes/ResultScene.ts +++ b/apps/web/src/phaser/scenes/ResultScene.ts @@ -1,5 +1,6 @@ import * as Phaser from 'phaser'; import { UIButton } from '../objects/UIButton'; +import { audio } from '../audio/AudioManager'; interface ResultData { matchId: string; @@ -12,6 +13,7 @@ interface ResultData { } const choiceLabels = ['Pierre', 'Feuille', 'Ciseaux']; +const choiceTextures = ['rock', 'paper', 'scissors']; export class ResultScene extends Phaser.Scene { constructor() { @@ -40,18 +42,26 @@ export class ResultScene extends Phaser.Scene { } this.add - .text(width / 2, height / 2 - 140, resultText, { + .text(width / 2, height / 2 - 180, resultText, { fontSize: '72px', fontFamily: 'ui-sans-serif, system-ui, sans-serif', color, fontStyle: '900', }) - .setOrigin(0.5); + .setOrigin(0.5) + .setShadow(0, 0, color, 20, true, true); + + const p1Tex = choiceTextures[data.p1Choice]; + const p2Tex = choiceTextures[data.p2Choice]; + + this.add.image(width / 2 - 160, height / 2 - 40, p1Tex).setScale(1.4); + this.add.image(width / 2 + 160, height / 2 - 40, p2Tex).setScale(1.4); + this.add.image(width / 2, height / 2 - 40, 'vs').setScale(1.5); this.add .text( width / 2, - height / 2 - 60, + height / 2 + 60, `Toi: ${choiceLabels[data.isPlayer1 ? data.p1Choice : data.p2Choice]} vs Adversaire: ${ choiceLabels[data.isPlayer1 ? data.p2Choice : data.p1Choice] }`, @@ -63,21 +73,14 @@ export class ResultScene extends Phaser.Scene { this.add .text( width / 2, - height / 2, + height / 2 + 110, isWinner ? `Gain: ${data.payout} ETH` : 'Mise perdue', { fontSize: '28px', color: isWinner ? '#22c55e' : '#ef4444', fontStyle: 'bold' } ) .setOrigin(0.5); } - // Impact particles - const graphics = this.make.graphics({ x: 0, y: 0, }); - graphics.fillStyle(0xffffff, 1); - graphics.fillCircle(4, 4, 4); - graphics.generateTexture('resultSpark', 8, 8); - graphics.destroy(); - - this.add.particles(width / 2, height / 2 - 60, 'resultSpark', { + const emitter = this.add.particles(width / 2, height / 2 - 40, 'spark', { speed: { min: 100, max: 300 }, angle: { min: 0, max: 360 }, quantity: 40, @@ -86,15 +89,22 @@ export class ResultScene extends Phaser.Scene { scale: { start: 0.8, end: 0 }, tint: isDraw ? 0x94a3b8 : isWinner ? 0x22c55e : 0xef4444, }); + emitter?.explode(40); - new UIButton(this, width / 2, height / 2 + 140, 'Retour au Lobby', () => { + new UIButton(this, width / 2, height / 2 + 220, 'Retour au Lobby', () => { + audio.playClick(); + audio.startLobbyAmbience(); this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('LobbyScene'); }); }); - // Camera shake on win + audio.playImpact(); + if (isDraw) audio.playDraw(); + else if (isWinner) audio.playVictory(); + else audio.playDefeat(); + if (isWinner) { this.cameras.main.shake(500, 0.01); this.cameras.main.flash(300, 34, 197, 94); diff --git a/docker-compose.yml b/docker-compose.yml index b772cd3..0cdc27a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,11 @@ services: build: context: . dockerfile: apps/web/Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.jeu.cosmolan.fr} + NEXT_PUBLIC_CONTRACT_ADDRESS: ${NEXT_PUBLIC_CONTRACT_ADDRESS} + NEXT_PUBLIC_CHAIN_ID: ${NEXT_PUBLIC_CHAIN_ID:-31337} + NEXT_PUBLIC_HARDHAT_RPC_URL: ${NEXT_PUBLIC_HARDHAT_RPC_URL} container_name: rps-web restart: unless-stopped environment: