feat: Next.js + Socket.IO 3-mode game (Skribbl, Gartic, Color)

This commit is contained in:
PM
2026-05-01 20:12:36 +00:00
parent b02976c10b
commit 2a40097fad
47 changed files with 7907 additions and 0 deletions
+12
View File
@@ -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(),
});
}
+31
View File
@@ -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 });
}
+56
View File
@@ -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>
);
}
+301
View File
@@ -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,
};
+196
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+75
View File
@@ -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;
}
+278
View File
@@ -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>
);
}
+158
View File
@@ -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
View File
@@ -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; }
+95
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+26
View File
@@ -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;
}
}
+44
View File
@@ -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 {}
}
+49
View File
@@ -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;
};
};
+23
View File
@@ -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
View File
@@ -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 &amp; <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 &amp; 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>
);
}
+136
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+94
View File
@@ -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>
);
}