feat: Implementiere Crash-Spiel-Logik mit WebSocket-Unterstützung und füge Crash-Seite hinzu

This commit is contained in:
2025-11-23 13:01:58 +01:00
parent 0ad33488dc
commit 2beb5efdb4
5 changed files with 251 additions and 23 deletions

View File

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

View File

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

View 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();

View File

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

View 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>
);
};