Red Mode: 10 FSK-18-Karten (Rotlicht), Host-Schalter im Raum, rotes Design
Build & Deploy / deploy (push) Successful in 19s
Build & Deploy / deploy (push) Successful in 19s
This commit is contained in:
+25
-1
@@ -1,6 +1,6 @@
|
||||
// ── Kategorien & Statement-Karten (aus dem offiziellen Spielregeln-PDF) ──
|
||||
|
||||
export type KategorieId = 'abenteuer' | 'persoenlichkeit' | 'alltag' | 'party' | 'erfolg'
|
||||
export type KategorieId = 'abenteuer' | 'persoenlichkeit' | 'alltag' | 'party' | 'erfolg' | 'rotlicht'
|
||||
|
||||
export interface Kategorie {
|
||||
id: KategorieId
|
||||
@@ -59,6 +59,15 @@ export const KATEGORIEN: Kategorie[] = [
|
||||
ring: 'ring-emerald-400/40',
|
||||
chip: 'bg-emerald-400/15 text-emerald-300 border-emerald-400/30',
|
||||
},
|
||||
{
|
||||
id: 'rotlicht',
|
||||
titel: 'Rotlicht & Versuchung 🔞',
|
||||
emoji: '🌶️',
|
||||
farbe: 'from-red-500 to-rose-700',
|
||||
textfarbe: 'text-red-300',
|
||||
ring: 'ring-red-400/40',
|
||||
chip: 'bg-red-400/15 text-red-300 border-red-400/30',
|
||||
},
|
||||
]
|
||||
|
||||
export const kategorieById = (id: KategorieId): Kategorie =>
|
||||
@@ -96,8 +105,23 @@ export const KARTEN: Spielkarte[] = [
|
||||
{ id: 18, kategorie: 'erfolg', text: 'Wer wäre der beste Kandidat für eine Reality-TV-Show?' },
|
||||
{ id: 19, kategorie: 'erfolg', text: 'Wer könnte am ehesten eine 30-minütige Präsentation über ein Thema halten, von dem er eigentlich gar keine Ahnung hat?' },
|
||||
{ id: 20, kategorie: 'erfolg', text: 'Wer hat das Potenzial, ein berühmter Geheimagent zu sein, weil er am besten Geheimnisse bewahren kann?' },
|
||||
// Rotlicht & Versuchung (Red Mode / FSK 18)
|
||||
{ id: 21, kategorie: 'rotlicht', text: 'Wer aus der Gruppe würde am ehesten einen One-Night-Stand über eine Dating-App haben?' },
|
||||
{ id: 22, kategorie: 'rotlicht', text: 'Wer würde bei „Wahrheit oder Pflicht“ drei Mal hintereinander „Pflicht“ wählen – egal, was kommt?' },
|
||||
{ id: 23, kategorie: 'rotlicht', text: 'Wer würde am ehesten heimlich in der hintersten Reihe des Kinos rummachen?' },
|
||||
{ id: 24, kategorie: 'rotlicht', text: 'Wer würde nach einer durchzechten Nacht als Erster den Namen seines Dates vergessen?' },
|
||||
{ id: 25, kategorie: 'rotlicht', text: 'Wer würde am ehesten einen Strip-Poker-Abend anzetteln – und zwar gewinnen?' },
|
||||
{ id: 26, kategorie: 'rotlicht', text: 'Wer würde am ehesten mit dem Ex eines guten Freunds anbändeln und es vertuschen?' },
|
||||
{ id: 27, kategorie: 'rotlicht', text: 'Wer hat die Stimme, mit der man am Telefon am ehesten flirten könnte?' },
|
||||
{ id: 28, kategorie: 'rotlicht', text: 'Wer würde in einem Club am ehesten auf der Theke tanzen – und es auch noch gut finden?' },
|
||||
{ id: 29, kategorie: 'rotlicht', text: 'Wer würde am ehesten den ganzen Abend mit einem Fremden flirten, nur um einen Drink abzugreifen?' },
|
||||
{ id: 30, kategorie: 'rotlicht', text: 'Wer sagt „Ich melde mich noch“ – und meint damit die nächsten drei Jahre?' },
|
||||
]
|
||||
|
||||
/** FSK-18-Karten – nur im Red Mode verfügbar */
|
||||
export const ROT_KARTEN: Spielkarte[] = KARTEN.filter((k) => k.kategorie === 'rotlicht')
|
||||
export const KARTEN_OHNE_ROT: Spielkarte[] = KARTEN.filter((k) => k.kategorie !== 'rotlicht')
|
||||
|
||||
// ── Spielregeln (Inhalt aus dem offiziellen PDF) ──────────
|
||||
|
||||
export interface RegelSchritt {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** Hängt die Design-Klasse red-mode an <body> und passt theme-color an. */
|
||||
export function useRedMode(aktiv: boolean) {
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('red-mode', aktiv)
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
const vorher = meta?.getAttribute('content')
|
||||
if (meta) meta.setAttribute('content', aktiv ? '#1f060b' : '#170a2e')
|
||||
return () => {
|
||||
document.body.classList.remove('red-mode')
|
||||
if (meta && vorher) meta.setAttribute('content', vorher)
|
||||
}
|
||||
}, [aktiv])
|
||||
}
|
||||
@@ -136,6 +136,28 @@
|
||||
animation: glanzen 3.5s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Red Mode (FSK 18): rote Design-Welt ────────────────── */
|
||||
body.red-mode {
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(239, 68, 68, 0.32), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(244, 63, 94, 0.22), transparent),
|
||||
radial-gradient(ellipse 60% 40% at 0% 100%, rgba(190, 18, 60, 0.2), transparent),
|
||||
linear-gradient(180deg, #1f060b 0%, #2a0710 55%, #1a0409 100%);
|
||||
}
|
||||
body.red-mode .text-gradient {
|
||||
background-image: linear-gradient(to right, #f87171, #fb7185, #f472b6);
|
||||
}
|
||||
body.red-mode nav.fixed {
|
||||
background-color: rgb(38 8 14 / 0.88);
|
||||
border-color: rgba(248, 113, 113, 0.18);
|
||||
}
|
||||
body.red-mode .glanz {
|
||||
background-image: linear-gradient(90deg, #ef4444, #e11d48, #be123c, #e11d48, #ef4444) !important;
|
||||
}
|
||||
body.red-mode .glass-card {
|
||||
border-color: rgba(248, 113, 113, 0.14);
|
||||
}
|
||||
|
||||
/* Zahlen-Chips der Hero-Sektion */
|
||||
.zahlen-chip {
|
||||
@apply flex h-9 w-9 items-center justify-center rounded-xl font-display text-lg font-semibold shadow-lg;
|
||||
|
||||
+4
-2
@@ -32,6 +32,7 @@ export interface RaumZustand {
|
||||
spieler: SpielerInfo[]
|
||||
minSpieler: number
|
||||
maxSpieler: number
|
||||
redMode: boolean
|
||||
alleReady: boolean
|
||||
rankingsFertig: number
|
||||
punkte: Record<string, number>
|
||||
@@ -79,11 +80,11 @@ async function anfrage(methode: 'GET' | 'POST', pfad: string, body?: object, tok
|
||||
}
|
||||
|
||||
/** Raum eröffnen; liefert Session direkt mit */
|
||||
export async function raumErstellen(name: string): Promise<{ session: RaumSession; zustand: RaumZustand }> {
|
||||
export async function raumErstellen(name: string, redMode = false): Promise<{ session: RaumSession; zustand: RaumZustand }> {
|
||||
const res = await fetch('/api/game/room', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({ name, redMode }),
|
||||
})
|
||||
const daten = await res.json().catch(() => ({}))
|
||||
if (!res.ok || !daten.token) throw new ApiFehler(daten.fehler || 'Raum konnte nicht erstellt werden')
|
||||
@@ -108,6 +109,7 @@ export async function raumBeitreten(code: string, name: string): Promise<{ sessi
|
||||
|
||||
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 redModeSetzen = (s: RaumSession, redMode: boolean) => anfrage('POST', `/${s.code}/redmode`, { redMode }, 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)
|
||||
|
||||
+57
-7
@@ -1,6 +1,9 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { RefreshCw, Shuffle } from 'lucide-react'
|
||||
import { KARTEN, KATEGORIEN, kategorieById, type KategorieId, type Spielkarte } from '@/data/game'
|
||||
import { Flame, RefreshCw, Shuffle } from 'lucide-react'
|
||||
import { KARTEN, KATEGORIEN, KARTEN_OHNE_ROT, kategorieById, type KategorieId, type Spielkarte } from '@/data/game'
|
||||
import { useRedMode } from '@/hooks/useRedMode'
|
||||
|
||||
const REDMODE_KEY = 'einsbisacht-redmode'
|
||||
|
||||
type Filter = 'alle' | KategorieId
|
||||
|
||||
@@ -47,10 +50,24 @@ export default function Cards() {
|
||||
const [filter, setFilter] = useState<Filter>('alle')
|
||||
const [gezogen, setGezogen] = useState<Spielkarte | null>(null)
|
||||
const [zaehler, setZaehler] = useState(0) // triggert Re-Flip-Animation
|
||||
const [redMode, setRedMode] = useState(() => localStorage.getItem(REDMODE_KEY) === '1')
|
||||
useRedMode(redMode)
|
||||
|
||||
const toggleRedMode = () => {
|
||||
setRedMode((alt) => {
|
||||
localStorage.setItem(REDMODE_KEY, alt ? '0' : '1')
|
||||
return !alt
|
||||
})
|
||||
}
|
||||
|
||||
const stapel = useMemo(
|
||||
() => (filter === 'alle' ? KARTEN : KARTEN.filter((k) => k.kategorie === filter)),
|
||||
[filter]
|
||||
() =>
|
||||
filter === 'alle'
|
||||
? redMode
|
||||
? KARTEN
|
||||
: KARTEN_OHNE_ROT
|
||||
: KARTEN.filter((k) => k.kategorie === filter),
|
||||
[filter, redMode]
|
||||
)
|
||||
|
||||
const ziehen = () => {
|
||||
@@ -71,15 +88,48 @@ export default function Cards() {
|
||||
Runde haben soll! 🎨
|
||||
</p>
|
||||
|
||||
{/* Red Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleRedMode}
|
||||
className={`mt-4 flex w-full items-center justify-between rounded-2xl border p-3.5 transition-all active:scale-[0.98] ${
|
||||
redMode
|
||||
? 'border-red-400/50 bg-red-500/10 shadow-[0_0_20px_rgba(239,68,68,0.25)]'
|
||||
: 'border-white/10 bg-white/[0.05]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Flame size={19} className={redMode ? 'text-red-400' : 'text-white/40'} />
|
||||
<span className="text-left">
|
||||
<span className={`block text-sm font-bold ${redMode ? 'text-red-300' : 'text-white/75'}`}>
|
||||
🔞 Red Mode <span className="font-extrabold">(FSK 18)</span>
|
||||
</span>
|
||||
<span className="block text-xs text-white/45">
|
||||
{redMode ? 'Aktiviert – 10 extra freche Karten im Stapel' : 'Schaltet 10 freche Karten frei'}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-7 w-12 shrink-0 rounded-full transition-colors ${
|
||||
redMode ? 'bg-gradient-to-r from-red-500 to-rose-600' : 'bg-white/15'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-5 w-5 rounded-full bg-white shadow transition-all ${
|
||||
redMode ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Kategorie-Filter */}
|
||||
<div className="scrollbar-none -mx-5 mt-5 flex gap-2 overflow-x-auto px-5 pb-1">
|
||||
<div className="scrollbar-none -mx-5 mt-4 flex gap-2 overflow-x-auto px-5 pb-1">
|
||||
<FilterChip aktiv={filter === 'alle'} onClick={() => setFilter('alle')} label="✨ Alle" />
|
||||
{KATEGORIEN.map((k) => (
|
||||
{KATEGORIEN.filter((k) => k.id !== 'rotlicht' || redMode).map((k) => (
|
||||
<FilterChip
|
||||
key={k.id}
|
||||
aktiv={filter === k.id}
|
||||
onClick={() => setFilter(k.id)}
|
||||
label={`${k.emoji} ${k.titel.split(' ')[0]}`}
|
||||
label={k.id === 'rotlicht' ? `🌶️ Rotlicht` : `${k.emoji} ${k.titel.split(' ')[0]}`}
|
||||
chipKlasse={k.chip}
|
||||
/>
|
||||
))}
|
||||
|
||||
+71
-6
@@ -14,7 +14,8 @@ import {
|
||||
Trophy,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { KARTEN } from '@/data/game'
|
||||
import { KARTEN, KARTEN_OHNE_ROT } from '@/data/game'
|
||||
import { useRedMode } from '@/hooks/useRedMode'
|
||||
import {
|
||||
ladeSession,
|
||||
speichereSession,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
raumBeitreten,
|
||||
zustandLaden,
|
||||
readySetzen,
|
||||
redModeSetzen,
|
||||
frageStellen,
|
||||
rankingAbgeben,
|
||||
naechstePhase,
|
||||
@@ -34,6 +36,8 @@ export default function Game() {
|
||||
const [session, setSession] = useState<RaumSession | null>(ladeSession)
|
||||
const [zustand, setZustand] = useState<RaumZustand | null>(null)
|
||||
const [fehler, setFehler] = useState('')
|
||||
// Red Mode des Raums verdunkelt das Design (nur während des Spiels)
|
||||
useRedMode(!!zustand?.redMode)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!session) return
|
||||
@@ -66,8 +70,8 @@ export default function Game() {
|
||||
setSession(s)
|
||||
}, [])
|
||||
|
||||
const erstellen = useCallback(async (name: string) => {
|
||||
const { session: s } = await raumErstellen(name)
|
||||
const erstellen = useCallback(async (name: string, redMode: boolean) => {
|
||||
const { session: s } = await raumErstellen(name, redMode)
|
||||
setSession(s)
|
||||
}, [])
|
||||
|
||||
@@ -142,12 +146,13 @@ function Einstieg({
|
||||
onErstellen,
|
||||
onBeitreten,
|
||||
}: {
|
||||
onErstellen: (name: string) => Promise<void>
|
||||
onErstellen: (name: string, redMode: boolean) => Promise<void>
|
||||
onBeitreten: (code: string, name: string) => Promise<void>
|
||||
}) {
|
||||
const [modus, setModus] = useState<'start' | 'beitreten'>('start')
|
||||
const [name, setName] = useState('')
|
||||
const [code, setCode] = useState(() => new URLSearchParams(window.location.search).get('raum') ?? '')
|
||||
const [redMode, setRedMode] = useState(false)
|
||||
const [lade, setLade] = useState(false)
|
||||
const [fehler, setFehler] = useState('')
|
||||
|
||||
@@ -157,7 +162,7 @@ function Einstieg({
|
||||
setLade(true)
|
||||
setFehler('')
|
||||
try {
|
||||
if (modus === 'start') await onErstellen(name.trim())
|
||||
if (modus === 'start') await onErstellen(name.trim(), redMode)
|
||||
else await onBeitreten(code.trim(), name.trim())
|
||||
} catch (e) {
|
||||
setFehler(e instanceof Error ? e.message : 'Verbindung fehlgeschlagen')
|
||||
@@ -207,6 +212,30 @@ function Einstieg({
|
||||
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"
|
||||
/>
|
||||
</Feld>
|
||||
{modus === 'start' && (
|
||||
<button
|
||||
onClick={() => setRedMode(!redMode)}
|
||||
className={`flex w-full items-center justify-between rounded-xl border px-3.5 py-3 transition-all active:scale-[0.98] ${
|
||||
redMode ? 'border-red-400/50 bg-red-500/10' : 'border-white/10 bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-bold text-white/80">
|
||||
🌶️ Red Mode <span className="text-red-300">🔞 FSK 18</span>
|
||||
<span className="text-xs font-normal text-white/45">– freche Karten im Spiel</span>
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-6 w-10 shrink-0 rounded-full transition-colors ${
|
||||
redMode ? 'bg-gradient-to-r from-red-500 to-rose-600' : 'bg-white/15'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${
|
||||
redMode ? 'left-5' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{fehler && <p className="text-sm font-bold text-rose-300">{fehler}</p>}
|
||||
<button
|
||||
onClick={los}
|
||||
@@ -290,6 +319,36 @@ function Lobby({
|
||||
</h3>
|
||||
<span className="text-xs font-bold text-white/45">Mind. {z.minSpieler}</span>
|
||||
</div>
|
||||
|
||||
{/* Red Mode: Host schaltet frei, alle sehen den Status */}
|
||||
<button
|
||||
onClick={() => ich?.istHost && onAct((s) => redModeSetzen(s, !z.redMode))}
|
||||
disabled={!ich?.istHost}
|
||||
className={`mb-3 flex w-full items-center justify-between rounded-xl border px-3.5 py-2.5 text-left transition-all ${
|
||||
z.redMode ? 'border-red-400/50 bg-red-500/10' : 'border-white/10 bg-white/[0.04]'
|
||||
} ${ich?.istHost ? 'active:scale-[0.98]' : 'opacity-80'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-bold text-white/80">
|
||||
🌶️ Red Mode <span className="text-red-300">🔞 FSK 18</span>
|
||||
<span className="text-xs font-normal text-white/45">
|
||||
{z.redMode ? 'AN – freche Karten sind im Spiel' : 'aus – klassische Karten'}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-6 w-10 shrink-0 rounded-full transition-colors ${
|
||||
z.redMode ? 'bg-gradient-to-r from-red-500 to-rose-600' : 'bg-white/15'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${
|
||||
z.redMode ? 'left-5' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{!ich?.istHost && (
|
||||
<p className="-mt-2 mb-3 text-right text-[11px] text-white/35">Nur der Host kann den Red Mode ändern.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{z.spieler.map((s) => (
|
||||
<div key={s.name} className="flex items-center justify-between rounded-xl bg-white/[0.05] px-3.5 py-2.5">
|
||||
@@ -333,7 +392,8 @@ function FragePhase({ z, onAct }: { z: RaumZustand; onAct: (fn: (s: RaumSession)
|
||||
const istFragesteller = z.ich === z.fragesteller
|
||||
|
||||
const ziehen = () => {
|
||||
const karte = KARTEN[Math.floor(Math.random() * KARTEN.length)]
|
||||
const stapel = z.redMode ? KARTEN : KARTEN_OHNE_ROT
|
||||
const karte = stapel[Math.floor(Math.random() * stapel.length)]
|
||||
setFrage(karte.text)
|
||||
}
|
||||
|
||||
@@ -354,6 +414,11 @@ function FragePhase({ z, onAct }: { z: RaumZustand; onAct: (fn: (s: RaumSession)
|
||||
<div className="glass-card p-5 text-center">
|
||||
<p className="text-xs font-extrabold uppercase tracking-[0.25em] text-violet-300">Du bist dran!</p>
|
||||
<p className="mt-1 text-sm text-white/60">Ziehe eine Karte und lies sie laut vor – oder erfinde eigene Frage.</p>
|
||||
{z.redMode && (
|
||||
<p className="mt-2 inline-block rounded-full border border-red-400/40 bg-red-500/10 px-3 py-1 text-xs font-bold text-red-300">
|
||||
🌶️ Red Mode – freche Karten sind im Stapel
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="glass-card space-y-3 p-4">
|
||||
<button
|
||||
|
||||
@@ -154,6 +154,7 @@ function oeffentlicherZustand(raum, token) {
|
||||
})),
|
||||
minSpieler: MIN_SPIELER,
|
||||
maxSpieler: MAX_SPIELER,
|
||||
redMode: !!raum.redMode,
|
||||
alleReady,
|
||||
rankingsFertig: Object.keys(raum.rankings).length,
|
||||
punkte: raum.punkte,
|
||||
@@ -211,6 +212,7 @@ const server = http.createServer(async (req, res) => {
|
||||
status: 'lobby',
|
||||
runde: 0,
|
||||
frage: '',
|
||||
redMode: !!body.redMode,
|
||||
spieler: [{ name, token: crypto.randomBytes(18).toString('hex'), ready: false, istHost: true }],
|
||||
rankings: {},
|
||||
punkte: { [name]: 0 },
|
||||
@@ -260,6 +262,15 @@ const server = http.createServer(async (req, res) => {
|
||||
return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) });
|
||||
}
|
||||
|
||||
/* Red Mode umschalten (nur Host, nur vor Spielstart) */
|
||||
if (req.method === 'POST' && aktion === 'redmode') {
|
||||
if (raum.status !== 'lobby') return fehler(res, 409, 'Red Mode ist nur vor Spielstart aenderbar');
|
||||
if (!spieler.istHost) return fehler(res, 403, 'Nur der Host kann den Red Mode aendern');
|
||||
raum.redMode = !!body.redMode;
|
||||
speichern();
|
||||
return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) });
|
||||
}
|
||||
|
||||
/* Frage stellen */
|
||||
if (req.method === 'POST' && aktion === 'question') {
|
||||
if (raum.status !== 'question') return fehler(res, 409, 'Keine Fragephase');
|
||||
|
||||
Reference in New Issue
Block a user