feat: Next.js + Socket.IO 3-mode game (Skribbl, Gartic, Color)
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
uptime: Math.floor(process.uptime()),
|
||||
service: "skribbl-gartic-color",
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// We can't share in-memory Map between Next handlers and the socket server
|
||||
// trivially. The socket server holds room state — for this lightweight check
|
||||
// we just respond ok=false unconditionally with a short cache. The real check
|
||||
// happens on socket connect.
|
||||
//
|
||||
// However, since both are in the same process when running via custom server,
|
||||
// we can require the same module.
|
||||
|
||||
export async function GET(_req: Request, ctx: { params: { code: string } }) {
|
||||
let exists = false;
|
||||
let mode: string | null = null;
|
||||
let playerCount = 0;
|
||||
try {
|
||||
// require lazily; safe if running under custom server
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { getRoom } = require("../../../../../server/room-store.js");
|
||||
const room = getRoom(ctx.params.code);
|
||||
if (room) {
|
||||
exists = true;
|
||||
mode = room.mode;
|
||||
playerCount = room.players.filter((p: any) => p.connected).length;
|
||||
}
|
||||
} catch (e) {
|
||||
// if running outside custom server, no in-memory state
|
||||
}
|
||||
return NextResponse.json({ exists, mode, playerCount });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getSocket } from "../lib/socket-client";
|
||||
import { useGame } from "../lib/store";
|
||||
|
||||
export default function ChatBox({ placeholder = "Type your guess..." }: { placeholder?: string }) {
|
||||
const chat = useGame((s) => s.chat);
|
||||
const myId = useGame((s) => s.myId);
|
||||
const room = useGame((s) => s.room);
|
||||
const [text, setText] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [chat.length]);
|
||||
|
||||
const send = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
getSocket().emit("chat:send", { text: t });
|
||||
setText("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[360px]">
|
||||
<div ref={scrollRef} data-testid="chat-messages" className="flex-1 overflow-y-auto flex flex-col gap-2 pr-1">
|
||||
{chat.map((m) => {
|
||||
const me = m.fromId && m.fromId === myId;
|
||||
if (m.kind === "system") {
|
||||
return <div key={m.id} className="text-center italic text-xs" style={{color:"rgba(45,45,45,0.6)"}}>{m.text}</div>;
|
||||
}
|
||||
if (m.kind === "correct") {
|
||||
return <div key={m.id} className="rounded-xl px-3 py-2 text-sm font-semibold" style={{background:"rgba(78,205,196,0.18)", border:"2px dashed var(--mint)"}}>
|
||||
<strong>{m.fromName}</strong> {m.text}
|
||||
</div>;
|
||||
}
|
||||
if (m.kind === "close") {
|
||||
return <div key={m.id} className="rounded-xl px-3 py-2 text-sm italic" style={{background:"rgba(255,210,63,0.3)"}}>{m.text}</div>;
|
||||
}
|
||||
return <div key={m.id} className="rounded-xl px-3 py-2 text-sm" style={{background:"var(--cream)"}}>
|
||||
<span className="font-bold mr-1.5" style={{color: me ? "var(--mint)" : "var(--coral)"}}>{m.fromName}:</span>{m.text}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<form className="flex gap-2 pt-2.5 mt-2.5" style={{borderTop:"2px dashed rgba(45,45,45,0.15)"}} onSubmit={send}>
|
||||
<input data-testid="chat-input" value={text} onChange={(e)=>setText(e.target.value)} maxLength={200} className="input-text" style={{borderRadius:9999}} placeholder={placeholder}/>
|
||||
<button type="submit" data-testid="chat-send" className="w-11 h-11 rounded-full grid place-items-center border-2 border-dark shadow-chunky" style={{background:"var(--mint)"}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4z"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
import { useGame } from "../lib/store";
|
||||
|
||||
const AVATAR_BG = ["var(--yellow)","var(--mint)","var(--lavender)","var(--sky)","var(--coral)"];
|
||||
|
||||
export default function PlayerList({ highlightDrawerId }: { highlightDrawerId?: string | null }) {
|
||||
const room = useGame((s) => s.room);
|
||||
const myId = useGame((s) => s.myId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5" data-testid="player-list">
|
||||
{room.players.map((p, i) => {
|
||||
const me = p.id === myId;
|
||||
const drawing = highlightDrawerId === p.id;
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-3 p-2.5 px-3.5 rounded-2xl border-2 transition-all"
|
||||
style={{
|
||||
background: me ? "rgba(255,92,92,0.08)" : drawing ? "var(--yellow)" : "var(--cream)",
|
||||
borderColor: me ? "var(--coral)" : drawing ? "var(--dark)" : "transparent",
|
||||
}}>
|
||||
<div className="w-10 h-10 rounded-xl border-2 border-dark grid place-items-center text-xl flex-shrink-0" style={{background: AVATAR_BG[i % AVATAR_BG.length]}}>
|
||||
{p.avatar}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-sm flex items-center gap-1.5">
|
||||
<span className="truncate">{p.nickname}</span>
|
||||
{p.isHost && <span className="bg-yellow border-2 border-dark rounded-md px-1.5 py-0.5 text-[10px] font-bold flex items-center gap-1">HOST</span>}
|
||||
{me && <span className="bg-coral text-white rounded-md px-1.5 py-0.5 text-[10px] font-bold">YOU</span>}
|
||||
</div>
|
||||
<div className="text-xs font-medium" style={{color:"rgba(45,45,45,0.6)"}}>
|
||||
{drawing ? "Drawing..." : !p.connected ? "Reconnecting..." : `${p.score} pts`}
|
||||
</div>
|
||||
</div>
|
||||
{drawing && <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getSocket } from "../lib/socket-client";
|
||||
import { useGame, readSession, writeSession } from "../lib/store";
|
||||
import type { ChatMsg, RoomState } from "../lib/types";
|
||||
|
||||
/**
|
||||
* Mounts once per room page tree. Connects to the socket and (re)joins the room
|
||||
* using a stored session token. Pushes room state into the Zustand store.
|
||||
*/
|
||||
export default function RoomConnector({ code }: { code: string }) {
|
||||
const router = useRouter();
|
||||
const setRoom = useGame((s) => s.setRoom);
|
||||
const pushChat = useGame((s) => s.pushChat);
|
||||
const clearChat = useGame((s) => s.clearChat);
|
||||
const setMe = useGame((s) => s.setMe);
|
||||
const ranOnce = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranOnce.current) return;
|
||||
ranOnce.current = true;
|
||||
const socket = getSocket();
|
||||
const session = readSession(code);
|
||||
|
||||
const tryJoin = () => {
|
||||
socket.emit(
|
||||
"room:join",
|
||||
{
|
||||
code,
|
||||
nickname: session?.nickname || "Guest",
|
||||
avatar: session?.avatar || "🐱",
|
||||
sessionToken: session?.token,
|
||||
},
|
||||
(resp: any) => {
|
||||
if (!resp || !resp.ok) {
|
||||
// No valid session — bounce to /join with code prefilled
|
||||
router.replace(`/join?code=${code}`);
|
||||
return;
|
||||
}
|
||||
writeSession(resp.code, {
|
||||
token: resp.sessionToken,
|
||||
nickname: session?.nickname || "Guest",
|
||||
avatar: session?.avatar || "🐱",
|
||||
playerId: resp.playerId,
|
||||
});
|
||||
setMe(resp.playerId, resp.sessionToken);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (socket.connected) tryJoin();
|
||||
else socket.once("connect", tryJoin);
|
||||
|
||||
const onState = (state: RoomState) => setRoom(state);
|
||||
const onChat = (m: ChatMsg) => pushChat(m);
|
||||
|
||||
socket.on("room:state", onState);
|
||||
socket.on("chat:msg", onChat);
|
||||
|
||||
return () => {
|
||||
socket.off("room:state", onState);
|
||||
socket.off("chat:msg", onChat);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [code]);
|
||||
|
||||
// when leaving the room subtree clear local chat
|
||||
useEffect(() => {
|
||||
return () => clearChat();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSocket } from "../lib/socket-client";
|
||||
import { writeSession } from "../lib/store";
|
||||
|
||||
const AVATARS = ["🦄","🦊","🐼","🐸","🐱","🐶","🦁","🐯","🐰","🐻","🐨","🦝"];
|
||||
|
||||
export default function CreateRoomPage() {
|
||||
const router = useRouter();
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [avatar, setAvatar] = useState("🦊");
|
||||
const [mode, setMode] = useState<"skribbl"|"gartic"|"color">("skribbl");
|
||||
const [rounds, setRounds] = useState(4);
|
||||
const [drawTime, setDrawTime] = useState(80);
|
||||
const [canvasType, setCanvasType] = useState<"blank"|"template">("blank");
|
||||
const [templateId, setTemplateId] = useState("mandala");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
if (!nickname.trim()) { setErr("Pick a nickname"); return; }
|
||||
setBusy(true);
|
||||
const settings: any = {};
|
||||
if (mode === "skribbl") { settings.rounds = rounds; settings.drawTimeSec = drawTime; settings.language = "en"; }
|
||||
if (mode === "color") { settings.canvasType = canvasType; settings.templateId = templateId; }
|
||||
|
||||
const socket = getSocket();
|
||||
const finish = () => {
|
||||
socket.emit("room:create", { nickname: nickname.trim(), avatar, mode, settings }, (resp: any) => {
|
||||
if (!resp || !resp.ok) { setErr(resp?.error || "failed"); setBusy(false); return; }
|
||||
writeSession(resp.code, { token: resp.sessionToken, nickname: nickname.trim(), avatar, playerId: resp.playerId });
|
||||
router.push(`/room/${resp.code}`);
|
||||
});
|
||||
};
|
||||
if (socket.connected) finish();
|
||||
else socket.once("connect", finish);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex items-center justify-between max-w-[1280px] mx-auto px-7 py-5">
|
||||
<Link href="/" className="flex items-center gap-2.5 font-bold text-2xl no-underline text-dark">
|
||||
<span className="logo-mark sm">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</Link>
|
||||
<Link href="/join" className="btn btn-ghost" style={{padding:"10px 18px",fontSize:14}}>Join Instead</Link>
|
||||
</header>
|
||||
|
||||
<main className="max-w-[760px] mx-auto px-6 pb-16">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold">Create your room</h1>
|
||||
<p className="font-medium mt-2" style={{color:"rgba(45,45,45,0.7)"}}>Pick a mode and customise the rules</p>
|
||||
</div>
|
||||
<form className="panel flex flex-col gap-6" onSubmit={submit}>
|
||||
<div>
|
||||
<label className="field-label">Your nickname</label>
|
||||
<input data-testid="nickname-input" className="input-text" maxLength={20} value={nickname} onChange={(e)=>setNickname(e.target.value)} placeholder="Doodle Master"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">Pick avatar</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AVATARS.map(a => (
|
||||
<button type="button" key={a} onClick={()=>setAvatar(a)}
|
||||
className="w-12 h-12 rounded-xl border-[3px] grid place-items-center text-2xl shadow-chunky transition-transform hover:-translate-y-0.5"
|
||||
style={{background: avatar===a?"var(--yellow)":"var(--cream)", borderColor: avatar===a?"var(--dark)":"rgba(45,45,45,0.3)"}}
|
||||
data-testid={`avatar-${a}`}
|
||||
>{a}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">Choose game mode</label>
|
||||
<div className="grid grid-cols-3 gap-3 max-[640px]:grid-cols-1">
|
||||
{([
|
||||
{id:"skribbl", label:"Skribbl Race", desc:"Draw & guess", color:"var(--coral)", testid:"mode-skribbl"},
|
||||
{id:"gartic", label:"Gartic Phone", desc:"Pass it on", color:"var(--mint)", testid:"mode-gartic"},
|
||||
{id:"color", label:"Color Together", desc:"Chill paint", color:"var(--lavender)", testid:"mode-color"},
|
||||
] as const).map(m => (
|
||||
<button type="button" key={m.id} onClick={()=>setMode(m.id as any)} data-testid={m.testid}
|
||||
className="rounded-2xl border-[3px] p-4 text-left flex flex-col gap-1 transition-transform hover:-translate-y-1"
|
||||
style={{
|
||||
background: mode===m.id ? m.color : "var(--cream)",
|
||||
color: mode===m.id ? "white" : "var(--dark)",
|
||||
borderColor: mode===m.id ? "var(--dark)" : "rgba(45,45,45,0.2)",
|
||||
boxShadow: mode===m.id ? "0 5px 0 rgba(0,0,0,0.18)" : "none",
|
||||
}}>
|
||||
<div className="font-bold text-lg">{m.label}</div>
|
||||
<div className="text-sm opacity-90 font-medium">{m.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "skribbl" && (
|
||||
<div className="grid grid-cols-2 gap-4 max-[640px]:grid-cols-1">
|
||||
<div>
|
||||
<label className="field-label">Rounds</label>
|
||||
<div className="flex gap-2">
|
||||
{[2,4,8].map(r => (
|
||||
<button type="button" key={r} onClick={()=>setRounds(r)}
|
||||
data-testid={`rounds-${r}`}
|
||||
className="flex-1 py-3 rounded-xl border-[3px] font-bold"
|
||||
style={{background: rounds===r ? "var(--yellow)" : "var(--cream)", borderColor:"var(--dark)"}}>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Draw time</label>
|
||||
<div className="flex gap-2">
|
||||
{[60,80,120].map(t => (
|
||||
<button type="button" key={t} onClick={()=>setDrawTime(t)}
|
||||
data-testid={`time-${t}`}
|
||||
className="flex-1 py-3 rounded-xl border-[3px] font-bold"
|
||||
style={{background: drawTime===t ? "var(--mint)" : "var(--cream)", borderColor:"var(--dark)"}}>{t}s</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "color" && (
|
||||
<div className="grid grid-cols-2 gap-4 max-[640px]:grid-cols-1">
|
||||
<div>
|
||||
<label className="field-label">Canvas</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={()=>setCanvasType("blank")} className="flex-1 py-3 rounded-xl border-[3px] font-bold" style={{background: canvasType==="blank"?"var(--yellow)":"var(--cream)",borderColor:"var(--dark)"}}>Blank</button>
|
||||
<button type="button" onClick={()=>setCanvasType("template")} className="flex-1 py-3 rounded-xl border-[3px] font-bold" style={{background: canvasType==="template"?"var(--yellow)":"var(--cream)",borderColor:"var(--dark)"}}>Template</button>
|
||||
</div>
|
||||
</div>
|
||||
{canvasType === "template" && (
|
||||
<div>
|
||||
<label className="field-label">Template</label>
|
||||
<select className="input-text" value={templateId} onChange={(e)=>setTemplateId(e.target.value)}>
|
||||
{["mandala","cat","tree","fish","butterfly","house","flower","sun"].map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <div className="text-coral font-bold text-sm">{err}</div>}
|
||||
|
||||
<button type="submit" disabled={busy} data-testid="create-submit" className="btn btn-primary self-end" style={{padding:"16px 32px", fontSize:18}}>
|
||||
{busy ? "Creating..." : "Create Room"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--yellow: #FFD23F;
|
||||
--coral: #FF5C5C;
|
||||
--mint: #4ECDC4;
|
||||
--lavender: #A593E0;
|
||||
--sky: #5BCEFA;
|
||||
--dark: #2D2D2D;
|
||||
--cream: #FFF8E7;
|
||||
--white: #FFFFFF;
|
||||
--shadow-card: 0 6px 0 rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08);
|
||||
--shadow-btn: 0 5px 0 rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
font-family: 'Fredoka', system-ui, sans-serif;
|
||||
background: var(--cream);
|
||||
color: var(--dark);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body { min-height: 100vh; overflow-x: hidden; }
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply font-bold inline-flex items-center justify-center gap-2 rounded-full border-[3px] border-dark cursor-pointer transition-transform;
|
||||
box-shadow: var(--shadow-btn);
|
||||
padding: 14px 24px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.btn:hover { transform: translateY(-2px); box-shadow: 0 7px 0 rgba(0,0,0,0.18), 0 4px 10px rgba(0,0,0,0.14); }
|
||||
.btn:active { transform: translateY(2px); box-shadow: 0 2px 0 rgba(0,0,0,0.18); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
|
||||
.btn-primary { background: var(--coral); color: white; }
|
||||
.btn-secondary { background: var(--mint); color: var(--dark); }
|
||||
.btn-ghost { background: white; color: var(--dark); }
|
||||
.btn-yellow { background: var(--yellow); color: var(--dark); }
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border: 3px solid var(--dark);
|
||||
border-radius: 22px;
|
||||
padding: 22px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.pill {
|
||||
@apply inline-flex items-center gap-2 rounded-full border-2 border-dark bg-white font-bold;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,0.10);
|
||||
}
|
||||
|
||||
.input-text {
|
||||
@apply font-semibold w-full;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid var(--dark);
|
||||
border-radius: 14px;
|
||||
background: var(--cream);
|
||||
outline: none;
|
||||
}
|
||||
.input-text:focus { background: white; border-color: var(--coral); }
|
||||
|
||||
.field-label {
|
||||
@apply font-bold uppercase tracking-wider text-xs mb-2 block;
|
||||
color: rgba(45,45,45,0.6);
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 44px; height: 44px; border-radius: 14px; background: var(--coral);
|
||||
display: grid; place-items: center; box-shadow: var(--shadow-card); transform: rotate(-6deg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo-mark.sm { width: 38px; height: 38px; border-radius: 12px; }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(45,45,45,0.2); border-radius: 999px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(45,45,45,0.35); }
|
||||
|
||||
/* Utility for the chunky offset shadow */
|
||||
.shadow-card { box-shadow: 0 6px 0 rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.shadow-btn { box-shadow: 0 5px 0 rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12); }
|
||||
.shadow-chunky { box-shadow: 0 3px 0 rgba(0,0,0,0.15); }
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(-2deg); }
|
||||
50% { transform: translateY(-8px) rotate(2deg); }
|
||||
}
|
||||
.animate-float { animation: float 3.5s ease-in-out infinite; }
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { getSocket } from "../lib/socket-client";
|
||||
import { writeSession } from "../lib/store";
|
||||
|
||||
const AVATARS = ["🦄","🦊","🐼","🐸","🐱","🐶","🦁","🐯","🐰","🐻","🐨","🦝"];
|
||||
|
||||
export default function JoinRoomPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-10 text-center font-bold">Loading...</div>}>
|
||||
<JoinRoomInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function JoinRoomInner() {
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const [code, setCode] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [avatar, setAvatar] = useState("🐱");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const c = search?.get("code");
|
||||
if (c) setCode(c.toUpperCase());
|
||||
}, [search]);
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
const cleanCode = code.trim().toUpperCase();
|
||||
if (!cleanCode) { setErr("Enter a room code"); return; }
|
||||
if (!nickname.trim()) { setErr("Pick a nickname"); return; }
|
||||
setBusy(true);
|
||||
const socket = getSocket();
|
||||
const finish = () => {
|
||||
socket.emit("room:join", { code: cleanCode, nickname: nickname.trim(), avatar }, (resp: any) => {
|
||||
if (!resp || !resp.ok) { setErr(resp?.error || "couldn't join"); setBusy(false); return; }
|
||||
writeSession(resp.code, { token: resp.sessionToken, nickname: nickname.trim(), avatar, playerId: resp.playerId });
|
||||
router.push(`/room/${resp.code}`);
|
||||
});
|
||||
};
|
||||
if (socket.connected) finish();
|
||||
else socket.once("connect", finish);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex items-center justify-between max-w-[1280px] mx-auto px-7 py-5">
|
||||
<Link href="/" className="flex items-center gap-2.5 font-bold text-2xl no-underline text-dark">
|
||||
<span className="logo-mark sm">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</Link>
|
||||
<Link href="/create" className="btn btn-primary" style={{padding:"10px 18px",fontSize:14}}>Create Room</Link>
|
||||
</header>
|
||||
|
||||
<main className="max-w-[520px] mx-auto px-6 pb-16">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold">Join a room</h1>
|
||||
<p className="font-medium mt-2" style={{color:"rgba(45,45,45,0.7)"}}>Got a 6-character code? Drop it in.</p>
|
||||
</div>
|
||||
<form className="panel flex flex-col gap-5" onSubmit={submit}>
|
||||
<div>
|
||||
<label className="field-label">Room code</label>
|
||||
<input data-testid="room-code-input" maxLength={6} value={code} onChange={(e)=>setCode(e.target.value.toUpperCase())} className="input-text font-bold tracking-[8px] text-center text-2xl uppercase" placeholder="ABC123"/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Nickname</label>
|
||||
<input data-testid="nickname-input" maxLength={20} value={nickname} onChange={(e)=>setNickname(e.target.value)} className="input-text" placeholder="Your name"/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">Avatar</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AVATARS.map(a => (
|
||||
<button type="button" key={a} onClick={()=>setAvatar(a)}
|
||||
className="w-12 h-12 rounded-xl border-[3px] grid place-items-center text-2xl shadow-chunky transition-transform hover:-translate-y-0.5"
|
||||
style={{background: avatar===a?"var(--yellow)":"var(--cream)", borderColor: avatar===a?"var(--dark)":"rgba(45,45,45,0.3)"}}>{a}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="text-coral font-bold text-sm">{err}</div>}
|
||||
<button type="submit" disabled={busy} data-testid="join-submit" className="btn btn-secondary self-stretch" style={{padding:"16px 32px",fontSize:18}}>
|
||||
{busy ? "Joining..." : "Join Room"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "DrawTogether - Play, Draw & Color Together",
|
||||
description: "Skribbl Race, Gartic Phone & Color Together — three games, one playful canvas.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (typeof window === "undefined") {
|
||||
// SSR - return a fake unconnected socket placeholder; never used.
|
||||
return {} as Socket;
|
||||
}
|
||||
if (!socket) {
|
||||
socket = io({
|
||||
path: "/socket.io",
|
||||
transports: ["websocket", "polling"],
|
||||
autoConnect: true,
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import { create } from "zustand";
|
||||
import type { RoomState, ChatMsg } from "./types";
|
||||
|
||||
type Store = {
|
||||
room: RoomState | null;
|
||||
chat: ChatMsg[];
|
||||
myId: string | null;
|
||||
sessionToken: string | null;
|
||||
setRoom: (r: RoomState | null) => void;
|
||||
pushChat: (m: ChatMsg) => void;
|
||||
clearChat: () => void;
|
||||
setMe: (id: string | null, token: string | null) => void;
|
||||
};
|
||||
|
||||
export const useGame = create<Store>((set) => ({
|
||||
room: null,
|
||||
chat: [],
|
||||
myId: null,
|
||||
sessionToken: null,
|
||||
setRoom: (r) => set({ room: r }),
|
||||
pushChat: (m) =>
|
||||
set((s) => ({ chat: [...s.chat, m].slice(-150) })),
|
||||
clearChat: () => set({ chat: [] }),
|
||||
setMe: (id, token) => set({ myId: id, sessionToken: token }),
|
||||
}));
|
||||
|
||||
export function readSession(code?: string) {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const key = code ? `dt_session_${code}` : "dt_session_last";
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(code: string, data: { token: string; nickname: string; avatar: string; playerId: string }) {
|
||||
try {
|
||||
localStorage.setItem(`dt_session_${code}`, JSON.stringify(data));
|
||||
localStorage.setItem("dt_session_last", JSON.stringify({ ...data, code }));
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type Mode = "skribbl" | "gartic" | "color";
|
||||
|
||||
export type PlayerPublic = {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
score: number;
|
||||
connected: boolean;
|
||||
isHost: boolean;
|
||||
};
|
||||
|
||||
export type ChatMsg = {
|
||||
id: string;
|
||||
fromId: string | null;
|
||||
fromName: string;
|
||||
text: string;
|
||||
kind: "chat" | "system" | "correct" | "close";
|
||||
};
|
||||
|
||||
export type RoomState = {
|
||||
code: string;
|
||||
hostId: string;
|
||||
mode: Mode;
|
||||
settings: any;
|
||||
phase: "lobby" | "playing" | "results";
|
||||
players: PlayerPublic[];
|
||||
skribbl: null | {
|
||||
roundIndex: number;
|
||||
totalRounds: number;
|
||||
drawerId: string | null;
|
||||
wordMask: string | null;
|
||||
wordLength: number;
|
||||
phase: "choosing" | "drawing" | "between";
|
||||
endsAt: number;
|
||||
solvedIds: string[];
|
||||
};
|
||||
gartic: null | {
|
||||
turnIndex: number;
|
||||
totalTurns: number;
|
||||
phase: "prompt" | "drawing" | "guess" | "done";
|
||||
endsAt: number;
|
||||
submittedIds: string[];
|
||||
};
|
||||
color: null | {
|
||||
canvasType: "blank" | "template";
|
||||
templateId: string | null;
|
||||
strokeCount: number;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
"apple","banana","carrot","dog","cat","elephant","fish","guitar","house","ice cream",
|
||||
"jellyfish","kangaroo","lemon","mountain","notebook","octopus","pizza","queen","rainbow","sun",
|
||||
"tree","umbrella","violin","whale","xylophone","yacht","zebra","airplane","balloon","castle",
|
||||
"dolphin","eagle","forest","giraffe","hat","island","jacket","kite","ladder","mushroom",
|
||||
"ninja","orange","pumpkin","quilt","robot","strawberry","tiger","unicorn","volcano","window",
|
||||
"zipper","anchor","bee","cake","duck","ear","flag","ghost","hammer","igloo",
|
||||
"jeans","key","lamp","moon","needle","owl","pencil","queen bee","ring","skateboard",
|
||||
"tooth","umbrella","vase","wand","yarn","ant","bridge","clock","drum","envelope",
|
||||
"fence","glove","helicopter","ink","jar","koala","leaf","mask","nest","onion",
|
||||
"pirate","quill","river","snake","tornado","ufo","vampire","wizard","yogurt","zoo",
|
||||
"astronaut","beach","camera","desert","eye","feather","goat","heart","iguana","jellybean",
|
||||
"knight","lion","monkey","nut","ocean","panda","quokka","rocket","spider","turtle",
|
||||
"unicycle","vest","watermelon","wallet","yo-yo","zombie","alien","bat","crayon","donut",
|
||||
"eel","fox","grape","hippo","ink bottle","jet","king","ladybug","mermaid","narwhal",
|
||||
"octagon","pirate ship","quest","rabbit","scarecrow","tomato","unicorn horn","violin bow","wolf","x-ray",
|
||||
"yak","zucchini","apricot","bicycle","candle","dinosaur","earring","frog","golf club","hourglass",
|
||||
"ice","jukebox","kayak","lighthouse","mailbox","newspaper","ostrich","penguin","quartz","raccoon",
|
||||
"scissors","train","umpire","vacuum","watch","yawn","zigzag","arrow","bookshelf","cactus",
|
||||
"diamond","eraser","flute","glasses","horseshoe","iceberg","jaguar","ketchup","lollipop","moustache",
|
||||
"nose","oven","puzzle","quokka","ribbon","saxophone","telescope","ukulele","village","waffle",
|
||||
"yogi","brain","cherry","fork","gum","mug","pie","skull","stool","trumpet"
|
||||
]
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div>
|
||||
<header className="flex items-center justify-between max-w-[1280px] mx-auto px-8 py-5 max-[480px]:px-4">
|
||||
<div className="flex items-center gap-2.5 font-bold text-2xl tracking-wide">
|
||||
<span className="logo-mark">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><circle cx="6" cy="6" r="2"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</div>
|
||||
<nav className="flex gap-3 items-center">
|
||||
<Link href="#modes" className="font-semibold text-dark px-4 py-2 rounded-full hover:bg-yellow/40 max-[480px]:hidden">Modes</Link>
|
||||
<Link href="/join" className="btn btn-ghost" style={{padding: "10px 20px", fontSize: 15}} data-testid="nav-join">Join Room</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section className="hero max-w-[1280px] mx-auto px-8 py-10 grid gap-10 items-center max-[900px]:grid-cols-1 max-[900px]:pb-10 max-[480px]:px-4 max-[480px]:py-6" style={{gridTemplateColumns: "1.1fr 1fr"}}>
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 bg-white px-4 py-2 rounded-full font-semibold text-sm border-2 border-dark shadow-card mb-5">
|
||||
<span className="w-2.5 h-2.5 bg-mint rounded-full"/> Live now: 2,418 players drawing together
|
||||
</div>
|
||||
<h1 className="font-bold leading-none mb-5 tracking-tight" style={{fontSize: "clamp(40px, 6vw, 72px)"}}>
|
||||
Draw, guess & <span className="relative inline-block text-coral">color
|
||||
<span className="absolute left-0 right-0 bottom-1 -z-10 rounded-lg" style={{height: 14, background: "var(--yellow)"}}/>
|
||||
</span> with friends
|
||||
</h1>
|
||||
<p className="text-lg leading-snug max-w-[480px] mb-8 font-medium">
|
||||
Three games, one playful canvas. Race to guess in Skribbl, pass silly drawings in Gartic Phone, or chill out coloring together. No installs. Just doodle.
|
||||
</p>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<Link href="/create" className="btn btn-primary" data-testid="create-room-cta" style={{padding: "18px 32px", fontSize: 18}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Create Room
|
||||
</Link>
|
||||
<Link href="/join" className="btn btn-secondary" data-testid="join-room-cta" style={{padding: "18px 32px", fontSize: 18}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg>
|
||||
Join Room
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[420px] max-[900px]:min-h-[320px] max-[900px]:max-w-[420px] max-[900px]:mx-auto max-[900px]:w-full">
|
||||
<div className="absolute inset-0 bg-white border-[3px] border-dark rounded-[28px] shadow-card p-6 flex flex-col gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<span className="w-3 h-3 rounded-full bg-coral"/>
|
||||
<span className="w-3 h-3 rounded-full bg-yellow"/>
|
||||
<span className="w-3 h-3 rounded-full bg-mint"/>
|
||||
</div>
|
||||
<div className="flex-1 grid place-items-center">
|
||||
<svg width="100%" height="100%" viewBox="0 0 320 280" preserveAspectRatio="xMidYMid meet">
|
||||
<ellipse cx="160" cy="200" rx="90" ry="50" fill="#FFD23F" stroke="#2D2D2D" strokeWidth="4"/>
|
||||
<circle cx="160" cy="130" r="65" fill="#FFD23F" stroke="#2D2D2D" strokeWidth="4"/>
|
||||
<polygon points="105,90 95,40 145,80" fill="#FFD23F" stroke="#2D2D2D" strokeWidth="4" strokeLinejoin="round"/>
|
||||
<polygon points="215,90 225,40 175,80" fill="#FFD23F" stroke="#2D2D2D" strokeWidth="4" strokeLinejoin="round"/>
|
||||
<circle cx="138" cy="125" r="8" fill="#2D2D2D"/>
|
||||
<circle cx="182" cy="125" r="8" fill="#2D2D2D"/>
|
||||
<circle cx="141" cy="122" r="3" fill="white"/>
|
||||
<circle cx="185" cy="122" r="3" fill="white"/>
|
||||
<path d="M150 150 Q160 158 170 150" fill="#FF5C5C" stroke="#2D2D2D" strokeWidth="3" strokeLinecap="round"/>
|
||||
<path d="M150 155 Q145 170 155 175" stroke="#2D2D2D" strokeWidth="3" fill="none" strokeLinecap="round"/>
|
||||
<path d="M170 155 Q175 170 165 175" stroke="#2D2D2D" strokeWidth="3" fill="none" strokeLinecap="round"/>
|
||||
<line x1="105" y1="135" x2="80" y2="130" stroke="#2D2D2D" strokeWidth="3" strokeLinecap="round"/>
|
||||
<line x1="105" y1="142" x2="80" y2="145" stroke="#2D2D2D" strokeWidth="3" strokeLinecap="round"/>
|
||||
<line x1="215" y1="135" x2="240" y2="130" stroke="#2D2D2D" strokeWidth="3" strokeLinecap="round"/>
|
||||
<line x1="215" y1="142" x2="240" y2="145" stroke="#2D2D2D" strokeWidth="3" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-3.5 -left-2 bg-yellow border-[3px] border-dark rounded-full px-4 py-2 font-bold text-sm shadow-card flex items-center gap-2 animate-float">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><path d="M5 12l5 5L20 7"/></svg>
|
||||
Mia guessed it!
|
||||
</div>
|
||||
<div className="absolute top-[30%] -right-7 bg-lavender text-white border-[3px] border-dark rounded-full px-4 py-2 font-bold text-sm shadow-card animate-float" style={{animationDelay: "0.8s"}}>+120 pts</div>
|
||||
<div className="absolute bottom-3 -left-5 bg-sky border-[3px] border-dark rounded-full px-4 py-2 font-bold text-sm shadow-card flex items-center gap-2 animate-float" style={{animationDelay: "1.6s"}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
47s
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-[1280px] mx-auto px-8 pb-20 max-[480px]:px-4" id="modes">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="font-bold" style={{fontSize: "clamp(28px, 4vw, 44px)"}}>Three ways to play</h2>
|
||||
<p className="text-lg font-medium mt-2.5" style={{color: "rgba(45,45,45,0.7)"}}>Pick a mode, invite your crew, get drawing</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-6 max-[900px]:grid-cols-1">
|
||||
<article className="panel relative overflow-hidden flex flex-col gap-4 hover:-translate-y-1.5 transition-transform" data-testid="mode-card-skribbl">
|
||||
<div className="absolute top-4 -right-7 rotate-[35deg] bg-yellow px-8 py-1 text-xs font-bold border-y-2 border-dark">CLASSIC</div>
|
||||
<div className="w-[72px] h-[72px] rounded-[20px] grid place-items-center border-[3px] border-dark shadow-chunky bg-coral">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</div>
|
||||
<span className="self-start text-xs font-semibold px-3 py-1.5 bg-cream rounded-full">2-12 players</span>
|
||||
<h3 className="text-2xl font-bold">Skribbl Race</h3>
|
||||
<p className="text-base leading-snug font-medium" style={{color: "rgba(45,45,45,0.75)"}}>One player draws a secret word, everyone else races to guess. Fast, chaotic, hilarious.</p>
|
||||
</article>
|
||||
|
||||
<article className="panel relative overflow-hidden flex flex-col gap-4 hover:-translate-y-1.5 transition-transform" data-testid="mode-card-gartic">
|
||||
<div className="w-[72px] h-[72px] rounded-[20px] grid place-items-center border-[3px] border-dark shadow-chunky bg-mint">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/></svg>
|
||||
</div>
|
||||
<span className="self-start text-xs font-semibold px-3 py-1.5 bg-cream rounded-full">4-10 players</span>
|
||||
<h3 className="text-2xl font-bold">Gartic Phone</h3>
|
||||
<p className="text-base leading-snug font-medium" style={{color: "rgba(45,45,45,0.75)"}}>Telephone-game with drawings. Write a prompt, draw what you got, guess what they drew.</p>
|
||||
</article>
|
||||
|
||||
<article className="panel relative overflow-hidden flex flex-col gap-4 hover:-translate-y-1.5 transition-transform" data-testid="mode-card-color">
|
||||
<div className="absolute top-4 -right-7 rotate-[35deg] bg-yellow px-8 py-1 text-xs font-bold border-y-2 border-dark">CHILL</div>
|
||||
<div className="w-[72px] h-[72px] rounded-[20px] grid place-items-center border-[3px] border-dark shadow-chunky bg-lavender">
|
||||
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="10.5" r="2.5"/><circle cx="8.5" cy="7.5" r="2.5"/><circle cx="6.5" cy="12.5" r="2.5"/><path d="M12 22a10 10 0 1 1 10-10c0 4-3 4-4 4h-3a2 2 0 0 0-1 4 2 2 0 0 1-1 4z"/></svg>
|
||||
</div>
|
||||
<span className="self-start text-xs font-semibold px-3 py-1.5 bg-cream rounded-full">2-6 players</span>
|
||||
<h3 className="text-2xl font-bold">Color Together</h3>
|
||||
<p className="text-base leading-snug font-medium" style={{color: "rgba(45,45,45,0.75)"}}>A shared coloring book. Pick a canvas, fill it in together. Snapshot & save your art.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="bg-dark text-cream py-9 px-8" style={{borderTopLeftRadius: 32, borderTopRightRadius: 32}}>
|
||||
<div className="max-w-[1280px] mx-auto flex flex-wrap justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-2.5 font-bold text-cream">
|
||||
<span className="logo-mark sm" style={{background: "var(--yellow)"}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#2D2D2D" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</div>
|
||||
<nav className="flex gap-6">
|
||||
<a href="#" className="text-cream font-medium opacity-80 hover:opacity-100">About</a>
|
||||
<a href="#" className="text-cream font-medium opacity-80 hover:opacity-100">Privacy</a>
|
||||
<a href="#" className="text-cream font-medium opacity-80 hover:opacity-100">GitHub</a>
|
||||
</nav>
|
||||
<div className="text-sm opacity-60">© 2026 DrawTogether</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import RoomConnector from "../../components/RoomConnector";
|
||||
import PlayerList from "../../components/PlayerList";
|
||||
import ChatBox from "../../components/ChatBox";
|
||||
import { getSocket } from "../../lib/socket-client";
|
||||
import { useGame } from "../../lib/store";
|
||||
|
||||
export default function LobbyPage() {
|
||||
const params = useParams<{ code: string }>();
|
||||
const code = String(params.code || "").toUpperCase();
|
||||
const router = useRouter();
|
||||
const room = useGame((s) => s.room);
|
||||
const myId = useGame((s) => s.myId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (room && room.phase === "playing") router.push(`/room/${code}/play`);
|
||||
if (room && room.phase === "results") router.push(`/room/${code}/results`);
|
||||
}, [room?.phase, code, router]);
|
||||
|
||||
const isHost = room && myId && room.hostId === myId;
|
||||
const shareUrl = typeof window !== "undefined" ? `${window.location.origin}/join?code=${code}` : "";
|
||||
|
||||
const start = () => {
|
||||
setErr("");
|
||||
getSocket().emit("game:start", null, (resp: any) => {
|
||||
if (!resp?.ok) setErr(resp?.error || "could not start");
|
||||
});
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(shareUrl); setCopied(true); setTimeout(()=>setCopied(false), 1500); } catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RoomConnector code={code} />
|
||||
<header className="flex items-center justify-between max-w-[1400px] mx-auto px-7 py-4">
|
||||
<Link href="/" className="flex items-center gap-2.5 font-bold text-2xl no-underline text-dark">
|
||||
<span className="logo-mark sm">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</Link>
|
||||
<div className="pill" data-testid="room-pill">
|
||||
<span className="w-2 h-2 rounded-full bg-mint"/> Room <strong>{code}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-[1400px] mx-auto px-7 pb-20 grid gap-6 max-[900px]:grid-cols-1" style={{gridTemplateColumns: "1fr 1.2fr"}}>
|
||||
<section className="panel" data-testid="panel-players">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-bold text-lg flex items-center gap-2.5">
|
||||
<span className="w-8 h-8 rounded-lg border-2 border-dark grid place-items-center" style={{background:"var(--mint)"}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</span>
|
||||
Players
|
||||
</h2>
|
||||
<span className="pill" style={{padding:"4px 10px", fontSize:12}}>{room?.players.length || 0} / 12</span>
|
||||
</div>
|
||||
<PlayerList />
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="panel">
|
||||
<h2 className="font-bold text-lg mb-3">Invite friends</h2>
|
||||
<div className="flex gap-2.5 items-center p-2 pl-4 rounded-2xl border-[3px] border-dark" style={{background:"var(--cream)"}}>
|
||||
<span className="flex-1 font-semibold text-sm overflow-hidden text-ellipsis whitespace-nowrap min-w-0" data-testid="share-url">{shareUrl}</span>
|
||||
<button onClick={copy} data-testid="copy-link" className="px-3.5 py-2 rounded-xl font-bold text-xs text-white border-2 border-dark shadow-chunky flex items-center gap-1.5" style={{background:"var(--coral)"}}>
|
||||
{copied ? "Copied!" : (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center p-4 mt-3 rounded-2xl border-[3px] border-dashed border-dark" style={{background:"var(--cream)"}}>
|
||||
<div className="text-xs font-bold uppercase tracking-widest" style={{color:"rgba(45,45,45,0.6)"}}>Room code</div>
|
||||
<div className="text-3xl font-bold tracking-[8px] mt-1" data-testid="room-code">{code}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2 className="font-bold text-lg mb-3">Game settings</h2>
|
||||
<div className="text-sm font-medium" style={{color:"rgba(45,45,45,0.7)"}}>
|
||||
Mode: <strong className="text-dark uppercase">{room?.mode || "—"}</strong>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-3 max-[640px]:grid-cols-1">
|
||||
{room?.mode === "skribbl" && <>
|
||||
<SettingTile label="Rounds" value={String(room?.settings?.rounds || 4)}/>
|
||||
<SettingTile label="Draw Time" value={`${room?.settings?.drawTimeSec || 80}s`}/>
|
||||
</>}
|
||||
{room?.mode === "gartic" && <>
|
||||
<SettingTile label="Players" value={String(room?.players.length || 0)}/>
|
||||
<SettingTile label="Time / round" value="60s"/>
|
||||
</>}
|
||||
{room?.mode === "color" && <>
|
||||
<SettingTile label="Canvas" value={room?.settings?.canvasType || "blank"}/>
|
||||
{room?.settings?.canvasType === "template" && <SettingTile label="Template" value={room?.settings?.templateId || "mandala"}/>}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{isHost ? (
|
||||
<button onClick={start} data-testid="start-game" className="btn btn-primary w-full mt-4" style={{padding:"16px",fontSize:18}}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>
|
||||
Start Game
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-center mt-4 font-semibold text-sm" style={{color:"rgba(45,45,45,0.6)"}}>Waiting for host to start...</div>
|
||||
)}
|
||||
{err && <div className="text-coral font-bold text-sm mt-2 text-center">{err}</div>}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2 className="font-bold text-lg mb-3">Lobby chat</h2>
|
||||
<ChatBox placeholder="Say hi to your crew..."/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl p-3 px-3.5 border-2" style={{background:"var(--cream)", borderColor:"rgba(45,45,45,0.1)"}}>
|
||||
<div className="text-xs font-semibold uppercase" style={{color:"rgba(45,45,45,0.6)"}}>{label}</div>
|
||||
<div className="font-bold text-base capitalize">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import RoomConnector from "../../../components/RoomConnector";
|
||||
import SkribblGame from "../../../components/SkribblGame";
|
||||
import GarticGame from "../../../components/GarticGame";
|
||||
import ColorGame from "../../../components/ColorGame";
|
||||
import { useGame } from "../../../lib/store";
|
||||
|
||||
export default function PlayPage() {
|
||||
const params = useParams<{ code: string }>();
|
||||
const code = String(params.code || "").toUpperCase();
|
||||
const room = useGame((s) => s.room);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (room && room.phase === "lobby") router.push(`/room/${code}`);
|
||||
if (room && room.phase === "results") router.push(`/room/${code}/results`);
|
||||
}, [room?.phase, code, router]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RoomConnector code={code} />
|
||||
<header className="flex items-center justify-between max-w-[1500px] mx-auto px-5 py-3.5">
|
||||
<Link href="/" className="flex items-center gap-2.5 font-bold text-xl no-underline text-dark">
|
||||
<span className="logo-mark sm">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</Link>
|
||||
<div className="flex gap-2.5 items-center">
|
||||
<span className="pill" data-testid="room-pill">Room <strong className="ml-1">{code}</strong></span>
|
||||
<Link href={`/room/${code}`} className="pill" style={{cursor:"pointer"}}>Lobby</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-[1500px] mx-auto px-5 pb-10">
|
||||
{!room && <div className="panel text-center py-10 font-bold">Connecting to room...</div>}
|
||||
{room?.mode === "skribbl" && <SkribblGame />}
|
||||
{room?.mode === "gartic" && <GarticGame />}
|
||||
{room?.mode === "color" && <ColorGame />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import RoomConnector from "../../../components/RoomConnector";
|
||||
import { getSocket } from "../../../lib/socket-client";
|
||||
import { useGame } from "../../../lib/store";
|
||||
|
||||
export default function ResultsPage() {
|
||||
const params = useParams<{ code: string }>();
|
||||
const code = String(params.code || "").toUpperCase();
|
||||
const room = useGame((s) => s.room);
|
||||
const [books, setBooks] = useState<any[] | null>(null);
|
||||
const [finalScores, setFinalScores] = useState<any[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onBook = (d: any) => setBooks(d.books);
|
||||
const onScores = (d: any) => setFinalScores(d.finalScores);
|
||||
socket.on("gartic:bookComplete", onBook);
|
||||
socket.on("skribbl:gameOver", onScores);
|
||||
return () => {
|
||||
socket.off("gartic:bookComplete", onBook);
|
||||
socket.off("skribbl:gameOver", onScores);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sorted = (finalScores || room?.players?.map(p => ({id:p.id,name:p.nickname,avatar:p.avatar,score:p.score})) || [])
|
||||
.slice().sort((a:any,b:any)=> (b.score||0)-(a.score||0));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RoomConnector code={code} />
|
||||
<header className="flex items-center justify-between max-w-[1280px] mx-auto px-7 py-4">
|
||||
<Link href="/" className="flex items-center gap-2.5 font-bold text-2xl no-underline text-dark">
|
||||
<span className="logo-mark sm">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
||||
</span>
|
||||
DrawTogether
|
||||
</Link>
|
||||
<Link href={`/room/${code}`} className="pill">Back to lobby</Link>
|
||||
</header>
|
||||
|
||||
<main className="max-w-[1100px] mx-auto px-6 pb-16">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-5xl font-bold">Game over!</h1>
|
||||
<p className="font-medium mt-2 text-lg" style={{color:"rgba(45,45,45,0.6)"}}>Great drawings — let's see how it went</p>
|
||||
</div>
|
||||
|
||||
{(room?.mode === "skribbl" || room?.mode === "color") && (
|
||||
<section className="panel mb-6">
|
||||
<h2 className="font-bold text-2xl mb-4">Final scores</h2>
|
||||
<div className="flex flex-col gap-3" data-testid="scoreboard-final">
|
||||
{sorted.map((p:any, i:number) => (
|
||||
<div key={p.id} className="flex items-center gap-4 p-3 px-4 rounded-2xl border-2 border-dark" style={{background: i===0 ? "var(--yellow)" : "var(--cream)"}}>
|
||||
<div className="w-9 h-9 rounded-full grid place-items-center font-bold text-sm border-2 border-dark" style={{background: i===0 ? "var(--coral)" : "white", color: i===0 ? "white" : "var(--dark)"}}>#{i+1}</div>
|
||||
<div className="text-2xl">{p.avatar || "🎨"}</div>
|
||||
<div className="flex-1 font-bold">{p.name}</div>
|
||||
<div className="font-bold tabular-nums">{p.score || 0} pts</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{room?.mode === "gartic" && (
|
||||
<section className="panel">
|
||||
<h2 className="font-bold text-2xl mb-4">The book of mayhem</h2>
|
||||
{!books && <div className="font-medium" style={{color:"rgba(45,45,45,0.6)"}}>(Books will appear here once everyone is done)</div>}
|
||||
{books && books.map((book:any, bi:number) => (
|
||||
<div key={bi} className="mb-6 p-4 rounded-2xl border-2 border-dark" style={{background:"var(--cream)"}}>
|
||||
<div className="font-bold text-lg mb-3">📖 {book.ownerName}'s book</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{book.pages.map((pg:any, pi:number) => (
|
||||
<div key={pi} className="rounded-xl p-3 bg-white border-2 border-dark">
|
||||
<div className="text-xs font-bold uppercase" style={{color:"rgba(45,45,45,0.5)"}}>{pg.authorName} · {pg.type}</div>
|
||||
{pg.type === "drawing" && pg.content
|
||||
? <img src={pg.content} alt="drawing" className="mt-1 max-h-[260px] mx-auto"/>
|
||||
: <div className="mt-1 font-medium">{pg.content || "..."}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<Link href="/create" className="btn btn-primary inline-flex" style={{padding:"14px 28px"}}>Play again</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user