Files
BaraBingo/components/BingoCard.tsx
SlavaVlad 05677924b5
Some checks failed
Deploy / build-and-deploy (push) Failing after 2m53s
V1 bingo
2026-06-14 21:29:43 +03:00

300 lines
12 KiB
TypeScript
Raw 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;
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;
}
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 handleMark = async (itemId: string) => {
if (marking) return;
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) {
const item = grid.find(c => c.item?.id === itemId);
if (item?.item) playSound(item.item.soundCategory, item.item.soundUrl);
await fetchState();
} else if (res.status === 409) {
await fetchState();
}
} catch {}
setMarking(null);
};
const markedCount = grid.filter(c => c.marked).length;
const totalItems = grid.filter(c => c.item).length;
const hullHealth = Math.max(0, 100 - chaosLevel * 100);
const reactorTemp = 80 + chaosLevel * 120;
const o2Level = Math.max(0, 100 - chaosLevel * 130);
const stageLabel =
chaosLevel < 0.25 ? "✅ Sub stable" :
chaosLevel < 0.5 ? "⚠️ Leaking" :
chaosLevel < 0.75 ? "🚨 MELTDOWN" : "☢️ SUB DESTROYED";
const chaosStageClass =
chaosLevel < 0.3 ? "from-cyan-600 to-cyan-500" :
chaosLevel < 0.6 ? "from-amber-600 to-orange-500" :
"from-red-600 to-red-500";
return (
<div className="flex flex-col items-center gap-4 w-full max-w-5xl mx-auto">
{/* Header */}
<div className="text-center w-full">
<h2 className="text-2xl font-bold text-cyan-300 font-mono tracking-wide uppercase">
{campaign.name}
</h2>
<p className="text-sm text-slate-500 font-mono mt-1">
{markedCount}/{totalItems} cells marked
</p>
</div>
{/* Chaos Meter */}
<div className="w-full space-y-1">
<div className="flex justify-between text-xs font-mono text-slate-500">
<span>{stageLabel}</span>
<span>{Math.round(chaosLevel * 100)}% chaos</span>
</div>
<div className="w-full bg-slate-900/60 rounded-full h-3 overflow-hidden border border-slate-700/30">
<div
className={cn(
"h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r",
chaosStageClass,
chaosLevel >= 0.6 && "animate-pulse"
)}
style={{ width: `${chaosLevel * 100}%` }}
/>
</div>
</div>
{/* Sub Status + Grid Row */}
<div className="flex gap-4 w-full">
{/* Sub Status Panel */}
<div className="hidden md:flex flex-col gap-2 w-48 shrink-0">
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 space-y-2">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider">Sub Status</h3>
<div className="space-y-2">
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">Hull</span>
<span className={cn(hullHealth < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(hullHealth)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", hullHealth < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${hullHealth}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">Reactor</span>
<span className={cn(reactorTemp > 120 ? "text-orange-400" : "text-slate-400")}>{Math.round(reactorTemp)}°C</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", reactorTemp > 120 ? "bg-orange-500 animate-pulse" : "bg-cyan-500")} style={{ width: `${Math.min(reactorTemp / 2, 100)}%` }} />
</div>
</div>
<div>
<div className="flex justify-between text-[9px] font-mono">
<span className="text-slate-500">O</span>
<span className={cn(o2Level < 30 ? "text-red-400" : "text-slate-400")}>{Math.round(o2Level)}%</span>
</div>
<div className="w-full h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-700", o2Level < 30 ? "bg-red-500" : "bg-cyan-500")} style={{ width: `${o2Level}%` }} />
</div>
</div>
</div>
<div className="text-[8px] font-mono text-slate-700 pt-1 border-t border-slate-700/30">
{chaosLevel < 0.25 && "All systems nominal"}
{chaosLevel >= 0.25 && chaosLevel < 0.5 && "⚠️ Minor breaches detected"}
{chaosLevel >= 0.5 && chaosLevel < 0.75 && "🚨 EVACUATE! SUB COMPROMISED"}
{chaosLevel >= 0.75 && "☢️ MELTDOWN IN PROGRESS"}
</div>
</div>
{/* Activity Log */}
<div className="bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 flex-1 min-h-0 max-h-60 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2 sticky top-0 bg-slate-900/80 pb-1">Crew Log</h3>
<div className="space-y-1">
{activityLog.length === 0 && (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
)}
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
</div>
</div>
{/* Bingo Grid */}
<div className="flex-1 min-w-0">
<div
className="grid gap-1.5 w-full"
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={handleMark}
disabled={marking !== null}
/>
))}
</div>
</div>
</div>
{/* Bingo Lines */}
{bingoLines.length > 0 && (
<div className="flex flex-wrap gap-2 items-center justify-center">
<Badge variant="destructive" className="text-sm px-4 py-1 animate-pulse shadow-lg shadow-red-600/30">
🏆 BINGO! {bingoLines.length} line(s)
</Badge>
</div>
)}
{/* Bingo Celebration 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>
)}
{/* Mobile Activity Log */}
<div className="md:hidden w-full bg-slate-900/60 border border-slate-700/30 rounded-lg p-3 max-h-40 overflow-y-auto">
<h3 className="text-[9px] font-mono text-slate-600 uppercase tracking-wider mb-2">Crew Log</h3>
<div className="space-y-1">
{activityLog.length === 0 && (
<p className="text-[9px] font-mono text-slate-700 italic">Awaiting chaos...</p>
)}
{activityLog.map((entry, i) => (
<p key={i} className="text-[9px] font-mono text-slate-500 leading-relaxed">{entry}</p>
))}
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2 justify-center text-[10px] text-slate-600 font-mono">
<span>🎺 horn</span>
<span>🚨 alarm</span>
<span>🌊 flood</span>
<span>💥 boom</span>
<span>👹 monster</span>
<span>💀 death</span>
<span>🔥 chaos</span>
</div>
</div>
);
}