mirror of
https://github.com/MrUnknownDE/bierkasten-casino.git
synced 2026-04-19 06:43:46 +02:00
feat: Implementiere Crash-Spiel-Logik mit WebSocket-Unterstützung und füge Crash-Seite hinzu
This commit is contained in:
@@ -15,7 +15,8 @@
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.19.0",
|
||||
"express-session": "^1.17.3",
|
||||
"pg": "^8.12.0"
|
||||
"pg": "^8.12.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@types/express-session": "^1.17.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/ws": "^8.5.11",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { handleConnection } from "./services/crashGame";
|
||||
import session from "express-session";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
@@ -16,10 +19,10 @@ import {
|
||||
import { adminRouter } from "./routes/admin";
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
wss.on('connection', handleConnection);
|
||||
|
||||
// --- ÄNDERUNG 1: Robustere Proxy-Erkennung ---
|
||||
// Vertraue dem X-Forwarded-For Header, der von den Proxys gesetzt wird.
|
||||
// Das ist zuverlässiger als die Anzahl der Proxys zu raten.
|
||||
app.set("trust proxy", true);
|
||||
|
||||
|
||||
|
||||
105
backend/src/services/crashGame.ts
Normal file
105
backend/src/services/crashGame.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { pool } from "../db";
|
||||
|
||||
// Typen für den Spielzustand
|
||||
type GamePhase = "waiting" | "betting" | "running" | "crashed";
|
||||
|
||||
interface Player {
|
||||
ws: WebSocket;
|
||||
userId: number;
|
||||
bet: number;
|
||||
cashedOutAt?: number;
|
||||
}
|
||||
|
||||
// Spielzustand
|
||||
let phase: GamePhase = "waiting";
|
||||
let players: Map<WebSocket, Player> = new Map();
|
||||
let multiplier = 1.0;
|
||||
let crashPoint = 0;
|
||||
let roundStartTime = 0;
|
||||
|
||||
// --- WebSocket-Verwaltung ---
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
export function handleConnection(ws: WebSocket) {
|
||||
clients.add(ws);
|
||||
console.log("[Crash] New client connected.");
|
||||
|
||||
// Sende den aktuellen Zustand an den neuen Client
|
||||
ws.send(JSON.stringify({
|
||||
type: "gameState",
|
||||
phase,
|
||||
multiplier,
|
||||
players: Array.from(players.values()).map(p => ({ userId: p.userId, bet: p.bet, cashedOutAt: p.cashedOutAt }))
|
||||
}));
|
||||
|
||||
ws.on("message", (message) => {
|
||||
// Hier werden wir später die "bet" und "cashout" Nachrichten verarbeiten
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
clients.delete(ws);
|
||||
players.delete(ws); // Spieler bei Disconnect entfernen
|
||||
console.log("[Crash] Client disconnected.");
|
||||
});
|
||||
}
|
||||
|
||||
function broadcast(message: object) {
|
||||
const data = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Spiellogik ---
|
||||
|
||||
function calculateCrashPoint(): number {
|
||||
// Dies ist die "geheime Zutat". Ein guter Crash-Algorithmus ist entscheidend.
|
||||
// Wir verwenden hier eine einfache Formel für den Anfang.
|
||||
const r = Math.random();
|
||||
// Die Formel sorgt dafür, dass niedrige Multiplikatoren viel häufiger sind.
|
||||
const crash = 1 / (1 - r);
|
||||
return Math.max(1.01, parseFloat(crash.toFixed(2)));
|
||||
}
|
||||
|
||||
async function runGameLoop() {
|
||||
while (true) {
|
||||
// 1. Betting Phase (10 Sekunden)
|
||||
phase = "betting";
|
||||
crashPoint = calculateCrashPoint();
|
||||
console.log(`[Crash] New round starting. Crash point will be: ${crashPoint}x`);
|
||||
broadcast({ type: "newRound", phase: "betting", duration: 10000 });
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
|
||||
// 2. Running Phase
|
||||
phase = "running";
|
||||
roundStartTime = Date.now();
|
||||
broadcast({ type: "roundStart", phase: "running" });
|
||||
|
||||
while (multiplier < crashPoint) {
|
||||
const elapsed = (Date.now() - roundStartTime) / 1000;
|
||||
// Der Multiplikator steigt exponentiell an, um es spannender zu machen
|
||||
multiplier = parseFloat(Math.pow(1.05, elapsed).toFixed(2));
|
||||
|
||||
broadcast({ type: "multiplierUpdate", multiplier });
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // Update alle 100ms
|
||||
}
|
||||
|
||||
// 3. Crashed Phase
|
||||
phase = "crashed";
|
||||
multiplier = crashPoint;
|
||||
broadcast({ type: "crash", multiplier: crashPoint });
|
||||
|
||||
// TODO: Gewinne an die Spieler auszahlen, die gecashed haben.
|
||||
// TODO: Einsätze von Spielern abziehen, die nicht gecashed haben.
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000)); // 5s Pause
|
||||
|
||||
// Reset für die nächste Runde
|
||||
players.clear();
|
||||
multiplier = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Starte den Spiel-Loop
|
||||
runGameLoop();
|
||||
@@ -4,6 +4,7 @@ import { Routes, Route, Link, Navigate, useLocation } from "react-router-dom";
|
||||
import { getMe, getLoginUrl, logout, MeResponse, getAdminMe, AdminMeResponse } from "./api";
|
||||
import { GamePage } from "./pages/GamePage";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { CrashPage } from "./pages/CrashPage";
|
||||
|
||||
const AdminRoute: React.FC<{ adminInfo: AdminMeResponse | null; children: React.ReactNode }> = ({ adminInfo, children }) => {
|
||||
if (!adminInfo || !adminInfo.is_admin) {
|
||||
@@ -51,7 +52,7 @@ const App: React.FC = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ color: 'white', textAlign: 'center', fontSize: '1.5rem' }}>
|
||||
<div style={{ color: 'white', textAlign: 'center', fontSize: '1.5rem', fontFamily: 'system-ui, sans-serif' }}>
|
||||
Lade Bierbaron Casino...
|
||||
</div>
|
||||
);
|
||||
@@ -72,7 +73,7 @@ const App: React.FC = () => {
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: location.pathname.startsWith('/admin') ? "1200px" : "960px", // Admin-Seite breiter
|
||||
maxWidth: location.pathname.startsWith('/admin') ? "1200px" : "960px",
|
||||
background: "rgba(10,10,18,0.95)",
|
||||
borderRadius: "18px",
|
||||
padding: "24px 28px 30px",
|
||||
@@ -91,15 +92,17 @@ const App: React.FC = () => {
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||
<h1 style={{ margin: 0, fontSize: "1.8rem", textAlign: "left" }}>
|
||||
<Link to="/" style={{ color: "inherit", textDecoration: "none" }}>🍺 Bierbaron Casino</Link>
|
||||
</h1>
|
||||
{adminInfo?.is_admin && (
|
||||
<Link to="/admin" style={{ color: "#ffb347", textDecoration: "none", fontSize: "0.9rem", marginLeft: '4px' }}>
|
||||
🛠 Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
<nav style={{ display: 'flex', gap: '16px', alignItems: 'center', borderLeft: '1px solid #333', paddingLeft: '24px' }}>
|
||||
<Link to="/" style={{ color: "#ccc", textDecoration: "none", fontSize: "1rem" }}>🎰 Slots</Link>
|
||||
<Link to="/crash" style={{ color: "#ccc", textDecoration: "none", fontSize: "1rem" }}>🚀 Bier-Crash</Link>
|
||||
{adminInfo?.is_admin && (
|
||||
<Link to="/admin" style={{ color: "#ffb347", textDecoration: "none", fontSize: "1rem" }}>🛠 Admin</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -146,17 +149,20 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Routes>
|
||||
<Route path="/*" element={<GamePage me={me} />} />
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<AdminRoute adminInfo={adminInfo}>
|
||||
<AdminPage adminInfo={adminInfo} />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<GamePage me={me} />} />
|
||||
<Route path="/crash" element={<CrashPage me={me} />} />
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<AdminRoute adminInfo={adminInfo}>
|
||||
<AdminPage adminInfo={adminInfo} />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
112
frontend/src/pages/CrashPage.tsx
Normal file
112
frontend/src/pages/CrashPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// frontend/src/pages/CrashPage.tsx
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { MeResponse } from '../api';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface CrashPageProps {
|
||||
me: MeResponse | null;
|
||||
}
|
||||
|
||||
export const CrashPage: React.FC<CrashPageProps> = ({ me }) => {
|
||||
const [phase, setPhase] = useState('connecting');
|
||||
const [multiplier, setMultiplier] = useState(1.00);
|
||||
const [history, setHistory] = useState<{ time: number, value: number }[]>([]);
|
||||
const ws = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Die WebSocket-URL muss auf deine Domain zeigen, aber mit wss:// (für https)
|
||||
const wsUrl = `wss://${window.location.host}`;
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
console.log("WebSocket connected");
|
||||
setPhase('connected');
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'gameState':
|
||||
setPhase(data.phase);
|
||||
setMultiplier(data.multiplier);
|
||||
break;
|
||||
case 'newRound':
|
||||
setPhase(data.phase);
|
||||
setHistory([]); // Graphen zurücksetzen
|
||||
setMultiplier(1.00);
|
||||
break;
|
||||
case 'roundStart':
|
||||
setPhase(data.phase);
|
||||
break;
|
||||
case 'multiplierUpdate':
|
||||
setMultiplier(data.multiplier);
|
||||
setHistory(prev => [...prev, { time: prev.length, value: data.multiplier }]);
|
||||
break;
|
||||
case 'crash':
|
||||
setPhase('crashed');
|
||||
setMultiplier(data.multiplier);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onclose = () => {
|
||||
console.log("WebSocket disconnected");
|
||||
setPhase('disconnected');
|
||||
};
|
||||
|
||||
return () => {
|
||||
ws.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (phase) {
|
||||
case 'connecting': return 'Verbinde mit dem Server...';
|
||||
case 'connected':
|
||||
case 'waiting': return 'Warte auf die nächste Runde...';
|
||||
case 'betting': return 'Einsätze platzieren! Runde startet bald...';
|
||||
case 'running': return 'Runde läuft!';
|
||||
case 'crashed': return `CRASHED @ ${multiplier.toFixed(2)}x`;
|
||||
case 'disconnected': return 'Verbindung verloren. Bitte Seite neu laden.';
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>🚀 Bier-Crash</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 3fr', gap: '24px' }}>
|
||||
{/* Linke Spalte: Steuerung */}
|
||||
<div>
|
||||
<h4>Dein Einsatz</h4>
|
||||
<input type="number" placeholder="100" style={{ width: '100%', padding: '8px', background: '#0b0b10', border: '1px solid #555', color: 'white', borderRadius: '4px' }} />
|
||||
<button style={{ width: '100%', padding: '12px', marginTop: '16px', background: 'limegreen', border: 'none', borderRadius: '4px', color: 'white', fontWeight: 'bold' }}>
|
||||
Einsatz platzieren
|
||||
</button>
|
||||
<button style={{ width: '100%', padding: '12px', marginTop: '8px', background: 'dodgerblue', border: 'none', borderRadius: '4px', color: 'white', fontWeight: 'bold' }}>
|
||||
CASHOUT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rechte Spalte: Graph und Multiplikator */}
|
||||
<div style={{ background: '#0b0b10', padding: '16px', borderRadius: '8px', position: 'relative', height: '400px' }}>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', zIndex: 1 }}>
|
||||
<h1 style={{ fontSize: '4rem', margin: 0, color: phase === 'crashed' ? 'salmon' : 'white' }}>
|
||||
{multiplier.toFixed(2)}x
|
||||
</h1>
|
||||
<p style={{ margin: 0, fontSize: '1.2rem' }}>{getStatusMessage()}</p>
|
||||
</div>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={history}>
|
||||
<XAxis type="number" dataKey="time" hide />
|
||||
<YAxis type="number" domain={['auto', 'auto']} hide />
|
||||
<Tooltip content={() => null} />
|
||||
<Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={4} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user