Compare commits
3 commits
main
...
fix/audio-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44602413f1 | ||
|
|
fb4c8ffde6 | ||
|
|
419ed6eef0 |
10 changed files with 592 additions and 126 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'arène...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
222
apps/web/src/phaser/audio/AudioManager.ts
Normal file
222
apps/web/src/phaser/audio/AudioManager.ts
Normal 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();
|
||||
106
apps/web/src/phaser/objects/AssetLoader.ts
Normal file
106
apps/web/src/phaser/objects/AssetLoader.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue