diff --git a/README.md b/README.md index db50a84..4735205 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # 1 bis 8 Party – Mobile Web-App Das ultimative Gesellschaftsspiel über Selbsteinschätzung, Vorurteile und echte -Offenbarungen – als mobile Web-App umgesetzt: offizielle Spielregeln, 20 +Offenbarungen – als mobile Multiplayer-Web-App: offizielle Spielregeln, 20 Statement-Karten in 5 Kategorien und ein interaktiver Spielleiter für 3–8 -Spieler mit automatischer Mehrheits-Punkteberechnung. +Spieler. **Jeder spielt und stimmt auf seinem eigenen Handy ab:** Ein Host +eröffnet einen Spielraum und gibt den 4-stelligen Code weiter; sobald alle +Spieler „Ready“ melden, startet das Spiel automatisch. **Live:** https://1bis.hocks.eu/ (Alias: https://1bis8.hocks.eu/) @@ -13,8 +15,9 @@ Spieler mit automatischer Mehrheits-Punkteberechnung. |---|---| | `app/` | React + TypeScript + Vite + Tailwind (Quelle der Web-App) | | `app/src/data/game.ts` | Kategorien, 20 Statement-Karten, Regelwerk-Texte | -| `app/src/lib/scoring.ts` | Punkteberechnung nach offiziellen Regeln | -| `server/` | Docker-Stack für den VPS (Web + Postfix-Relay + Mail-API) | +| `app/src/lib/api.ts` | Client für die Game-API (Räume, Ready, Rankings) | +| `server/game/api/server.js` | Game-API: Spielräume, Auto-Start, Punkteberechnung | +| `server/web/`, `server/mail/` | nginx-Web-Stack und Postfix-Relay + Mail-API | | `docs/` | Original-Spielregeln (PDF) | ## Entwicklung diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts new file mode 100644 index 0000000..6100b50 --- /dev/null +++ b/app/src/lib/api.ts @@ -0,0 +1,114 @@ +// ── Client für die Game-API (Multiplayer-Spielräume) ────── + +export type RaumStatus = 'lobby' | 'question' | 'ranking' | 'reveal' | 'scoreboard' | 'finished' + +export interface SpielerInfo { + name: string + ready: boolean + istHost: boolean + rankingAbgegeben: boolean +} + +export interface PlatzDetail { + platz: number + person: string + stimmen: number + punkte: number +} + +export interface Ergebnis { + mehrheit: string[] + punkte: Record + platzDetails: PlatzDetail[] +} + +export interface RaumZustand { + code: string + status: RaumStatus + runde: number + anzahlRunden: number + frage: string + fragesteller: string + spieler: SpielerInfo[] + minSpieler: number + maxSpieler: number + alleReady: boolean + rankingsFertig: number + punkte: Record + ergebnis: Ergebnis | null + ich: string +} + +export interface RaumSession { + code: string + token: string + name: string +} + +const SESSION_KEY = 'einsbisacht-raum-v1' + +export function ladeSession(): RaumSession | null { + try { + const roh = localStorage.getItem(SESSION_KEY) + return roh ? (JSON.parse(roh) as RaumSession) : null + } catch { + return null + } +} + +export function speichereSession(s: RaumSession | null) { + if (s) localStorage.setItem(SESSION_KEY, JSON.stringify(s)) + else localStorage.removeItem(SESSION_KEY) +} + +class ApiFehler extends Error {} + +async function anfrage(methode: 'GET' | 'POST', pfad: string, body?: object, token?: string): Promise { + const res = await fetch(`/api/game/room${pfad}`, { + method: methode, + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'X-Player-Token': token } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + const daten = await res.json().catch(() => ({})) + if (!res.ok) throw new ApiFehler(daten.fehler || `Fehler ${res.status}`) + if (daten.zustand) return daten.zustand as RaumZustand + throw new ApiFehler('Unerwartete Antwort der Spiel-API') +} + +/** Raum eröffnen; liefert Session direkt mit */ +export async function raumErstellen(name: string): Promise<{ session: RaumSession; zustand: RaumZustand }> { + const res = await fetch('/api/game/room', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + const daten = await res.json().catch(() => ({})) + if (!res.ok || !daten.token) throw new ApiFehler(daten.fehler || 'Raum konnte nicht erstellt werden') + const session: RaumSession = { code: daten.code, token: daten.token, name } + speichereSession(session) + return { session, zustand: daten.zustand } +} + +/** Raum beitreten; liefert Session direkt mit */ +export async function raumBeitreten(code: string, name: string): Promise<{ session: RaumSession; zustand: RaumZustand }> { + const res = await fetch(`/api/game/room/${code.toUpperCase()}/join`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) + const daten = await res.json().catch(() => ({})) + if (!res.ok || !daten.token) throw new ApiFehler(daten.fehler || 'Beitritt fehlgeschlagen') + const session: RaumSession = { code: code.toUpperCase(), token: daten.token, name } + speichereSession(session) + return { session, zustand: daten.zustand } +} + +export const zustandLaden = (s: RaumSession) => anfrage('GET', `/${s.code}`, undefined, s.token) +export const readySetzen = (s: RaumSession, ready: boolean) => anfrage('POST', `/${s.code}/ready`, { ready }, s.token) +export const frageStellen = (s: RaumSession, frage: string) => anfrage('POST', `/${s.code}/question`, { frage }, s.token) +export const rankingAbgeben = (s: RaumSession, reihenfolge: string[]) => + anfrage('POST', `/${s.code}/ranking`, { reihenfolge }, s.token) +export const naechstePhase = (s: RaumSession) => anfrage('POST', `/${s.code}/next`, {}, s.token) diff --git a/app/src/pages/Game.tsx b/app/src/pages/Game.tsx index 4d518d2..06ca687 100644 --- a/app/src/pages/Game.tsx +++ b/app/src/pages/Game.tsx @@ -1,422 +1,495 @@ -import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react' -import { ArrowRight, Eraser, Plus, RotateCcw, Trophy, Users, Check, EyeOff } from 'lucide-react' -import { KARTEN, MIN_SPIELER, MAX_SPIELER } from '@/data/game' -import { berechneRunde, type RundenErgebnis } from '@/lib/scoring' - -type Phase = 'setup' | 'frage' | 'ranking' | 'aufloesung' | 'punktestand' | 'ende' - -interface SpielState { - phase: Phase - spieler: string[] - runde: number - frage: string - rankings: Record - punkte: Record - ergebnis: RundenErgebnis | null -} - -const LEER: SpielState = { - phase: 'setup', - spieler: [], - runde: 0, - frage: '', - rankings: {}, - punkte: {}, - ergebnis: null, -} - -const STORAGE_KEY = 'einsbisacht-spielstand-v1' - -function ladeStand(): SpielState { - try { - const roh = localStorage.getItem(STORAGE_KEY) - return roh ? { ...LEER, ...JSON.parse(roh) } : LEER - } catch { - return LEER - } -} +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { + Check, + Copy, + Crown, + DoorOpen, + Eraser, + EyeOff, + Gamepad2, + Loader2, + LogOut, + PartyPopper, + Share2, + Trophy, + Users, +} from 'lucide-react' +import { KARTEN } from '@/data/game' +import { + ladeSession, + speichereSession, + raumErstellen, + raumBeitreten, + zustandLaden, + readySetzen, + frageStellen, + rankingAbgeben, + naechstePhase, + type RaumSession, + type RaumZustand, +} from '@/lib/api' +/* ── Seite ──────────────────────────────────────────────── */ export default function Game() { - const [state, setState] = useState(ladeStand) + const [session, setSession] = useState(ladeSession) + const [zustand, setZustand] = useState(null) + const [fehler, setFehler] = useState('') + const refresh = useCallback(async () => { + if (!session) return + try { + setZustand(await zustandLaden(session)) + setFehler('') + } catch (e) { + // Raum existiert nicht mehr (abgelaufen) -> Session verwerfen + if (e instanceof Error && /nicht gefunden/.test(e.message)) { + speichereSession(null) + setSession(null) + } else { + setFehler(e instanceof Error ? e.message : 'Verbindungsfehler') + } + } + }, [session]) + + // Polling: alle 1,5 s synchronisieren (nur wenn Tab sichtbar) useEffect(() => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) - }, [state]) + if (!session) return + refresh() + const timer = setInterval(() => { + if (!document.hidden) refresh() + }, 1500) + return () => clearInterval(timer) + }, [session, refresh]) - const fragesteller = state.spieler[state.runde % Math.max(1, state.spieler.length)] - const anzahlRunden = state.spieler.length + const beitreten = useCallback(async (code: string, name: string) => { + const { session: s } = await raumBeitreten(code, name) + setSession(s) + }, []) - const neuesSpiel = () => { - localStorage.removeItem(STORAGE_KEY) - setState(LEER) - } + const erstellen = useCallback(async (name: string) => { + const { session: s } = await raumErstellen(name) + setSession(s) + }, []) + + const verlassen = useCallback(() => { + speichereSession(null) + setSession(null) + setZustand(null) + }, []) + + const act = useCallback( + async (fn: (s: RaumSession) => Promise) => { + if (!session) return + try { + setZustand(await fn(session)) + setFehler('') + } catch (e) { + setFehler(e instanceof Error ? e.message : 'Aktion fehlgeschlagen') + } + }, + [session] + ) return (

