Files

302 lines
15 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { getSocket } from "../lib/socket-client";
import { useGame } from "../lib/store";
const PALETTE = [
"#FF5C5C","#FFD23F","#4ECDC4","#A593E0","#5BCEFA","#FF8FB1","#1AAE56","#7B3F00",
"#2D2D2D","#FFFFFF","#F87171","#FBBF24","#34D399","#60A5FA","#A78BFA","#F472B6"
];
export default function ColorGame() {
const room = useGame((s) => s.room);
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 [color, setColor] = useState("#FF5C5C");
const [size, setSize] = useState(8);
const [tool, setTool] = useState<"brush"|"eraser"|"bucket">("brush");
const [regions, setRegions] = useState<Record<string,string>>({});
const useTemplate = room?.color?.canvasType === "template";
const templateId = room?.color?.templateId || "mandala";
useEffect(() => {
const socket = getSocket();
const onState = (s: any) => {
// redraw all strokes
clearLocalCanvas();
for (const stroke of s.strokes || []) drawStrokeLocal(stroke);
setRegions(s.regions || {});
};
const onStroke = (s: any) => drawStrokeLocal(s);
const onBucket = (b: any) => setRegions((r) => ({ ...r, [b.regionId]: b.color }));
socket.on("color:state", onState);
socket.on("color:strokeBroadcast", onStroke);
socket.on("color:bucketBroadcast", onBucket);
return () => {
socket.off("color:state", onState);
socket.off("color:strokeBroadcast", onStroke);
socket.off("color:bucketBroadcast", onBucket);
};
}, []);
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 drawStrokeLocal = (s: any) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx || !s.points || s.points.length === 0) return;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = s.tool === "eraser" ? "#FFF8E7" : s.color;
ctx.lineWidth = s.size;
ctx.beginPath();
ctx.moveTo(s.points[0].x, s.points[0].y);
for (const p of s.points.slice(1)) ctx.lineTo(p.x, p.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 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 pointBuf = useRef<{x:number;y:number}[]>([]);
const onDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (tool === "bucket") return;
e.preventDefault();
drawingRef.current = true;
const p = getPos(e);
lastRef.current = p;
pointBuf.current = [p];
drawStrokeLocal({ points: [p, p], color, size, tool });
};
const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!drawingRef.current) return;
const p = getPos(e);
drawStrokeLocal({ points: [lastRef.current!, p], color, size, tool });
pointBuf.current.push(p);
lastRef.current = p;
if (pointBuf.current.length >= 6) flushStroke();
};
const onUp = () => {
if (!drawingRef.current) return;
drawingRef.current = false;
lastRef.current = null;
flushStroke();
};
const flushStroke = () => {
if (pointBuf.current.length < 2) { pointBuf.current = []; return; }
const stroke = { points: pointBuf.current.slice(), color, size, tool };
pointBuf.current = pointBuf.current.slice(-1);
getSocket().emit("color:stroke", stroke);
};
const handleRegionClick = (regionId: string) => {
if (tool !== "bucket") return;
getSocket().emit("color:bucket", { regionId, color });
};
const reset = () => {
if (!confirm("Clear the whole canvas?")) return;
getSocket().emit("color:reset", {});
};
const TemplateSvg = useTemplate ? TEMPLATE_SVGS[templateId] || TEMPLATE_SVGS.mandala : null;
return (
<div className="grid gap-5">
<div className="panel" style={{padding:16}}>
<div ref={stageRef} className="rounded-2xl overflow-hidden relative" style={{background:"white", border:"3px solid var(--dark)", aspectRatio:"4/3", minHeight: 420}}>
{/* Template SVG painted underneath; canvas above for freehand */}
{TemplateSvg && (
<svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid meet" className="absolute inset-0 w-full h-full pointer-events-auto">
<TemplateSvg regions={regions} onRegionClick={handleRegionClick}/>
</svg>
)}
<canvas
ref={canvasRef}
data-testid="color-canvas"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerLeave={onUp}
className="absolute inset-0 w-full h-full"
style={{ touchAction: "none", cursor: tool === "bucket" ? "pointer" : "crosshair", display:"block", pointerEvents: tool === "bucket" && useTemplate ? "none" : "auto" }}
/>
</div>
<div className="flex items-center justify-center gap-3 flex-wrap mt-4 p-3 rounded-2xl" style={{background:"var(--cream)"}}>
<div className="flex gap-1.5 flex-wrap justify-center">
{PALETTE.map(c => (
<button key={c} type="button" onClick={()=>setColor(c)} data-testid={`color-${c}`} className="w-8 h-8 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-8" style={{background:"rgba(45,45,45,0.15)"}}/>
<div className="flex gap-1.5">
{[
{id:"brush", label:"Brush"},
{id:"eraser", label:"Eraser"},
...(useTemplate ? [{id:"bucket", label:"Bucket"}] : []),
].map((t: any) => (
<button key={t.id} type="button" onClick={()=>setTool(t.id)} data-testid={`tool-${t.id}`} className="px-3 h-10 rounded-xl border-2 border-dark font-bold text-xs shadow-chunky" style={{background: tool===t.id ? "var(--yellow)" : "white"}}>{t.label}</button>
))}
</div>
<div className="w-0.5 h-8" style={{background:"rgba(45,45,45,0.15)"}}/>
<div className="flex gap-1.5">
{[4,8,16].map((s, i) => (
<button key={s} type="button" onClick={()=>setSize(s)} className="w-10 h-10 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>
))}
</div>
<div className="w-0.5 h-8" style={{background:"rgba(45,45,45,0.15)"}}/>
<button type="button" onClick={reset} data-testid="color-reset" className="px-4 h-10 rounded-xl border-2 border-dark font-bold text-xs shadow-chunky" style={{background:"var(--coral)", color:"white"}}>Reset</button>
</div>
</div>
</div>
);
}
// Lightweight inline templates with named regions
function MandalaT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return (
<g>
<rect x="0" y="0" width="400" height="300" fill={get("bg", "#FFFFFF")} onClick={()=>onRegionClick("bg")} />
<circle cx="200" cy="150" r="120" fill={get("c1", "#FFF8E7")} stroke="#2D2D2D" strokeWidth="3" onClick={()=>onRegionClick("c1")}/>
<circle cx="200" cy="150" r="80" fill={get("c2", "#FFF8E7")} stroke="#2D2D2D" strokeWidth="3" onClick={()=>onRegionClick("c2")}/>
<circle cx="200" cy="150" r="40" fill={get("c3", "#FFF8E7")} stroke="#2D2D2D" strokeWidth="3" onClick={()=>onRegionClick("c3")}/>
{[0,45,90,135,180,225,270,315].map((deg, i) => {
const r = 100;
const x = 200 + Math.cos(deg*Math.PI/180) * r;
const y = 150 + Math.sin(deg*Math.PI/180) * r;
return <circle key={i} cx={x} cy={y} r="14" fill={get(`p${i}`, "#FFF8E7")} stroke="#2D2D2D" strokeWidth="3" onClick={()=>onRegionClick(`p${i}`)}/>;
})}
</g>
);
}
function CatT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<ellipse cx="200" cy="220" rx="110" ry="60" fill={get("body","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("body")}/>
<circle cx="200" cy="140" r="80" fill={get("head","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("head")}/>
<polygon points="135,90 125,40 175,80" fill={get("ear1","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("ear1")}/>
<polygon points="265,90 275,40 225,80" fill={get("ear2","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("ear2")}/>
<circle cx="178" cy="135" r="6" fill="#2D2D2D"/>
<circle cx="222" cy="135" r="6" fill="#2D2D2D"/>
<path d="M190 160 Q200 168 210 160" fill={get("nose","#FF5C5C")} stroke="#2D2D2D" strokeWidth="3" onClick={()=>onRegionClick("nose")}/>
</g>;
}
function TreeT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<rect x="180" y="180" width="40" height="100" fill={get("trunk","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("trunk")}/>
<circle cx="200" cy="140" r="80" fill={get("leaves","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("leaves")}/>
<circle cx="160" cy="100" r="40" fill={get("leaves2","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("leaves2")}/>
<circle cx="240" cy="100" r="40" fill={get("leaves3","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("leaves3")}/>
</g>;
}
function FishT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<ellipse cx="180" cy="150" rx="110" ry="60" fill={get("body","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("body")}/>
<polygon points="280,150 350,100 350,200" fill={get("tail","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("tail")}/>
<circle cx="130" cy="135" r="8" fill="#2D2D2D"/>
<path d="M120 155 Q140 170 160 155" stroke="#2D2D2D" strokeWidth="3" fill="none"/>
</g>;
}
function ButterflyT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<ellipse cx="200" cy="150" rx="14" ry="60" fill={get("body","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("body")}/>
<ellipse cx="135" cy="120" rx="60" ry="50" fill={get("wing1","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("wing1")}/>
<ellipse cx="265" cy="120" rx="60" ry="50" fill={get("wing2","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("wing2")}/>
<ellipse cx="135" cy="190" rx="50" ry="40" fill={get("wing3","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("wing3")}/>
<ellipse cx="265" cy="190" rx="50" ry="40" fill={get("wing4","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("wing4")}/>
</g>;
}
function HouseT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<rect x="120" y="140" width="160" height="120" fill={get("walls","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("walls")}/>
<polygon points="100,140 200,60 300,140" fill={get("roof","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("roof")}/>
<rect x="180" y="200" width="40" height="60" fill={get("door","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("door")}/>
<rect x="135" y="160" width="30" height="30" fill={get("win1","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("win1")}/>
<rect x="235" y="160" width="30" height="30" fill={get("win2","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("win2")}/>
</g>;
}
function FlowerT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<rect x="195" y="180" width="10" height="100" fill={get("stem","#FFF8E7")} stroke="#2D2D2D" strokeWidth="3" onClick={()=>onRegionClick("stem")}/>
{[0,72,144,216,288].map((deg, i) => {
const r = 50;
const x = 200 + Math.cos((deg-90)*Math.PI/180) * r;
const y = 140 + Math.sin((deg-90)*Math.PI/180) * r;
return <ellipse key={i} cx={x} cy={y} rx="34" ry="24" fill={get(`pet${i}`,"#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick(`pet${i}`)} transform={`rotate(${deg-90} ${x} ${y})`}/>;
})}
<circle cx="200" cy="140" r="22" fill={get("center","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("center")}/>
</g>;
}
function SunT({ regions, onRegionClick }: any) {
const get = (id: string, def: string) => regions[id] || def;
return <g>
<rect x="0" y="0" width="400" height="300" fill={get("bg","#FFFFFF")} onClick={()=>onRegionClick("bg")}/>
<circle cx="200" cy="150" r="60" fill={get("sun","#FFF8E7")} stroke="#2D2D2D" strokeWidth="4" onClick={()=>onRegionClick("sun")}/>
{[0,30,60,90,120,150,180,210,240,270,300,330].map((deg, i) => {
const r1 = 75, r2 = 105;
const x1 = 200 + Math.cos(deg*Math.PI/180)*r1;
const y1 = 150 + Math.sin(deg*Math.PI/180)*r1;
const x2 = 200 + Math.cos(deg*Math.PI/180)*r2;
const y2 = 150 + Math.sin(deg*Math.PI/180)*r2;
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#2D2D2D" strokeWidth="6" strokeLinecap="round"/>;
})}
</g>;
}
const TEMPLATE_SVGS: Record<string, any> = {
mandala: MandalaT,
cat: CatT,
tree: TreeT,
fish: FishT,
butterfly: ButterflyT,
house: HouseT,
flower: FlowerT,
sun: SunT,
};