Compare commits

..

5 commits

Author SHA1 Message Date
Ubuntu
c2cd2e7b3a docs: add game design doc and update README with current features
- Add GAME_DESIGN.md with complete mechanics, economy, modes,
  visual and audio design documentation
- Update README with procedural audio/graphics features,
  AI mode, guest play, and links to all docs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:38:05 +00:00
Ubuntu
6f00f46111 feat(animations): add clash animation, lock indicators, and visual feedback
- BootScene: pulsing glow behind title
- ArenaScene: lock icons (🔒) that appear when players commit,
  pulsating waiting indicator, avatar pulse during suspense
- ResultScene: icons slide in from sides and crash together with
  explosion particles, glowing background pulse
- Tweak suspense zoom to 1.15x, shake intensity to 0.008

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:35:47 +00:00
Ubuntu
44602413f1 feat(audio+visual): add procedural audio engine and cyberpunk arena
- Add AudioManager with Web Audio API procedural sounds:
  lobby ambience, click, commit, suspense riser, heartbeat,
  victory fanfare, defeat, draw, impact
- Integrate audio across BootScene, LobbyScene, ArenaScene, ResultScene
- Add cyberpunk grid floor background to ArenaScene with perspective
  lines, neon columns, and ambient particles
- Add VS badge to ArenaScene between avatars

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:38:50 +00:00
Ubuntu
fb4c8ffde6 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>
2026-05-24 11:32:56 +00:00
Ubuntu
419ed6eef0 fix(web): correct Phaser rendering and Socket.io API URL for Docker
- Rewrite /play page to use direct dynamic import inside useEffect
  instead of next/dynamic (fixes BAILOUT_TO_CLIENT_SIDE_RENDERING)
- Fix Phaser 3.86 particle API: use direct property assignment
  instead of setFrequency/setLifespan
- Add AI match detection and skip blockchain calls for AI opponents
- Pass NEXT_PUBLIC_* build args via Dockerfile/docker-compose
  so the built client points to the correct API endpoint
- Remove obsolete PlayClient.tsx

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 04:40:39 +00:00
12 changed files with 815 additions and 138 deletions

View file