- Interaktiver Spielleiter + Jeder auf seinem Handy

Das Spiel

- {state.phase !== 'setup' && ( + {session && ( )}
- {/* Runden-Fortschritt */} - {state.phase !== 'setup' && state.phase !== 'ende' && ( -
- {state.spieler.map((_, i) => ( -
- ))} - - Runde {Math.min(state.runde + 1, anzahlRunden)}/{anzahlRunden} - + {fehler && ( +
+ {fehler}
)}
- {state.phase === 'setup' && } - {state.phase === 'frage' && ( - + {!session && } + {session && !zustand && ( +
+ Verbinde mit Spielraum … +
)} - {state.phase === 'ranking' && } - {state.phase === 'aufloesung' && } - {state.phase === 'punktestand' && ( - + {session && zustand && ( + <> + {zustand.status === 'lobby' && } + {zustand.status === 'question' && } + {zustand.status === 'ranking' && } + {zustand.status === 'reveal' && } + {zustand.status === 'scoreboard' && } + {zustand.status === 'finished' && } + )} - {state.phase === 'ende' && }
) } -/* ── Phase 1: Setup ─────────────────────────────────────── */ -function SetupPhase({ - state, - setState, +/* ── Einstieg: Raum erstellen oder beitreten ────────────── */ +function Einstieg({ + onErstellen, + onBeitreten, }: { - state: SpielState - setState: Dispatch> + onErstellen: (name: string) => Promise + onBeitreten: (code: string, name: string) => Promise }) { - const [namen, setNamen] = useState(state.spieler.length >= MIN_SPIELER ? state.spieler : ['', '']) + const [modus, setModus] = useState<'start' | 'beitreten'>('start') + const [name, setName] = useState('') + const [code, setCode] = useState(() => new URLSearchParams(window.location.search).get('raum') ?? '') + const [lade, setLade] = useState(false) const [fehler, setFehler] = useState('') - const gueltigeNamen = namen.map((n) => n.trim()).filter(Boolean) - const anzahlOk = gueltigeNamen.length >= MIN_SPIELER && gueltigeNamen.length <= MAX_SPIELER - const doppelt = new Set(gueltigeNamen).size !== gueltigeNamen.length - - const starten = (startIndex: number) => { - if (!anzahlOk || doppelt) { - setFehler( - !anzahlOk - ? `Bitte ${MIN_SPIELER} bis ${MAX_SPIELER} Spielernamen eintragen.` - : 'Jeder Name darf nur einmal vorkommen.' - ) - return + const los = async () => { + if (!name.trim()) return setFehler('Bitte einen Namen eintragen.') + if (modus === 'beitreten' && code.trim().length !== 4) return setFehler('Der Raumcode hat 4 Zeichen.') + setLade(true) + setFehler('') + try { + if (modus === 'start') await onErstellen(name.trim()) + else await onBeitreten(code.trim(), name.trim()) + } catch (e) { + setFehler(e instanceof Error ? e.message : 'Verbindung fehlgeschlagen') + } finally { + setLade(false) } - // Liste rotieren, damit der Startspieler (z. B. der Jüngste) anfängt - const spieler = [...gueltigeNamen.slice(startIndex), ...gueltigeNamen.slice(0, startIndex)] - setState({ - ...LEER, - phase: 'frage', - spieler, - punkte: Object.fromEntries(spieler.map((s) => [s, 0])), - }) } return (
-
-

- Wer spielt mit? -

-

- {MIN_SPIELER}–{MAX_SPIELER} Spieler. Der jüngste Spieler beginnt laut Regelwerk – - oder würfelt euch einen Startspieler aus. 🎲 -

-
- {namen.map((name, i) => ( -
- - {i + 1} - - setNamen(namen.map((n, j) => (j === i ? e.target.value : n)))} - placeholder={`Spieler ${i + 1}`} - maxLength={16} - className="w-full rounded-xl border border-white/10 bg-white/[0.06] px-3.5 py-2.5 text-[15px] font-bold text-white placeholder:font-normal placeholder:text-white/30 focus:border-pink-400/60 focus:outline-none focus:ring-2 focus:ring-pink-400/20" - /> -
- ))} -
- {namen.length < MAX_SPIELER && ( - - )} - {fehler &&

{fehler}

} +
+ setModus('start')} + emoji="🏠" + titel="Spielraum eröffnen" + text="Du leitest die Runde und gibst den Code weiter" + /> + setModus('beitreten')} + emoji="🚪" + titel="Spielraum beitreten" + text="Du hast einen Code vom Spielleiter" + />
-
+
+ {modus === 'beitreten' && ( + + setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4))} + placeholder="ABCD" + maxLength={4} + className="w-full rounded-xl border border-white/10 bg-white/[0.06] px-3.5 py-3 text-center font-display text-2xl font-bold uppercase tracking-[0.4em] text-white placeholder:tracking-normal placeholder:text-white/25 focus:border-pink-400/60 focus:outline-none focus:ring-2 focus:ring-pink-400/20" + /> + + )} + + setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && los()} + placeholder="Wie sollen die anderen dich sehen?" + maxLength={16} + className="w-full rounded-xl border border-white/10 bg-white/[0.06] px-3.5 py-3 text-[15px] font-bold text-white placeholder:font-normal placeholder:text-white/30 focus:border-pink-400/60 focus:outline-none focus:ring-2 focus:ring-pink-400/20" + /> + + {fehler &&

{fehler}

} -
) } -/* ── Phase 2: Frage stellen ─────────────────────────────── */ -function FragePhase({ - fragesteller, - state, - setState, +/* ── Lobby: Code teilen, Ready melden ───────────────────── */ +function Lobby({ + z, + onAct, }: { - fragesteller: string - state: SpielState - setState: Dispatch> + z: RaumZustand + onAct: (fn: (s: RaumSession) => Promise) => Promise }) { - const [frage, setFrage] = useState(state.frage) - const [eigene, setEigene] = useState(false) + const ich = z.spieler.find((s) => s.name === z.ich) + const beitrittsUrl = `${window.location.origin}/?raum=${z.code}` + const [kopiert, setKopiert] = useState(false) + + const teilen = async () => { + if (navigator.share) { + try { + await navigator.share({ title: '1 bis 8 Party', text: `Tritt meinem Spielraum ${z.code} bei!`, url: beitrittsUrl }) + return + } catch { + /* User abgebrochen -> Copy Fallback */ + } + } + kopieren() + } + + const kopieren = async () => { + try { + await navigator.clipboard.writeText(beitrittsUrl) + } catch { + await navigator.clipboard.writeText(z.code) + } + setKopiert(true) + setTimeout(() => setKopiert(false), 2000) + } + + return ( +
+
+

Spielraum-Code

+

{z.code}

+

+ Alle öffnen 1bis.hocks.eu auf dem Handy und + treten mit dem Code bei. +

+
+ + +
+
+ +
+
+

+ Spieler + + ({z.spieler.length}/{z.maxSpieler}) + +

+ Mind. {z.minSpieler} +
+
+ {z.spieler.map((s) => ( +
+ + {s.istHost && } + {s.name} + {s.name === z.ich && (du)} + + + {s.ready ? '✓ Ready' : 'Wartet …'} + +
+ ))} +
+
+ + +

+ Sobald alle Spieler ready sind, startet das Spiel automatisch. 🎲 +

+
+ ) +} + +/* ── Fragephase ─────────────────────────────────────────── */ +function FragePhase({ z, onAct }: { z: RaumZustand; onAct: (fn: (s: RaumSession) => Promise) => Promise }) { + const [frage, setFrage] = useState('') + const istFragesteller = z.ich === z.fragesteller const ziehen = () => { const karte = KARTEN[Math.floor(Math.random() * KARTEN.length)] setFrage(karte.text) - setEigene(false) } - const weiter = () => { - if (!frage.trim()) return - setState((s) => ({ ...s, phase: 'ranking', frage: frage.trim(), rankings: {}, ergebnis: null })) + if (!istFragesteller) { + return ( +
+

🎤

+

{z.fragesteller}

+

ist als Fragesteller an der Reihe und wählt gerade eine Karte …

+

Auf deinem Handy geht es gleich weiter.

+
+ ) } return (
+
-

Fragesteller

-

{fragesteller}

-

zieht eine Karte und liest laut vor 🎤

+

Du bist dran!

+

Ziehe eine Karte und lies sie laut vor – oder erfinde eigene Frage.

- -
-
- - -
- - {(eigene || frage) && ( -