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 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-24 11:32:56 +00:00
parent 419ed6eef0
commit fb4c8ffde6
6 changed files with 262 additions and 103 deletions

View file

@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react';
export default function PlayPage() {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const gameRef = useRef<any>(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 (
<div className="w-full h-screen flex flex-col items-center justify-center bg-slate-950 text-slate-200 gap-4 px-6">
<p className="text-red-400 font-bold text-lg">
Erreur de chargement
</p>
<div className="w-full flex flex-col items-center justify-center bg-slate-950 text-slate-200 gap-4 px-6"
style={{ height: 'calc(100vh - 64px)' }}>
<p className="text-red-400 font-bold text-lg">Erreur de chargement</p>
<p className="text-slate-400 text-sm">{error}</p>
</div>
);
}
return (
<div className="w-full h-screen flex items-center justify-center bg-slate-950">
<div
id="phaser-container"
ref={containerRef}
style={{ width: '100%', height: '100%', minHeight: '600px' }}
></div>
<div
ref={containerRef}
id="phaser-container"
className="relative w-full bg-slate-950"
style={{ height: 'calc(100vh - 64px)', minHeight: '600px' }}
>
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center bg-slate-950 z-10">
<p className="text-cyan-400 text-lg font-bold animate-pulse">
Chargement de l&apos;arène...
</p>
</div>
)}
</div>
);
}

View file

@ -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();
}

View file

@ -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');

View file

@ -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();
}
}

View file

@ -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' }
)

View file

@ -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');