Files
BaraBingo/components/BingoCard.tsx

275 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { BingoCell } from "./BingoCell";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { playSound, playBingo } from "@/lib/sounds";
import { cn } from "@/lib/utils";
type Item = {
id: string;
text: string;
emoji: string;
soundCategory: string;
soundUrl?: string | null;
imageUrl?: string | null;
gridIndex: number;
};
type GridCell = {
index: number;
item: Item | null;
marked: boolean;
markedBy: string[];
markCount: number;
};
type Campaign = {
id: string;
name: string;
gridSize: number;
status: string;
};
type Props = {
campaign: Campaign;
initialGrid: GridCell[];
currentUserNickname: string;
isAdmin: boolean;
};
function checkBingo(grid: GridCell[], gridSize: number): number[][] {
const lines: number[][] = [];
const size = gridSize;
for (let row = 0; row < size; row++) {
const indices = Array.from({ length: size }, (_, c) => row * size + c);
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
}
for (let col = 0; col < size; col++) {
const indices = Array.from({ length: size }, (_, r) => r * size + col);
if (indices.every(i => grid[i]?.marked)) lines.push(indices);
}
const diag1 = Array.from({ length: size }, (_, i) => i * size + i);
if (diag1.every(i => grid[i]?.marked)) lines.push(diag1);
const diag2 = Array.from({ length: size }, (_, i) => (i + 1) * size - i - 1);
if (diag2.every(i => grid[i]?.marked)) lines.push(diag2);
return lines;
}
function StatusGauge({ label, value, unit, low, high, icon, stage }: {
label: string; value: number; unit?: string; low?: boolean; high?: boolean; icon: string; stage?: string;
}) {
const pct = Math.min(Math.max(value, 0), 10000) / 100;
return (
<div className="flex items-center gap-1.5 text-[9px] font-mono">
<span className="w-4 text-center shrink-0">{icon}</span>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex justify-between">
<span className="text-slate-500">{label}</span>
<span className={cn(low && "text-red-400", high && "text-orange-400", !low && !high && "text-slate-400")}>
{unit ? `${Math.round(value)}${unit}` : Math.round(value)}
</span>
</div>
<div className="h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={cn(
"h-full rounded-full transition-all duration-700",
low && "bg-red-500", high && "bg-orange-500",
!low && !high && "bg-cyan-500",
stage === "panic" && "animate-pulse",
stage === "lost" && "animate-pulse",
)} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
</div>
</div>
);
}
export function BingoCard({ campaign, initialGrid, currentUserNickname, isAdmin }: Props) {
const [grid, setGrid] = useState<GridCell[]>(initialGrid);
const [bingoLines, setBingoLines] = useState<number[][]>([]);
const [marking, setMarking] = useState<string | null>(null);
const [chaosLevel, setChaosLevel] = useState(0);
const [showBingo, setShowBingo] = useState(false);
const [activityLog, setActivityLog] = useState<string[]>([]);
const bingoNotified = useRef(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchState = useCallback(async () => {
try {
const res = await fetch(`/api/game/${campaign.id}/state`);
const data = await res.json();
if (data.grid) {
setGrid(data.grid);
const lines = checkBingo(data.grid, campaign.gridSize);
setBingoLines(lines);
const markedCount = data.grid.filter((c: GridCell) => c.marked).length;
const totalItems = data.grid.filter((c: GridCell) => c.item).length;
setChaosLevel(totalItems > 0 ? Math.min(markedCount / totalItems, 1) : 0);
if (lines.length > 0 && !bingoNotified.current) {
bingoNotified.current = true;
setShowBingo(true);
playBingo();
}
if (data.activityLog) setActivityLog(data.activityLog);
}
} catch {}
}, [campaign.id, campaign.gridSize]);
useEffect(() => {
fetchState();
pollingRef.current = setInterval(fetchState, 3000);
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, [fetchState]);
const markItem = async (itemId: string) => {
if (marking) return;
const item = grid.find(c => c.item?.id === itemId);
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
setMarking(itemId);
try {
const res = await fetch("/api/game/mark", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campaignId: campaign.id, itemId }),
});
if (!res.ok && res.status === 409) {}
await fetchState();
} catch {}
setMarking(null);
};
const unmarkItem = async (itemId: string) => {
if (marking) return;
setMarking(itemId);
try {
await fetch("/api/game/mark", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ campaignId: campaign.id, itemId }),
});
await fetchState();
} catch {}
setMarking(null);
};
const markedCount = grid.filter(c => c.marked).length;
const totalItems = grid.filter(c => c.item).length;
// HUD params
const reactorTemp = 2000 + chaosLevel * 8000;
const hullHealth = Math.max(0, 100 - chaosLevel * 100);
const floodLevel = Math.min(100, chaosLevel * 100);
const captainSanity = Math.max(0, 100 - chaosLevel * 100);
const sanityStage =
captainSanity >= 75 ? "calm" :
captainSanity >= 50 ? "nervous" :
captainSanity >= 25 ? "panic" : "lost";
const sanityLabel =
sanityStage === "calm" ? "Держится ещё" :
sanityStage === "nervous" ? "Кукуха свистит" :
sanityStage === "panic" ? "Паника!" : "Кукуха уехала пиздаааа!";
const sanityIcon =
sanityStage === "calm" ? "😐" :
sanityStage === "nervous" ? "😰" :
sanityStage === "panic" ? "😱" : "🤪";
const stageLabel =
chaosLevel < 0.25 ? "Sub stable" :
chaosLevel < 0.5 ? "⚠️ Leaking" :
chaosLevel < 0.75 ? "🚨 MELTDOWN" : "☢️ DESTROYED";
return (
<div className="flex flex-col items-center gap-3 w-full max-w-2xl mx-auto">
{/* Header */}
<div className="text-center w-full">
<h2 className="text-xl font-bold text-cyan-300 font-mono tracking-wide uppercase">{campaign.name}</h2>
<p className="text-[10px] text-slate-500 font-mono">
{markedCount}/{totalItems} cells {stageLabel}
</p>
</div>
{/* HUD Panel */}
<div className={cn(
"w-full border rounded-lg p-2 space-y-1.5 bg-slate-900/50 transition-all",
sanityStage === "panic" && "border-orange-700/40 animate-shake",
sanityStage === "lost" && "border-purple-700/50 animate-flicker",
sanityStage !== "panic" && sanityStage !== "lost" && "border-slate-700/30",
)}>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<StatusGauge icon="⚛️" label="Reactor" value={reactorTemp} unit="°C" low={reactorTemp > 9000} high={reactorTemp > 5000 && reactorTemp <= 9000} />
<StatusGauge icon="🛡️" label="Hull" value={hullHealth} unit="%" low={hullHealth < 30} />
<StatusGauge icon="🌊" label="Flood" value={floodLevel} unit="%" high={floodLevel > 50} />
<StatusGauge icon={sanityIcon} label="Кэп" value={captainSanity} unit="%" low={captainSanity < 30} stage={sanityStage === "panic" || sanityStage === "lost" ? sanityStage : undefined} />
</div>
<div className="text-[8px] font-mono text-slate-600 text-center pt-1 border-t border-slate-700/20">
{sanityStage === "calm" && "Капитан спокоен. Пока."}
{sanityStage === "nervous" && "Капитан дёргается. Это нормально."}
{sanityStage === "panic" && "КЭП НА НЕРВАХ! НЕ ТРОГАЙТЕ ЕГО!"}
{sanityStage === "lost" && "КУКУХА УЕХАЛА! Капитан ушёл в отрыв!"}
</div>
</div>
{/* Bingo Grid */}
<div className="w-full">
<div
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${campaign.gridSize}, 1fr)` }}
>
{grid.map((cell) => (
<BingoCell
key={cell.index}
item={cell.item}
index={cell.index}
marked={cell.marked}
markCount={cell.markCount}
markedBy={cell.markedBy}
gridSize={campaign.gridSize}
onMark={markItem}
onUnmark={unmarkItem}
disabled={marking !== null}
/>
))}
</div>
</div>
{/* Bingo Lines */}
{bingoLines.length > 0 && (
<Badge variant="destructive" className="text-xs px-3 py-1 animate-pulse shadow-lg shadow-red-600/30">
🏆 BINGO! {bingoLines.length} line(s)
</Badge>
)}
{/* Activity Log */}
<div className="w-full bg-slate-900/60 border border-slate-700/30 rounded-lg p-2 max-h-28 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-1 sticky top-0 bg-slate-900/80 pb-0.5">Crew Log</h3>
{activityLog.length === 0 ? (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
) : (
<div className="space-y-0.5">
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
)}
</div>
{/* Bingo Modal */}
{showBingo && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={() => setShowBingo(false)}>
<div className="bg-slate-900 border-2 border-amber-500/50 rounded-2xl p-8 text-center shadow-2xl shadow-amber-900/30 animate-[bounce_1s_ease-in-out_infinite] max-w-md mx-4" onClick={e => e.stopPropagation()}>
<div className="text-6xl mb-4">🏆🔥💀</div>
<h2 className="text-3xl font-bold text-amber-400 font-mono mb-2">БИНГО!</h2>
<p className="text-slate-300 mb-4">Капитан офонарел! Суб разгерметизирован! Все мертвы!</p>
<div className="text-4xl mb-4 animate-pulse">🎉💥🌊🤪</div>
<Button variant="outline" onClick={() => setShowBingo(false)}>Продолжить хаос</Button>
</div>
</div>
)}
</div>
);
}