197 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|