Compare commits
No commits in common. "fix/e2e-rate-limit" and "main" have entirely different histories.
fix/e2e-ra
...
main
19 changed files with 140 additions and 954 deletions
22
README.md
22
README.md
|
|
@ -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 | Web Audio API (procédural) |
|
||||
| Audio | Howler.js |
|
||||
| CI/CD | Forgejo Actions |
|
||||
| Reverse Proxy | Nginx Proxy Manager |
|
||||
|
||||
|
|
@ -32,21 +32,9 @@ 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
|
||||
|
|
@ -97,14 +85,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"cors": "^2.8.5",
|
||||
"ethers": "^6.13.0",
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"ioredis": "^5.4.0",
|
||||
"socket.io": "^4.8.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import express from 'express';
|
|||
import { createServer as createHttpServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { setupSocketHandlers } from './socket/handlers.js';
|
||||
import { apiRoutes } from './api/routes.js';
|
||||
import { listenToContractEvents } from './blockchain/service.js';
|
||||
|
|
@ -11,16 +10,6 @@ export function createServer(port: number | string) {
|
|||
const app = express();
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting for REST API
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 120,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
});
|
||||
app.use('/api', apiLimiter);
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
const httpServer = createHttpServer(app);
|
||||
|
|
|
|||
|
|
@ -5,16 +5,6 @@ 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
|
||||
|
|
|
|||
22
apps/web/src/app/play/PlayClient.tsx
Normal file
22
apps/web/src/app/play/PlayClient.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
'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,63 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const PlayClient = dynamic(() => import('./PlayClient'), { ssr: false });
|
||||
|
||||
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(() => {
|
||||
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>
|
||||
);
|
||||
return <PlayClient />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
// 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();
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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,7 +3,6 @@ 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: {
|
||||
|
|
@ -27,14 +26,8 @@ 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' });
|
||||
|
|
@ -61,50 +54,14 @@ 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;
|
||||
|
||||
this.createCyberpunkBackground(width, height);
|
||||
// Background gradient feel via dark rects
|
||||
this.add.rectangle(width / 2, height / 2, width, height, 0x0f172a);
|
||||
|
||||
this.add
|
||||
.text(width / 2, 36, `TABLE #${this.matchId.slice(-6)}`, {
|
||||
.text(width / 2, 40, `TABLE #${this.matchId.slice(-6)}`, {
|
||||
fontSize: '28px',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
color: '#f8fafc',
|
||||
|
|
@ -113,97 +70,48 @@ export class ArenaScene extends Phaser.Scene {
|
|||
.setOrigin(0.5);
|
||||
|
||||
this.infoText = this.add
|
||||
.text(width / 2, 76, `Mise: ${this.betAmount} ETH | En attente du commit`, {
|
||||
.text(width / 2, 80, `Mise: 0.01 ETH | En attente du commit`, {
|
||||
fontSize: '16px',
|
||||
color: '#94a3b8',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.statusText = this.add
|
||||
.text(width / 2, height / 2 - 60, 'CHOISIS TON SIGNE', {
|
||||
fontSize: '36px',
|
||||
.text(width / 2, height / 2, 'CHOISIS TON SIGNE', {
|
||||
fontSize: '32px',
|
||||
color: '#22d3ee',
|
||||
fontStyle: 'bold',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// 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
|
||||
// Choice buttons
|
||||
const choices = [
|
||||
{ label: 'PIERRE', value: Choice.Rock, tex: 'rock' },
|
||||
{ label: 'FEUILLE', value: Choice.Paper, tex: 'paper' },
|
||||
{ label: 'CISEAUX', value: Choice.Scissors, tex: 'scissors' },
|
||||
{ label: 'PIERRE', value: Choice.Rock },
|
||||
{ label: 'FEUILLE', value: Choice.Paper },
|
||||
{ label: 'CISEAUX', value: Choice.Scissors },
|
||||
];
|
||||
|
||||
choices.forEach((c, i) => {
|
||||
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();
|
||||
const btn = new UIButton(this, width / 2 - 280 + i * 280, height / 2 + 60, c.label, () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
// 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.setDisabled(true);
|
||||
|
||||
socket.on('match:commitReceived', (data: { matchId: string; playerAddress: string }) => {
|
||||
// Particle emitter for atmosphere (initially off)
|
||||
this.createParticles();
|
||||
|
||||
// Socket listeners
|
||||
socket.on('match:commitReceived', (data: { matchId: 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) => {
|
||||
|
|
@ -294,44 +202,40 @@ 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);
|
||||
|
||||
audio.playSuspenseRiser();
|
||||
// Camera zoom and shake
|
||||
this.cameras.main.zoomTo(1.1, 2000, 'Sine.easeInOut');
|
||||
this.cameras.main.shake(3000, 0.005);
|
||||
|
||||
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 });
|
||||
// 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,
|
||||
alpha: 0.12,
|
||||
alpha: 0.15,
|
||||
duration: 200,
|
||||
yoyo: true,
|
||||
repeat: 6,
|
||||
onComplete: () => flash.destroy(),
|
||||
});
|
||||
|
||||
// Countdown text
|
||||
let countdown = 4;
|
||||
const countdownText = this.add
|
||||
.text(width / 2, height / 2 - 120, `${countdown}`, {
|
||||
fontSize: '120px',
|
||||
.text(width / 2, height / 2 - 100, `${countdown}`, {
|
||||
fontSize: '96px',
|
||||
color: '#f8fafc',
|
||||
fontStyle: '900',
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setAlpha(0)
|
||||
.setScale(0.8);
|
||||
.setAlpha(0);
|
||||
|
||||
this.tweens.add({
|
||||
targets: countdownText,
|
||||
alpha: 1,
|
||||
scaleX: 1.2,
|
||||
scaleY: 1.2,
|
||||
duration: 500,
|
||||
repeat: 3,
|
||||
yoyo: true,
|
||||
|
|
@ -376,6 +280,25 @@ 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,81 +1,55 @@
|
|||
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 - 120, 'THE ARENA', {
|
||||
fontSize: '80px',
|
||||
.text(width / 2, height / 2 - 80, 'THE ARENA', {
|
||||
fontSize: '64px',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
color: '#f8fafc',
|
||||
fontStyle: '900',
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setShadow(0, 0, '#06b6d4', 20, true, true);
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.add
|
||||
.text(width / 2, height / 2 - 40, 'RPS ROYALE', {
|
||||
fontSize: '28px',
|
||||
.text(width / 2, height / 2 - 20, 'RPS Royale', {
|
||||
fontSize: '24px',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
color: '#94a3b8',
|
||||
letterSpacing: 8,
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
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();
|
||||
new UIButton(this, width / 2, height / 2 + 80, 'Entrer dans l\'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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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[] = [];
|
||||
|
|
@ -14,32 +13,14 @@ 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, 50, 'SALLES DE JEU', {
|
||||
fontSize: '44px',
|
||||
.text(width / 2, 60, 'LOBBY', {
|
||||
fontSize: '40px',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
color: '#f8fafc',
|
||||
fontStyle: '900',
|
||||
})
|
||||
.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);
|
||||
.setOrigin(0.5);
|
||||
|
||||
this.statusText = this.add
|
||||
.text(width / 2, 120, '', {
|
||||
|
|
@ -48,21 +29,17 @@ export class LobbyScene extends Phaser.Scene {
|
|||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
new UIButton(this, width / 2 - 200, height - 180, 'Matchmaking 0.01 ETH', () => {
|
||||
audio.playClick();
|
||||
new UIButton(this, width / 2 - 140, height / 2 + 80, 'Matchmaking (0.01 ETH)', () => {
|
||||
this.requestMatch('0.01');
|
||||
}, 280);
|
||||
});
|
||||
|
||||
new UIButton(this, width / 2 + 200, height - 180, 'Matchmaking 0.05 ETH', () => {
|
||||
audio.playClick();
|
||||
new UIButton(this, width / 2 + 140, height / 2 + 80, 'Matchmaking (0.05 ETH)', () => {
|
||||
this.requestMatch('0.05');
|
||||
}, 280);
|
||||
});
|
||||
|
||||
const aiBtn = new UIButton(this, width / 2, height - 90, 'Jouer contre l\'IA', () => {
|
||||
audio.playClick();
|
||||
new UIButton(this, width / 2, height / 2 + 160, 'Jouer contre l\'IA', () => {
|
||||
this.requestAIMatch('0.01');
|
||||
}, 320, 64);
|
||||
aiBtn.setLabel('Jouer contre l\'IA');
|
||||
});
|
||||
|
||||
socket.emit('lobby:join');
|
||||
|
||||
|
|
@ -71,7 +48,6 @@ 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 });
|
||||
|
|
@ -82,6 +58,7 @@ 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('');
|
||||
|
|
@ -103,13 +80,14 @@ 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, 200, 'Aucune table active. Créez-en une !', { fontSize: '16px', color: '#64748b' })
|
||||
.text(width / 2, 220, 'Aucune table active. Créez-en une !', { fontSize: '16px', color: '#64748b' })
|
||||
.setOrigin(0.5);
|
||||
this.tablesText.push(t);
|
||||
return;
|
||||
|
|
@ -119,7 +97,7 @@ export class LobbyScene extends Phaser.Scene {
|
|||
const t = this.add
|
||||
.text(
|
||||
width / 2,
|
||||
180 + i * 44,
|
||||
200 + i * 40,
|
||||
`Table #${table.id.slice(0, 6)} — Mise: ${table.betAmount} ETH — Joueurs: ${table.players}/2`,
|
||||
{ fontSize: '16px', color: '#cbd5e1' }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { UIButton } from '../objects/UIButton';
|
||||
import { audio } from '../audio/AudioManager';
|
||||
|
||||
interface ResultData {
|
||||
matchId: string;
|
||||
|
|
@ -13,7 +12,6 @@ interface ResultData {
|
|||
}
|
||||
|
||||
const choiceLabels = ['Pierre', 'Feuille', 'Ciseaux'];
|
||||
const choiceTextures = ['rock', 'paper', 'scissors'];
|
||||
|
||||
export class ResultScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
|
|
@ -41,89 +39,19 @@ 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 - 180, resultText, {
|
||||
.text(width / 2, height / 2 - 140, resultText, {
|
||||
fontSize: '72px',
|
||||
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
||||
color,
|
||||
fontStyle: '900',
|
||||
})
|
||||
.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);
|
||||
.setOrigin(0.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]
|
||||
}`,
|
||||
|
|
@ -135,41 +63,40 @@ export class ResultScene extends Phaser.Scene {
|
|||
this.add
|
||||
.text(
|
||||
width / 2,
|
||||
height / 2 + 110,
|
||||
height / 2,
|
||||
isWinner ? `Gain: ${data.payout} ETH` : 'Mise perdue',
|
||||
{ fontSize: '28px', color: isWinner ? '#22c55e' : '#ef4444', fontStyle: 'bold' }
|
||||
)
|
||||
.setOrigin(0.5);
|
||||
}
|
||||
|
||||
// Secondary lingering particles
|
||||
const emitter = this.add.particles(width / 2, height / 2 - 40, 'spark', {
|
||||
speed: { min: 50, max: 200 },
|
||||
// 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 },
|
||||
angle: { min: 0, max: 360 },
|
||||
quantity: 20,
|
||||
lifespan: 2000,
|
||||
alpha: { start: 0.6, end: 0 },
|
||||
scale: { start: 0.6, end: 0 },
|
||||
quantity: 40,
|
||||
lifespan: 1500,
|
||||
alpha: { start: 0.8, end: 0 },
|
||||
scale: { start: 0.8, end: 0 },
|
||||
tint: isDraw ? 0x94a3b8 : isWinner ? 0x22c55e : 0xef4444,
|
||||
});
|
||||
emitter?.explode(30);
|
||||
|
||||
new UIButton(this, width / 2, height / 2 + 220, 'Retour au Lobby', () => {
|
||||
audio.playClick();
|
||||
audio.startLobbyAmbience();
|
||||
new UIButton(this, width / 2, height / 2 + 140, 'Retour au Lobby', () => {
|
||||
this.cameras.main.fadeOut(400, 0, 0, 0);
|
||||
this.time.delayedCall(400, () => {
|
||||
this.scene.start('LobbyScene');
|
||||
});
|
||||
});
|
||||
|
||||
audio.playImpact();
|
||||
if (isDraw) audio.playDraw();
|
||||
else if (isWinner) audio.playVictory();
|
||||
else audio.playDefeat();
|
||||
|
||||
// Camera shake on win
|
||||
if (isWinner) {
|
||||
this.cameras.main.shake(600, 0.015);
|
||||
this.cameras.main.shake(500, 0.01);
|
||||
this.cameras.main.flash(300, 34, 197, 94);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,11 +74,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
# 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 (1–3s) 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)
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('The Arena — RPS Royale', () => {
|
||||
test('play page loads with Phaser canvas', async ({ page }) => {
|
||||
await page.goto('/play');
|
||||
|
||||
// Wait for Phaser canvas to appear
|
||||
const canvas = page.locator('canvas');
|
||||
await canvas.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// BootScene title should be visible in the canvas (we can't easily read canvas text,
|
||||
// but we can verify the canvas has a non-zero size)
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box!.width).toBeGreaterThan(0);
|
||||
expect(box!.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('health endpoint responds', async ({ request }) => {
|
||||
const response = await request.get('http://127.0.0.1:3051/api/health');
|
||||
expect(response.status()).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe('ok');
|
||||
});
|
||||
|
||||
test('leaderboard page loads', async ({ page }) => {
|
||||
const response = await page.goto('/leaderboard');
|
||||
expect(response?.status()).toBe(200);
|
||||
});
|
||||
|
||||
test('home page loads', async ({ page }) => {
|
||||
const response = await page.goto('/');
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('h1', { hasText: 'The Arena' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
"dev": "pnpm --parallel dev",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"db:migrate": "pnpm --filter server db:migrate",
|
||||
"db:generate": "pnpm --filter server db:generate",
|
||||
|
|
@ -18,8 +17,6 @@
|
|||
"contract:deploy:sepolia": "pnpm --filter contracts deploy:sepolia"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"playwright": "^1.60.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0"
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:3050',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
66
pnpm-lock.yaml
generated
66
pnpm-lock.yaml
generated
|
|
@ -8,12 +8,6 @@ importers:
|
|||
|
||||
.:
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
playwright:
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
|
|
@ -47,9 +41,6 @@ importers:
|
|||
express:
|
||||
specifier: ^4.21.0
|
||||
version: 4.22.2
|
||||
express-rate-limit:
|
||||
specifier: ^8.5.2
|
||||
version: 8.5.2(express@4.22.2)
|
||||
ioredis:
|
||||
specifier: ^5.4.0
|
||||
version: 5.10.1
|
||||
|
|
@ -89,7 +80,7 @@ importers:
|
|||
version: 2.2.4
|
||||
next:
|
||||
specifier: ^15.1.0
|
||||
version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
phaser:
|
||||
specifier: ^3.86.0
|
||||
version: 3.90.0
|
||||
|
|
@ -803,11 +794,6 @@ packages:
|
|||
resolution: {integrity: sha512-q4n32/FNKIhQ3zQGGw5CvPF6GTvDCpYwIf7bEY/dZTZbgfDsHyjJwURxUJf3VQuuJj+fDIFl4+KkBVbw4Ef6jA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@prisma/client@5.22.0':
|
||||
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
|
||||
engines: {node: '>=16.13'}
|
||||
|
|
@ -1650,12 +1636,6 @@ packages:
|
|||
evp_bytestokey@1.0.3:
|
||||
resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
|
||||
|
||||
express-rate-limit@8.5.2:
|
||||
resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@4.22.2:
|
||||
resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
|
@ -1762,11 +1742,6 @@ packages:
|
|||
fs.realpath@1.0.0:
|
||||
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
|
|
@ -1965,10 +1940,6 @@ packages:
|
|||
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ip-address@10.2.0:
|
||||
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
|
@ -2409,16 +2380,6 @@ packages:
|
|||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.60.0:
|
||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3873,10 +3834,6 @@ snapshots:
|
|||
'@nomicfoundation/solidity-analyzer-linux-x64-musl': 0.1.2
|
||||
'@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.2
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
dependencies:
|
||||
playwright: 1.60.0
|
||||
|
||||
'@prisma/client@5.22.0(prisma@5.22.0)':
|
||||
optionalDependencies:
|
||||
prisma: 5.22.0
|
||||
|
|
@ -4866,11 +4823,6 @@ snapshots:
|
|||
md5.js: 1.3.5
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
express-rate-limit@8.5.2(express@4.22.2):
|
||||
dependencies:
|
||||
express: 4.22.2
|
||||
ip-address: 10.2.0
|
||||
|
||||
express@4.22.2:
|
||||
dependencies:
|
||||
accepts: 1.3.8
|
||||
|
|
@ -5018,9 +4970,6 @@ snapshots:
|
|||
|
||||
fs.realpath@1.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
|
|
@ -5304,8 +5253,6 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ip-address@10.2.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
|
|
@ -5551,7 +5498,7 @@ snapshots:
|
|||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
next@15.5.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@next/env': 15.5.18
|
||||
'@swc/helpers': 0.5.15
|
||||
|
|
@ -5569,7 +5516,6 @@ snapshots:
|
|||
'@next/swc-linux-x64-musl': 15.5.18
|
||||
'@next/swc-win32-arm64-msvc': 15.5.18
|
||||
'@next/swc-win32-x64-msvc': 15.5.18
|
||||
'@playwright/test': 1.60.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
|
@ -5682,14 +5628,6 @@ snapshots:
|
|||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
playwright-core@1.60.0: {}
|
||||
|
||||
playwright@1.60.0:
|
||||
dependencies:
|
||||
playwright-core: 1.60.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.15):
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue