All checks were successful
Deploy / build-and-deploy (push) Successful in 1m14s
257 lines
9.1 KiB
TypeScript
257 lines
9.1 KiB
TypeScript
"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 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) {
|
||
// already marked by someone else, just refresh
|
||
}
|
||
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;
|
||
|
||
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-2 w-full max-w-lg 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-xs text-slate-500 font-mono">
|
||
{markedCount}/{totalItems} cells marked
|
||
</p>
|
||
</div>
|
||
|
||
{/* Chaos + Subs compact row */}
|
||
<div className="w-full flex gap-2 items-stretch">
|
||
<div className="flex-1 min-w-0 space-y-0.5">
|
||
<div className="flex justify-between text-[9px] 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-2 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>
|
||
<div className="hidden sm:flex gap-2 text-[9px] font-mono">
|
||
<div className={cn("px-2 py-1 rounded border", hullHealth < 30 ? "border-red-800/50 text-red-400" : "border-slate-700/30 text-slate-500")}>
|
||
Hull {Math.round(hullHealth)}%
|
||
</div>
|
||
<div className={cn("px-2 py-1 rounded border", reactorTemp > 120 ? "border-orange-800/50 text-orange-400" : "border-slate-700/30 text-slate-500")}>
|
||
R{Math.round(reactorTemp)}°
|
||
</div>
|
||
<div className={cn("px-2 py-1 rounded border", o2Level < 30 ? "border-red-800/50 text-red-400" : "border-slate-700/30 text-slate-500")}>
|
||
O₂ {Math.round(o2Level)}%
|
||
</div>
|
||
</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 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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|