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

197 lines
8.6 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { getSocket } from "../lib/socket-client";
import { useGame } from "../lib/store";
const PALETTE = ["#2D2D2D","#FF5C5C","#FFD23F","#4ECDC4","#A593E0","#5BCEFA","#FF8FB1","#1AAE56"];
export default function GarticGame() {
const room = useGame((s) => s.room);
const [task, setTask] = useState<"prompt"|"drawing"|"guess"|null>(null);
const [content, setContent] = useState("");
const [endsAt, setEndsAt] = useState(0);
const [now, setNow] = useState(Date.now());
const [text, setText] = useState("");
const [submitted, setSubmitted] = useState(false);
const [color, setColor] = useState("#2D2D2D");
const [size, setSize] = useState(5);
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);
useEffect(() => {
const socket = getSocket();
const onTurn = (d: any) => {
setTask(d.task);
setContent(d.content || "");
setEndsAt(Date.now() + d.durationMs);
setText("");
setSubmitted(false);
setTimeout(() => clearLocalCanvas(), 50);
};
const onBook = () => { setTask(null); };
socket.on("gartic:turn", onTurn);
socket.on("gartic:bookComplete", onBook);
return () => {
socket.off("gartic:turn", onTurn);
socket.off("gartic:bookComplete", onBook);
};
}, []);
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(t);
}, []);
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);
}, [task]);
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 drawSegment = (px:number, py:number, x:number, y:number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = color;
ctx.lineWidth = size;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(x, y);
ctx.stroke();
};
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 (task !== "drawing" || submitted) return;
e.preventDefault();
drawingRef.current = true;
const p = getPos(e);
lastRef.current = p;
drawSegment(p.x, p.y, p.x, p.y);
};
const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
const p = getPos(e);
drawSegment(lastRef.current!.x, lastRef.current!.y, p.x, p.y);
lastRef.current = p;
};
const onUp = () => { drawingRef.current = false; lastRef.current = null; };
const submit = () => {
if (submitted) return;
let data: string = "";
if (task === "drawing") {
const c = canvasRef.current;
if (!c) return;
data = c.toDataURL("image/png");
} else {
data = text.trim();
if (!data) return;
}
getSocket().emit("gartic:submit", { type: task, data }, (resp: any) => {
if (resp?.ok) setSubmitted(true);
});
};
const remaining = Math.max(0, Math.ceil((endsAt - now) / 1000));
const totalTurns = room?.gartic?.totalTurns || 0;
const turnIndex = room?.gartic?.turnIndex || 0;
if (!task) {
return <div className="panel text-center p-8"><div className="font-bold text-2xl">Get ready...</div><div className="font-medium text-sm mt-2" style={{color:"rgba(45,45,45,0.6)"}}>The first prompt is loading.</div></div>;
}
return (
<div className="grid gap-5 max-w-[900px] mx-auto">
<div className="panel flex items-center justify-between" style={{padding:"14px 18px"}}>
<div className="pill" style={{background:"var(--lavender)", color:"white"}}>Turn {turnIndex+1}/{totalTurns}</div>
<div className="text-2xl font-bold tabular-nums">{remaining}s</div>
<div className="pill">{task === "prompt" ? "Write a prompt" : task === "drawing" ? "Draw it" : "What is this?"}</div>
</div>
<div className="panel flex flex-col gap-4" style={{minHeight: 460}}>
{task === "prompt" && (
<div className="flex flex-col gap-3 items-center justify-center flex-1">
<h2 className="font-bold text-3xl text-center">Write a silly prompt</h2>
<p className="font-medium text-sm text-center" style={{color:"rgba(45,45,45,0.6)"}}>Other players will draw this. Be creative!</p>
<input data-testid="gartic-prompt" maxLength={120} value={text} onChange={(e)=>setText(e.target.value)} className="input-text max-w-[440px] text-center text-lg" placeholder="A penguin riding a skateboard..." disabled={submitted}/>
</div>
)}
{task === "drawing" && (
<>
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-wider" style={{color:"rgba(45,45,45,0.6)"}}>Draw this</div>
<div className="text-xl font-bold mt-1" data-testid="gartic-source">{content || "..."}</div>
</div>
<div ref={stageRef} className="flex-1 rounded-2xl overflow-hidden relative" style={{background:"#FFF8E7", border:"2px dashed rgba(45,45,45,0.18)", minHeight: 320}}>
<canvas ref={canvasRef} data-testid="gartic-canvas"
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerLeave={onUp}
style={{touchAction:"none", cursor:"crosshair", display:"block"}}/>
</div>
<div className="flex items-center gap-2 justify-center flex-wrap p-2">
{PALETTE.map(c => (
<button key={c} type="button" onClick={()=>setColor(c)} className="w-7 h-7 rounded-lg border-2 border-dark shadow-chunky" style={{background:c, outline: color===c ? "3px solid var(--dark)" : undefined, outlineOffset: color===c ? 2 : undefined}}/>
))}
<div className="w-0.5 h-7" style={{background:"rgba(45,45,45,0.2)"}}/>
{[3,6,12].map((s, i) => (
<button key={s} type="button" onClick={()=>setSize(s)} className="w-9 h-9 rounded-xl border-2 border-dark grid place-items-center shadow-chunky" style={{background: size===s ? "var(--mint)" : "white"}}>
<span className="rounded-full bg-dark" style={{width: 4+i*4, height: 4+i*4}}/>
</button>
))}
<button type="button" onClick={clearLocalCanvas} className="px-3 h-9 rounded-xl border-2 border-dark font-bold text-xs shadow-chunky" style={{background:"white"}}>Clear</button>
</div>
</>
)}
{task === "guess" && (
<>
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-wider" style={{color:"rgba(45,45,45,0.6)"}}>What is this?</div>
</div>
<div className="flex-1 rounded-2xl overflow-hidden grid place-items-center" style={{background:"#FFF8E7", border:"2px dashed rgba(45,45,45,0.18)"}}>
{content ? <img src={content} alt="drawing" data-testid="gartic-image" className="max-h-[380px] max-w-full object-contain"/> : <span className="font-medium" style={{color:"rgba(45,45,45,0.5)"}}>(no drawing)</span>}
</div>
<input data-testid="gartic-guess" maxLength={120} value={text} onChange={(e)=>setText(e.target.value)} className="input-text max-w-[440px] mx-auto text-center text-lg" placeholder="What do you see?" disabled={submitted}/>
</>
)}
<button onClick={submit} disabled={submitted} data-testid="gartic-submit" className="btn btn-primary self-center" style={{padding:"14px 28px"}}>
{submitted ? "Waiting for others..." : "Submit"}
</button>
</div>
</div>
);
}