@ -18,7 +18,7 @@ Un jeu multijoueur **Pierre-Feuille-Ciseaux** en 2D premium avec paris sur la bl
| API & DB | Express + Prisma ORM + PostgreSQL 17 |
| Cache | Redis |
| Blockchain | Hardhat + Solidity + Ethers.js v6 |
| Audio | Howler.js |
| Audio | Web Audio API (procédural) |
| CI/CD | Forgejo Actions |
| Reverse Proxy | Nginx Proxy Manager |
@ -32,9 +32,21 @@ rps-royale/
│ └── contracts/ # Hardhat + Solidity
├── packages/
│ └── shared/ # Types et constantes partagés
├── docs/ # Documentation complète
└── docker-compose.yml
```
## Fonctionnalités
- **Matchmaking temps réel** : appariement automatique par montant de mise
- **Mode IA** : joue contre un adversaire IA si personne n'est disponible
- **Sans wallet** : génération automatique d'une adresse guest
- **Anti-triche** : pattern commit-reveal via hash keccak256
- **Audio procédural** : musique et SFX générés en temps réel (pas de fichiers audio)
- **Graphismes procéduraux** : sprites générés par Phaser (pas d'assets externes)
- **Animations premium** : zoom, shake, particules, clash cinématique
- **Smart contract** : 7/7 tests passants, déployé sur Hardhat local
## Développement local
### Prérequis
@ -85,6 +97,14 @@ Le jeu repose sur un **pattern commit-reveal** blockchain pour garantir l'intég
Voir [docs/GAME_DESIGN.md](docs/GAME_DESIGN.md) pour la documentation complète.
## Documentation
- [Architecture](docs/ARCHITECTURE.md)
- [API Reference](docs/API_REFERENCE.md)
- [Smart Contract](docs/SMART_CONTRACT.md)
- [Déploiement](docs/DEPLOYMENT.md)
- [Game Design](docs/GAME_DESIGN.md)
## Licence
MIT

View file

@ -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

View file

@ -1,22 +0,0 @@
'use client';
import { useEffect, useRef } from 'react';
import { initGame } from '@/phaser/Game';
export default function PlayClient() {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const game = initGame('phaser-container');
return () => {
game.destroy(true);
};
}, []);
return (
<div className="w-full h-screen flex items-center justify-center bg-slate-950">
<div id="phaser-container" ref={containerRef} className="w-full h-full"></div>
</div>
);
}

View file

@ -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 <PlayClient />;
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
const gameRef = useRef<any>(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 (
<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
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,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();

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

@ -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,14 @@ 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;
private p1Lock!: Phaser.GameObjects.Text;
private p2Lock!: Phaser.GameObjects.Text;
private waitingIndicator!: Phaser.GameObjects.Text;
constructor() {
super({ key: 'ArenaScene' });
@ -54,14 +61,50 @@ export class ArenaScene extends Phaser.Scene {
return this.matchId.startsWith('ai_');
}
createCyberpunkBackground(width: number, height: number) {
this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a);
const g = this.add.graphics();
g.lineStyle(1, 0x1e293b, 0.6);
for (let i = 0; i < 12; i++) {
const y = height / 2 + i * 32;
g.lineBetween(0, y, width, y);
}
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);
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);
}
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,48 +113,97 @@ 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 with lock indicators
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);
this.p1Lock = this.add.text(140, height / 2 - 90, '', { fontSize: '24px', color: '#22c55e' }).setOrigin(0.5);
this.p2Lock = this.add.text(width - 140, height / 2 - 90, '', { fontSize: '24px', color: '#22c55e' }).setOrigin(0.5);
this.add.image(width / 2, height / 2 - 40, 'vs').setScale(1.0).setAlpha(0.8);
// Waiting for opponent text (initially hidden)
this.waitingIndicator = this.add
.text(width / 2, height / 2 + 10, '', { fontSize: '14px', color: '#f59e0b' })
.setOrigin(0.5)
.setAlpha(0);
// 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 }) => {
socket.on('match:commitReceived', (data: { matchId: string; playerAddress: string }) => {
if (data.matchId !== this.matchId) return;
const isMe = data.playerAddress.toLowerCase() === this.playerAddress.toLowerCase();
if (isMe) {
this.p1Lock.setText('🔒');
this.tweens.add({ targets: this.p1Lock, scaleX: 1.5, scaleY: 1.5, duration: 200, yoyo: true });
} else {
this.p2Lock.setText('🔒');
this.tweens.add({ targets: this.p2Lock, scaleX: 1.5, scaleY: 1.5, duration: 200, yoyo: true });
}
this.infoText.setText('Un joueur a commité... en attente du second');
this.waitingIndicator.setText('En attente de l\'adversaire...').setAlpha(1);
this.tweens.add({
targets: this.waitingIndicator,
alpha: 0.4,
duration: 800,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
});
socket.on('match:suspenseStart', (matchId: string) => {
@ -202,40 +294,44 @@ export class ArenaScene extends Phaser.Scene {
const { width, height } = this.scale;
this.statusText.setText('SUSPENSE').setColor('#a855f7');
this.infoText.setText('Les deux joueurs ont commité. Prépare-toi...');
this.waitingIndicator.setAlpha(0);
this.tweens.killTweensOf(this.waitingIndicator);
// Camera zoom and shake
this.cameras.main.zoomTo(1.1, 2000, 'Sine.easeInOut');
this.cameras.main.shake(3000, 0.005);
audio.playSuspenseRiser();
// Intense particles
this.particles.setFrequency(30);
this.particles.setLifespan(2000);
this.cameras.main.zoomTo(1.15, 2000, 'Sine.easeInOut');
this.cameras.main.shake(3000, 0.008);
// Both avatars pulse with energy
this.tweens.add({ targets: this.p1Avatar, scaleX: 1.3, scaleY: 1.3, duration: 500, yoyo: true, repeat: 3 });
this.tweens.add({ targets: this.p2Avatar, scaleX: 1.3, scaleY: 1.3, duration: 500, yoyo: true, repeat: 3 });
// Flash effect
const flash = this.add.rectangle(width / 2, height / 2, width, height, 0xffffff).setAlpha(0);
this.tweens.add({
targets: flash,
alpha: 0.15,
alpha: 0.12,
duration: 200,
yoyo: true,
repeat: 6,
onComplete: () => flash.destroy(),
});
// Countdown text
let countdown = 4;
const countdownText = this.add
.text(width / 2, height / 2 - 100, `${countdown}`, {
fontSize: '96px',
.text(width / 2, height / 2 - 120, `${countdown}`, {
fontSize: '120px',
color: '#f8fafc',
fontStyle: '900',
})
.setOrigin(0.5)
.setAlpha(0);
.setAlpha(0)
.setScale(0.8);
this.tweens.add({
targets: countdownText,
alpha: 1,
scaleX: 1.2,
scaleY: 1.2,
duration: 500,
repeat: 3,
yoyo: true,
@ -280,25 +376,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,81 @@
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);
// Animated background stars/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],
});
// Pulsing glow behind title
const glow = this.add.circle(width / 2, height / 2 - 120, 180, 0x06b6d4, 0.15);
this.tweens.add({
targets: glow,
scaleX: 1.3,
scaleY: 1.3,
alpha: 0.05,
duration: 2000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
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();
}
}

View file

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

View file

@ -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() {
@ -39,19 +41,89 @@ export class ResultScene extends Phaser.Scene {
color = '#ef4444';
}
// Pulsing glow behind result
const glow = this.add.circle(width / 2, height / 2 - 180, 200, parseInt(color.replace('#', '0x')), 0.12);
this.tweens.add({
targets: glow,
scaleX: 1.4,
scaleY: 1.4,
alpha: 0.04,
duration: 1500,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
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);
// Animated clash: icons start far apart and smash together
const p1Tex = choiceTextures[data.p1Choice];
const p2Tex = choiceTextures[data.p2Choice];
const leftIcon = this.add.image(-80, height / 2 - 40, p1Tex).setScale(0.2).setAlpha(0);
const rightIcon = this.add.image(width + 80, height / 2 - 40, p2Tex).setScale(0.2).setAlpha(0);
// Animate icons sliding in from sides and crashing
this.tweens.add({
targets: leftIcon,
x: width / 2 - 100,
scaleX: 1.4,
scaleY: 1.4,
alpha: 1,
duration: 600,
ease: 'Back.out',
delay: 200,
});
this.tweens.add({
targets: rightIcon,
x: width / 2 + 100,
scaleX: 1.4,
scaleY: 1.4,
alpha: 1,
duration: 600,
ease: 'Back.out',
delay: 200,
});
// Crash effect after they meet
this.time.delayedCall(800, () => {
this.tweens.add({
targets: [leftIcon, rightIcon],
scaleX: 1.6,
scaleY: 1.6,
duration: 150,
yoyo: true,
ease: 'Bounce.easeOut',
});
// Impact explosion at center
const explosion = this.add.particles(width / 2, height / 2 - 40, 'spark', {
speed: { min: 150, max: 500 },
angle: { min: 0, max: 360 },
quantity: 60,
lifespan: 1200,
alpha: { start: 1, end: 0 },
scale: { start: 1.2, end: 0 },
tint: isDraw ? 0x94a3b8 : isWinner ? 0x22c55e : 0xef4444,
});
explosion?.explode(60);
this.time.delayedCall(1300, () => explosion?.destroy());
});
this.add.image(width / 2, height / 2 - 40, 'vs').setScale(1.5).setAlpha(0.8);
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,40 +135,41 @@ 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', {
speed: { min: 100, max: 300 },
// Secondary lingering particles
const emitter = this.add.particles(width / 2, height / 2 - 40, 'spark', {
speed: { min: 50, max: 200 },
angle: { min: 0, max: 360 },
quantity: 40,
lifespan: 1500,
alpha: { start: 0.8, end: 0 },
scale: { start: 0.8, end: 0 },
quantity: 20,
lifespan: 2000,
alpha: { start: 0.6, end: 0 },
scale: { start: 0.6, end: 0 },
tint: isDraw ? 0x94a3b8 : isWinner ? 0x22c55e : 0xef4444,
});
emitter?.explode(30);
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.shake(600, 0.015);
this.cameras.main.flash(300, 34, 197, 94);
}
}

View file

@ -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:

84
docs/GAME_DESIGN.md Normal file
View file

@ -0,0 +1,84 @@
# Game Design — The Arena RPS Royale
## Concept
The Arena RPS Royale est un jeu multijoueur **Pierre-Feuille-Ciseaux** en 2D premium avec paris basés sur la blockchain. Le gameplay repose sur un pattern **commit-reveal** garantissant l'impossibilité de tricher.
## Mécaniques de jeu
### 1. Commit (Phase de verrouillage)
- Le joueur choisit son signe (Pierre, Feuille ou Ciseaux)
- Le frontend calcule un hash : `keccak256(choice + nonce)`
- Le joueur envoie le hash au serveur et, si wallet connecté, à la blockchain
- Le choix reste secret : impossible de le deviner à partir du hash
### 2. Suspense (Phase de tension)
- Quand les deux joueurs ont commité, une phase de suspense démarre
- Zoom caméra, shake, particules intenses
- Compte à rebours de 4 secondes
- Audio : riser montant + heartbeat
### 3. Reveal (Phase de révélation)
- Les joueurs révèlent leur choix + nonce
- Le serveur (et le smart contract) vérifient que le hash correspond
- Le gagnant est déterminé selon les règles classiques
### 4. Résultat (Cinématique)
- Les icônes s'animent depuis les côtés et se heurtent au centre
- Explosion de particules colorées selon le résultat
- Affichage du gain ou de la perte
- Audio : fanfare de victoire, son de défaite, ou son de match nul
## Économie
- Mise minimale : 0.001 ETH (configurable)
- Frais plateforme : 3%
- Gain : 97% du pot total au gagnant
- Match nul : remboursement 100%
## Modes de jeu
### Matchmaking (multijoueur)
- Queue automatique par montant de mise
- Appariement des deux joueurs
- Match enregistré sur la blockchain
### Contre l'IA (solo)
- Si aucun adversaire n'est disponible, l'IA simule un adversaire
- Délai de réponse aléatoire (13s) pour le commit et le reveal
- Pas de transaction blockchain nécessaire
- Adresse IA : `0xA1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1A1`
### Sans wallet (invité)
- Le jeu génère automatiquement une adresse guest si MetaMask n'est pas détecté
- L'invité peut jouer en mode IA sans connecter de wallet
## Expérience visuelle
### Scènes Phaser
| Scène | Description |
|-------|-------------|
| BootScene | Écran titre avec particules flottantes, avatars tournants, glow pulsant |
| LobbyScene | Liste des tables, boutons de matchmaking, mode IA |
| ArenaScene | Arène cyberpunk avec grille perspective, colonnes néons, avatars, VS badge |
| ResultScene | Cinématique de clash des icônes avec explosion de particules |
### Effets visuels
- **Particules** : étincelles flottantes, explosions d'impact
- **Camera** : zoom, shake, flash
- **Tweening** : pulsations, déplacements, apparitions
- **Glow** : halos néons autour des textes et avatars
### Audio procédural
- **Lobby** : drone basse ambiant
- **Clic** : son triangulaire court
- **Commit** : montée de fréquence carrée
- **Suspense** : riser sawtooth + heartbeat
- **Victoire** : fanfare arpégée + bruit blanc
- **Défaite** : descente de fréquence sawtooth
- **Impact** : bruit blanc filtré exponentiellement
## Contrôles
- Souris : survol, clic sur les boutons et icônes
- MetaMask : connexion wallet (optionnel)