Files
skribbl-gartic-color/app/components/SkribblGame.tsx
T

279 lines
12 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { getSocket } from "../lib/socket-client";
import { useGame } from "../lib/store";
import ChatBox from "./ChatBox";
import PlayerList from "./PlayerList";
const PALETTE = [
"#2D2D2D","#FFFFFF","#FF5C5C","#FFD23F","#4ECDC4","#A593E0","#5BCEFA",
"#1F8FFF","#1AAE56","#A35924","#FF8FB1","#7B3F00","#888888"
];
const SIZES = [3, 6, 12];
export default function SkribblGame() {
const room = useGame((s) => s.room);
const myId = useGame((s) => s.myId);
const [choices, setChoices] = useState<string[]|null>(null);
const [endsAt, setEndsAt] = useState<number>(0);
const [now, setNow] = useState(Date.now());
const [color, setColor] = useState("#2D2D2D");
const [size, setSize] = useState(6);
const [tool, setTool] = useState<"brush"|"eraser">("brush");
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null);
const drawingRef = useRef(false);
const lastRef = useRef<{x:number;y:number}|null>(null);
const broadcastBufRef = useRef<any[]>([]);
const lastBroadcastRef = useRef(0);
// Listen for skribbl-specific events
useEffect(() => {
const socket = getSocket();
const onChoices = (c: string[]) => setChoices(c);
const onRoundStart = (d: any) => {
setChoices(null);
setEndsAt(Date.now() + d.durationMs);
clearLocalCanvas();
};
const onHint = () => {/* server pushes new room state */};
const onRoundEnd = () => { setEndsAt(0); };
const onStroke = (s: any) => {
drawSegment(s.prevX, s.prevY, s.x, s.y, s.color, s.size, s.tool);
};
const onClear = () => clearLocalCanvas();
socket.on("skribbl:wordChoices", onChoices);
socket.on("skribbl:roundStart", onRoundStart);
socket.on("skribbl:hint", onHint);
socket.on("skribbl:roundEnd", onRoundEnd);
socket.on("skribbl:stroke", onStroke);
socket.on("skribbl:clear", onClear);
return () => {
socket.off("skribbl:wordChoices", onChoices);
socket.off("skribbl:roundStart", onRoundStart);
socket.off("skribbl:hint", onHint);
socket.off("skribbl:roundEnd", onRoundEnd);
socket.off("skribbl:stroke", onStroke);
socket.off("skribbl:clear", onClear);
};
}, []);
// Use endsAt from room.skribbl too, in case we joined mid-round
useEffect(() => {
if (room?.skribbl?.endsAt) setEndsAt(room.skribbl.endsAt);
}, [room?.skribbl?.endsAt]);
// Tick
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(t);
}, []);
// Resize canvas
useEffect(() => {
const resize = () => {
const canvas = canvasRef.current;
const stage = stageRef.current;
if (!canvas || !stage) return;
const rect = stage.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(100, rect.width * dpr);
canvas.height = Math.max(100, rect.height * dpr);
canvas.style.width = rect.width + "px";
canvas.style.height = rect.height + "px";
const ctx = canvas.getContext("2d");
if (ctx) ctx.scale(dpr, dpr);
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
const drawSegment = (px:number, py:number, x:number, y:number, c:string, sz:number, tl:string) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = tl === "eraser" ? "#FFF8E7" : c;
ctx.lineWidth = sz;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(x, y);
ctx.stroke();
};
const clearLocalCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const sk = room?.skribbl;
const isDrawer = sk?.drawerId === myId;
const drawerName = room?.players.find(p => p.id === sk?.drawerId)?.nickname || "?";
const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const onDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer || sk?.phase !== "drawing") return;
e.preventDefault();
drawingRef.current = true;
const p = getPos(e);
lastRef.current = p;
drawSegment(p.x, p.y, p.x, p.y, color, size, tool);
queueStroke({ prevX: p.x, prevY: p.y, x: p.x, y: p.y, color, size, tool });
};
const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current || !isDrawer) return;
const p = getPos(e);
const last = lastRef.current!;
drawSegment(last.x, last.y, p.x, p.y, color, size, tool);
queueStroke({ prevX: last.x, prevY: last.y, x: p.x, y: p.y, color, size, tool });
lastRef.current = p;
};
const onUp = () => {
drawingRef.current = false;
lastRef.current = null;
flushStrokes();
};
const queueStroke = (s: any) => {
broadcastBufRef.current.push(s);
const now = Date.now();
if (now - lastBroadcastRef.current > 33) {
flushStrokes();
}
};
const flushStrokes = () => {
const buf = broadcastBufRef.current;
if (!buf.length) return;
const socket = getSocket();
for (const s of buf) socket.emit("skribbl:stroke", s);
broadcastBufRef.current = [];
lastBroadcastRef.current = Date.now();
};
const pickWord = (word: string) => {
getSocket().emit("skribbl:pickWord", { word }, () => {});
};
const clearAll = () => {
if (!isDrawer) return;
clearLocalCanvas();
getSocket().emit("skribbl:clear");
};
const remaining = Math.max(0, Math.ceil((endsAt - now) / 1000));
const totalSec = (room?.settings?.drawTimeSec || 80) as number;
const ringPct = endsAt ? Math.max(0, Math.min(1, (endsAt - now) / (totalSec * 1000))) : 0;
const dashTotal = 165;
const dashOff = dashTotal * (1 - ringPct);
const wordMask = sk?.wordMask || "";
return (
<div className="grid gap-5 max-[1100px]:grid-cols-1" style={{gridTemplateColumns:"220px 1fr 280px"}}>
<aside className="panel" data-testid="scoreboard">
<div className="text-xs font-bold uppercase tracking-wider mb-3 px-1" style={{color:"rgba(45,45,45,0.6)"}}>Scoreboard</div>
<PlayerList highlightDrawerId={sk?.drawerId} />
</aside>
<section className="flex flex-col gap-3.5">
<div className="flex items-center justify-between gap-4 panel" style={{padding:"14px 18px"}}>
<div className="relative w-[60px] h-[60px] flex-shrink-0" data-testid="timer-ring">
<svg width="60" height="60" style={{transform:"rotate(-90deg)"}}>
<circle cx="30" cy="30" r="26" fill="none" stroke="rgba(45,45,45,0.12)" strokeWidth="6"/>
<circle cx="30" cy="30" r="26" fill="none" stroke="var(--coral)" strokeWidth="6" strokeLinecap="round" strokeDasharray={dashTotal} strokeDashoffset={dashOff} style={{transition:"stroke-dashoffset 1s linear"}}/>
</svg>
<div className="absolute inset-0 grid place-items-center font-bold text-base">{remaining}</div>
</div>
<div className="flex-1 text-center">
<div className="text-xs font-bold uppercase tracking-wider" style={{color:"rgba(45,45,45,0.55)"}}>
{isDrawer ? "Your secret word" : `${drawerName} is drawing`}
</div>
<div className="text-xl font-bold tracking-[6px] mt-1" data-testid="word-mask">
{isDrawer && sk?.phase === "drawing" ? (sk as any)?.word || wordMask : wordMask || "_____"}
</div>
</div>
<div className="pill" style={{background:"var(--lavender)", color:"white"}}>
Round {sk?.roundIndex || 0}/{sk?.totalRounds || 0}
</div>
</div>
<div className="panel relative" style={{padding:16, aspectRatio:"16/10", minHeight:380, display:"flex", flexDirection:"column", gap:12}}>
{isDrawer && <div className="absolute top-3.5 left-3.5 px-3 py-1.5 rounded-full text-white border-2 border-dark text-xs font-bold flex items-center gap-1.5 z-10" style={{background:"var(--coral)", boxShadow:"0 3px 0 rgba(0,0,0,0.15)"}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/></svg>
You're drawing
</div>}
<div ref={stageRef} className="flex-1 rounded-2xl overflow-hidden relative" style={{background:"#FFF8E7", border:"2px dashed rgba(45,45,45,0.18)"}}>
<canvas
ref={canvasRef}
data-testid="draw-canvas"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerLeave={onUp}
style={{ touchAction: "none", cursor: isDrawer ? "crosshair" : "default", display:"block" }}
/>
</div>
{isDrawer && <div className="flex items-center gap-3.5 p-3 rounded-2xl flex-wrap justify-center" style={{background:"var(--cream)"}}>
<div className="flex gap-1.5 flex-wrap">
{PALETTE.map(c => (
<button type="button" key={c} onClick={()=>{setColor(c); setTool("brush");}}
data-testid={`color-${c}`}
className="w-7 h-7 rounded-lg border-2 border-dark shadow-chunky transition-transform hover:-translate-y-0.5"
style={{background:c, outline: color===c ? "3px solid var(--dark)" : undefined, outlineOffset: color===c ? 2 : undefined}}/>
))}
</div>
<div className="w-0.5 h-7" style={{background:"rgba(45,45,45,0.15)"}}/>
<div className="flex gap-1.5">
{SIZES.map((sz, i) => (
<button type="button" key={sz} onClick={()=>setSize(sz)} data-testid={`size-${sz}`} className="w-9 h-9 rounded-xl border-2 border-dark grid place-items-center shadow-chunky" style={{background: size===sz ? "var(--mint)" : "white"}}>
<span className="rounded-full bg-dark" style={{width: 4 + i*4, height: 4 + i*4}}/>
</button>
))}
</div>
<div className="w-0.5 h-7" style={{background:"rgba(45,45,45,0.15)"}}/>
<button type="button" onClick={()=>setTool(tool==="eraser"?"brush":"eraser")} data-testid="tool-eraser" className="w-9 h-9 rounded-xl border-2 border-dark grid place-items-center shadow-chunky" style={{background: tool==="eraser" ? "var(--yellow)" : "white"}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M20 20H7l-4-4 8-8 12 12-3 0z"/></svg>
</button>
<button type="button" onClick={clearAll} data-testid="tool-clear" className="px-3 h-9 rounded-xl border-2 border-dark font-bold text-xs shadow-chunky" style={{background:"white"}}>Clear</button>
</div>}
</div>
</section>
<aside className="panel flex flex-col" style={{minHeight:500}}>
<div className="text-xs font-bold uppercase tracking-wider mb-2 px-1" style={{color:"rgba(45,45,45,0.6)"}}>Guesses</div>
<ChatBox placeholder={isDrawer ? "You're drawing!" : "Type your guess..."}/>
</aside>
{choices && (
<Modal>
<h2 className="font-bold text-2xl text-center mb-1">Pick a word to draw</h2>
<p className="text-center font-medium text-sm mb-5" style={{color:"rgba(45,45,45,0.65)"}}>Choose one of the three. 15s timer.</p>
<div className="flex gap-3 justify-center flex-wrap">
{choices.map(w => (
<button key={w} onClick={()=>pickWord(w)} className="btn btn-yellow" data-testid={`pick-${w}`} style={{padding:"14px 22px"}}>{w}</button>
))}
</div>
</Modal>
)}
</div>
);
}
function Modal({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-50 grid place-items-center px-4" style={{background:"rgba(45,45,45,0.55)"}}>
<div className="panel max-w-[520px] w-full" data-testid="modal">{children}</div>
</div>
);
}