302 lines
15 KiB
TypeScript
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,
|
|
};
|