From fb4c8ffde650b35d1017adc05d755becc37e9b5c Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 24 May 2026 11:32:56 +0000 Subject: [PATCH] feat(graphics): add procedural assets and visual polish to all scenes - Add AssetLoader with procedurally generated sprites (rock, paper, scissors, avatars, VS badge, particles, ring glow) - Redesign BootScene with title glow, rotating avatars, ambient particles - Redesign LobbyScene with choice icons and improved layout - Redesign ArenaScene with player avatars, choice icons, visual feedback - Redesign ResultScene with large choice icons, VS badge, impact particles - Fix play page layout: remove debug magenta background Co-Authored-By: Claude Opus 4.7 --- apps/web/src/app/play/page.tsx | 31 +++--- apps/web/src/phaser/objects/AssetLoader.ts | 106 +++++++++++++++++++++ apps/web/src/phaser/scenes/ArenaScene.ts | 91 ++++++++++-------- apps/web/src/phaser/scenes/BootScene.ts | 63 +++++++----- apps/web/src/phaser/scenes/LobbyScene.ts | 45 ++++++--- apps/web/src/phaser/scenes/ResultScene.ts | 29 +++--- 6 files changed, 262 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/phaser/objects/AssetLoader.ts diff --git a/apps/web/src/app/play/page.tsx b/apps/web/src/app/play/page.tsx index 89a2a01..8a707ae 100644 --- a/apps/web/src/app/play/page.tsx +++ b/apps/web/src/app/play/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react'; export default function PlayPage() { const containerRef = useRef(null); const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); const gameRef = useRef(null); useEffect(() => { @@ -13,11 +14,11 @@ export default function PlayPage() { try { const { initGame } = await import('@/phaser/Game'); if (!mounted || !containerRef.current) return; - // Delay slightly to ensure container has layout - await new Promise((r) => setTimeout(r, 100)); + 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'); @@ -35,22 +36,28 @@ export default function PlayPage() { if (error) { return ( -
-

- Erreur de chargement -

+
+

Erreur de chargement

{error}

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

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

+
+ )}
); } 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 d5875f7..6b4ddcb 100644 --- a/apps/web/src/phaser/scenes/ArenaScene.ts +++ b/apps/web/src/phaser/scenes/ArenaScene.ts @@ -28,6 +28,10 @@ export class ArenaScene extends Phaser.Scene { 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' }); @@ -57,11 +61,11 @@ export class ArenaScene extends Phaser.Scene { create() { const { width, height } = this.scale; - // Background gradient feel via dark rects this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + // Title 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,43 +74,73 @@ 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 - 40, '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); + + // Glowing rings around avatars + 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); + + // 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 + 120; + 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, () => { this.selectChoice(c.value); + // Highlight selected icon + 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', () => { + this.actionButton = new UIButton(this, width / 2, height / 2 + 240, 'Valider le Commit', () => { if (this.phase === 'commit') this.sendCommit(); else if (this.phase === 'reveal') this.sendReveal(); - }); + }, 280); this.actionButton.setDisabled(true); - // Particle emitter for atmosphere (initially off) - this.createParticles(); + // Atmosphere 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.2, end: 0 }, + scale: { start: 0.3, end: 0 }, + tint: [0x06b6d4, 0xa855f7], + }); // Socket listeners socket.on('match:commitReceived', (data: { matchId: string }) => { @@ -207,12 +241,6 @@ export class ArenaScene extends Phaser.Scene { this.cameras.main.zoomTo(1.1, 2000, 'Sine.easeInOut'); this.cameras.main.shake(3000, 0.005); - // Intense particles - if (this.particles) { - this.particles.frequency = 30; - this.particles.lifespan = 2000; - } - // Flash effect const flash = this.add.rectangle(width / 2, height / 2, width, height, 0xffffff).setAlpha(0); this.tweens.add({ @@ -227,7 +255,7 @@ export class ArenaScene extends Phaser.Scene { // 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', @@ -282,25 +310,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..00cb936 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'; export class BootScene extends Phaser.Scene { constructor() { super({ key: 'BootScene' }); } + preload() { + generateAssets(this); + } + create() { const { width, height } = this.scale; + // Animated background gradient feel + 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], + }); + + // Big glowing title 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', () => { + // Decorative avatars rotating slowly + 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' }); + + // VS badge + this.add.image(width / 2, height / 2 + 40, 'vs').setScale(1.2); + + new UIButton(this, width / 2, height / 2 + 180, 'Entrer dans\'Arène', () => { 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..e37c3b4 100644 --- a/apps/web/src/phaser/scenes/LobbyScene.ts +++ b/apps/web/src/phaser/scenes/LobbyScene.ts @@ -13,14 +13,34 @@ export class LobbyScene extends Phaser.Scene { create() { const { width, height } = this.scale; + this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a); + + // Ambient particles + 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); + + // Decorative icons + 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 +49,18 @@ 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', () => { 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', () => { 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', () => { this.requestAIMatch('0.01'); - }); + }, 320, 64); + aiBtn.setLabel('Jouer contre l\'IA'); socket.emit('lobby:join'); @@ -58,7 +79,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 +100,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 +116,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 772ba21..3dec843 100644 --- a/apps/web/src/phaser/scenes/ResultScene.ts +++ b/apps/web/src/phaser/scenes/ResultScene.ts @@ -12,6 +12,7 @@ interface ResultData { } const choiceLabels = ['Pierre', 'Feuille', 'Ciseaux']; +const choiceTextures = ['rock', 'paper', 'scissors']; export class ResultScene extends Phaser.Scene { constructor() { @@ -39,19 +40,29 @@ export class ResultScene extends Phaser.Scene { color = '#ef4444'; } + // Title 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); + + // Show both choices as big icons + 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,7 +74,7 @@ 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' } ) @@ -71,13 +82,7 @@ export class ResultScene extends Phaser.Scene { } // 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(); - - const emitter = this.add.particles(width / 2, height / 2 - 60, 'resultSpark', { + const emitter = this.add.particles(width / 2, height / 2 - 60, 'spark', { speed: { min: 100, max: 300 }, angle: { min: 0, max: 360 }, quantity: 40, @@ -88,7 +93,7 @@ export class ResultScene extends Phaser.Scene { }); 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', () => { this.cameras.main.fadeOut(400, 0, 0, 0); this.time.delayedCall(400, () => { this.scene.start('LobbyScene');