feat: Next.js + Socket.IO 3-mode game (Skribbl, Gartic, Color)
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
designs
|
||||
test-results
|
||||
visual-test-runner.js
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
README.md
|
||||
.claude
|
||||
.DS_Store
|
||||
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
SIGNOZ_OTEL_ENDPOINT=http://100.64.0.10:4318
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.vercel
|
||||
@@ -0,0 +1,34 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- deps ----
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi
|
||||
|
||||
# ---- builder ----
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# ---- runner ----
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/package-lock.json* ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/server.js ./server.js
|
||||
COPY --from=builder /app/tracing.js ./tracing.js
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY --from=builder /app/app/lib/words ./app/lib/words
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# DrawTogether
|
||||
|
||||
Three-mode web game built with **Next.js 14 (App Router) + Socket.IO**:
|
||||
|
||||
- **Skribbl Race** — one player draws, others guess.
|
||||
- **Gartic Phone** — pass-it-on prompt → drawing → guess loop.
|
||||
- **Color Together** — shared coloring book with templates and free draw.
|
||||
|
||||
## Stack
|
||||
- Next.js 14, React 18, TypeScript
|
||||
- Tailwind CSS
|
||||
- Custom Node server (`server.js`) hosting Next + Socket.IO on a single port
|
||||
- OpenTelemetry auto-instrumentation (traces) → SigNoz OTLP HTTP
|
||||
|
||||
## Run locally
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm start # serves on :3000
|
||||
```
|
||||
|
||||
Dev mode: `npm run dev` (also via `server.js`).
|
||||
|
||||
## Environment
|
||||
| var | default | what |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | `3000` | http port |
|
||||
| `SIGNOZ_OTEL_ENDPOINT` | `http://100.64.0.10:4318` | OTLP base for tracing |
|
||||
| `NODE_ENV` | `production` in container | |
|
||||
|
||||
## Endpoints
|
||||
- `GET /` — landing
|
||||
- `GET /create` — create-room form
|
||||
- `GET /join` — join form
|
||||
- `GET /room/[code]` — lobby (auto-redirects to `/play` when host starts)
|
||||
- `GET /room/[code]/play` — game UI (skribbl/gartic/color)
|
||||
- `GET /room/[code]/results` — final scores / Gartic books
|
||||
- `GET /api/health` — health check
|
||||
- `GET /api/room/[code]/exists` — quick room lookup
|
||||
- WebSocket on the same port at `/socket.io`
|
||||
|
||||
## Docker
|
||||
```bash
|
||||
docker build -t drawtogether .
|
||||
docker run -p 3000:3000 drawtogether
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
experimental: {
|
||||
instrumentationHook: false,
|
||||
},
|
||||
webpack: (config) => {
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "skribbl-gartic-color",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node server.js",
|
||||
"build": "next build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.54.0",
|
||||
"@opentelemetry/sdk-node": "^0.54.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"next": "14.2.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"socket.io": "^4.7.5",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="body"><ellipse cx="200" cy="150" rx="14" ry="60" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="wing1"><ellipse cx="135" cy="120" rx="60" ry="50" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="wing2"><ellipse cx="265" cy="120" rx="60" ry="50" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="wing3"><ellipse cx="135" cy="190" rx="50" ry="40" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="wing4"><ellipse cx="265" cy="190" rx="50" ry="40" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 758 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="body"><ellipse cx="200" cy="220" rx="110" ry="60" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="head"><circle cx="200" cy="140" r="80" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="ear1"><polygon points="135,90 125,40 175,80" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="ear2"><polygon points="265,90 275,40 225,80" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 615 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="body"><ellipse cx="180" cy="150" rx="110" ry="60" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="tail"><polygon points="280,150 350,100 350,200" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="stem"><rect x="195" y="180" width="10" height="100" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="3"/></g>
|
||||
<g data-region="pet0"><ellipse cx="200" cy="90" rx="34" ry="24" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="pet1"><ellipse cx="247" cy="125" rx="34" ry="24" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="pet2"><ellipse cx="230" cy="170" rx="34" ry="24" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="pet3"><ellipse cx="170" cy="170" rx="34" ry="24" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="pet4"><ellipse cx="153" cy="125" rx="34" ry="24" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="center"><circle cx="200" cy="140" r="22" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 992 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="walls"><rect x="120" y="140" width="160" height="120" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="roof"><polygon points="100,140 200,60 300,140" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="door"><rect x="180" y="200" width="40" height="60" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="win1"><rect x="135" y="160" width="30" height="30" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="win2"><rect x="235" y="160" width="30" height="30" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="c1"><circle cx="200" cy="150" r="120" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="3"/></g>
|
||||
<g data-region="c2"><circle cx="200" cy="150" r="80" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="3"/></g>
|
||||
<g data-region="c3"><circle cx="200" cy="150" r="40" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="3"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="sun"><circle cx="200" cy="150" r="60" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 255 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
|
||||
<g data-region="bg"><rect width="400" height="300" fill="#FFFFFF"/></g>
|
||||
<g data-region="trunk"><rect x="180" y="180" width="40" height="100" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="leaves"><circle cx="200" cy="140" r="80" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="leaves2"><circle cx="160" cy="100" r="40" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
<g data-region="leaves3"><circle cx="240" cy="100" r="40" fill="#FFF8E7" stroke="#2D2D2D" stroke-width="4"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1,45 @@
|
||||
// Initialise tracing FIRST so auto-instrumentation can patch http/etc.
|
||||
require("./tracing");
|
||||
|
||||
const http = require("http");
|
||||
const next = require("next");
|
||||
const { Server } = require("socket.io");
|
||||
const { parse } = require("url");
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const hostname = "0.0.0.0";
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
|
||||
const app = next({ dev, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error("Error handling request:", err);
|
||||
res.statusCode = 500;
|
||||
res.end("internal error");
|
||||
}
|
||||
});
|
||||
|
||||
const io = new Server(server, {
|
||||
cors: { origin: true, credentials: true },
|
||||
path: "/socket.io",
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
// Register socket handlers (compiled JS at runtime via require)
|
||||
const { registerHandlers } = require("./server/socket-handlers.js");
|
||||
registerHandlers(io);
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
console.log(`Server ready on :${port}`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
// Pure game state machine — no socket dependencies. Functions take the room object
|
||||
// and return mutations + a list of side-effect descriptors that the socket layer
|
||||
// (socket-handlers.js) will translate into io.emit calls.
|
||||
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { makeId } = require("./room-store.js");
|
||||
const {
|
||||
normalize,
|
||||
maskWord,
|
||||
pickRevealIndex,
|
||||
isCloseGuess,
|
||||
} = require("./word-utils.js");
|
||||
|
||||
const WORDS_PATH = path.join(__dirname, "..", "app", "lib", "words", "en.json");
|
||||
let WORDS = [];
|
||||
try {
|
||||
WORDS = JSON.parse(fs.readFileSync(WORDS_PATH, "utf8"));
|
||||
} catch (e) {
|
||||
console.warn("[words] could not read en.json:", e.message);
|
||||
WORDS = ["apple", "banana", "rocket", "robot", "tree"];
|
||||
}
|
||||
|
||||
function pickRandomWords(n = 3) {
|
||||
const out = new Set();
|
||||
while (out.size < n) {
|
||||
out.add(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function makePlayer({ nickname, avatar }) {
|
||||
return {
|
||||
id: makeId(),
|
||||
sessionToken: makeId(),
|
||||
nickname: String(nickname || "Player").slice(0, 20),
|
||||
avatar: String(avatar || "🐱"),
|
||||
score: 0,
|
||||
connected: true,
|
||||
socketId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRoom({ code, hostId, mode, settings }) {
|
||||
return {
|
||||
code,
|
||||
hostId,
|
||||
mode, // 'skribbl' | 'gartic' | 'color'
|
||||
settings: settings || {},
|
||||
players: [],
|
||||
chat: [], // recent messages
|
||||
phase: "lobby", // lobby | playing | results
|
||||
lastActiveAt: Date.now(),
|
||||
// mode-specific state
|
||||
skribbl: null,
|
||||
gartic: null,
|
||||
color: null,
|
||||
timers: {},
|
||||
};
|
||||
}
|
||||
|
||||
function publicRoomState(room) {
|
||||
return {
|
||||
code: room.code,
|
||||
hostId: room.hostId,
|
||||
mode: room.mode,
|
||||
settings: room.settings,
|
||||
phase: room.phase,
|
||||
players: room.players.map((p) => ({
|
||||
id: p.id,
|
||||
nickname: p.nickname,
|
||||
avatar: p.avatar,
|
||||
score: p.score,
|
||||
connected: p.connected,
|
||||
isHost: p.id === room.hostId,
|
||||
})),
|
||||
skribbl: room.skribbl
|
||||
? {
|
||||
roundIndex: room.skribbl.roundIndex,
|
||||
totalRounds: room.skribbl.totalRounds,
|
||||
drawerId: room.skribbl.drawerId,
|
||||
wordMask: room.skribbl.wordMask,
|
||||
wordLength: room.skribbl.word ? room.skribbl.word.length : 0,
|
||||
phase: room.skribbl.phase, // 'choosing' | 'drawing' | 'between'
|
||||
endsAt: room.skribbl.endsAt,
|
||||
solvedIds: [...(room.skribbl.solvedIds || [])],
|
||||
}
|
||||
: null,
|
||||
gartic: room.gartic
|
||||
? {
|
||||
turnIndex: room.gartic.turnIndex,
|
||||
totalTurns: room.gartic.totalTurns,
|
||||
phase: room.gartic.phase,
|
||||
endsAt: room.gartic.endsAt,
|
||||
submittedIds: [...(room.gartic.submittedIds || [])],
|
||||
}
|
||||
: null,
|
||||
color: room.color
|
||||
? {
|
||||
canvasType: room.color.canvasType,
|
||||
templateId: room.color.templateId,
|
||||
strokeCount: room.color.strokes.length,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Skribbl ----------------------------------------------------------------
|
||||
|
||||
function skribblInit(room) {
|
||||
const rounds = Number(room.settings.rounds) || 4;
|
||||
const drawTimeSec = Number(room.settings.drawTimeSec) || 80;
|
||||
room.skribbl = {
|
||||
roundIndex: 0,
|
||||
totalRounds: rounds * room.players.length,
|
||||
drawerOrder: room.players.map((p) => p.id),
|
||||
drawerCursor: 0,
|
||||
drawerId: null,
|
||||
word: null,
|
||||
wordChoices: null,
|
||||
wordMask: null,
|
||||
revealed: new Set(),
|
||||
drawTimeSec,
|
||||
endsAt: 0,
|
||||
phase: "between",
|
||||
solvedIds: new Set(),
|
||||
solveOrder: [],
|
||||
};
|
||||
}
|
||||
|
||||
function skribblNextRound(room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return { done: true };
|
||||
if (sk.roundIndex >= sk.totalRounds) return { done: true };
|
||||
// pick next drawer that is connected
|
||||
let attempts = 0;
|
||||
while (attempts < sk.drawerOrder.length) {
|
||||
const candidate = sk.drawerOrder[sk.drawerCursor % sk.drawerOrder.length];
|
||||
sk.drawerCursor++;
|
||||
const p = room.players.find((pl) => pl.id === candidate);
|
||||
if (p && p.connected) {
|
||||
sk.drawerId = p.id;
|
||||
sk.phase = "choosing";
|
||||
sk.word = null;
|
||||
sk.wordChoices = pickRandomWords(3);
|
||||
sk.wordMask = null;
|
||||
sk.revealed = new Set();
|
||||
sk.solvedIds = new Set();
|
||||
sk.solveOrder = [];
|
||||
sk.endsAt = Date.now() + 15000;
|
||||
sk.roundIndex++;
|
||||
return { ok: true, drawer: p, choices: sk.wordChoices };
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
return { done: true };
|
||||
}
|
||||
|
||||
function skribblPickWord(room, drawerId, word) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return { ok: false, error: "no game" };
|
||||
if (sk.drawerId !== drawerId) return { ok: false, error: "not drawer" };
|
||||
if (sk.phase !== "choosing") return { ok: false, error: "not choosing" };
|
||||
if (!sk.wordChoices.includes(word)) return { ok: false, error: "bad word" };
|
||||
sk.word = word;
|
||||
sk.wordMask = maskWord(word, new Set());
|
||||
sk.revealed = new Set();
|
||||
sk.phase = "drawing";
|
||||
sk.endsAt = Date.now() + sk.drawTimeSec * 1000;
|
||||
return { ok: true, drawer: sk.drawerId, word, mask: sk.wordMask, endsAt: sk.endsAt };
|
||||
}
|
||||
|
||||
function skribblHandleGuess(room, fromId, text) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk || sk.phase !== "drawing" || !sk.word) {
|
||||
return { kind: "chat" };
|
||||
}
|
||||
if (fromId === sk.drawerId) return { kind: "chat" };
|
||||
if (sk.solvedIds.has(fromId)) return { kind: "chat" };
|
||||
const guess = normalize(text);
|
||||
const target = normalize(sk.word);
|
||||
if (guess === target) {
|
||||
sk.solvedIds.add(fromId);
|
||||
sk.solveOrder.push(fromId);
|
||||
// award points
|
||||
const total = sk.drawTimeSec * 1000;
|
||||
const left = Math.max(0, sk.endsAt - Date.now());
|
||||
const factor = left / total;
|
||||
const points = Math.floor(50 + factor * 100);
|
||||
const player = room.players.find((p) => p.id === fromId);
|
||||
if (player) player.score += points;
|
||||
// drawer bonus, +25 per guesser
|
||||
const drawer = room.players.find((p) => p.id === sk.drawerId);
|
||||
if (drawer) drawer.score += 25;
|
||||
return { kind: "correct", points };
|
||||
}
|
||||
if (isCloseGuess(text, sk.word)) {
|
||||
return { kind: "close" };
|
||||
}
|
||||
return { kind: "chat" };
|
||||
}
|
||||
|
||||
function skribblShouldEndRound(room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk || sk.phase !== "drawing") return false;
|
||||
// all non-drawer connected players solved?
|
||||
const guessers = room.players.filter(
|
||||
(p) => p.id !== sk.drawerId && p.connected
|
||||
);
|
||||
if (guessers.length === 0) return false;
|
||||
return guessers.every((p) => sk.solvedIds.has(p.id));
|
||||
}
|
||||
|
||||
function skribblRevealHint(room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk || !sk.word) return null;
|
||||
const idx = pickRevealIndex(sk.word, sk.revealed);
|
||||
if (idx == null) return null;
|
||||
sk.revealed.add(idx);
|
||||
sk.wordMask = maskWord(sk.word, sk.revealed);
|
||||
return sk.wordMask;
|
||||
}
|
||||
|
||||
// ---- Gartic ----------------------------------------------------------------
|
||||
|
||||
function garticInit(room) {
|
||||
const players = room.players.filter((p) => p.connected);
|
||||
const totalTurns = players.length; // each book goes around once
|
||||
const books = players.map((p) => ({
|
||||
ownerId: p.id,
|
||||
ownerName: p.nickname,
|
||||
pages: [], // [{type, content, authorId, authorName}]
|
||||
}));
|
||||
room.gartic = {
|
||||
players: players.map((p) => p.id),
|
||||
books,
|
||||
turnIndex: 0,
|
||||
totalTurns,
|
||||
phase: "prompt", // prompt | drawing | guess | done
|
||||
submittedIds: new Set(),
|
||||
endsAt: 0,
|
||||
durationMs: 60000,
|
||||
};
|
||||
}
|
||||
|
||||
function garticAssignmentForPlayer(gartic, playerId) {
|
||||
// book index for this player on this turn = (originalIndex + turnIndex) % len
|
||||
const playerIdx = gartic.players.indexOf(playerId);
|
||||
if (playerIdx < 0) return null;
|
||||
const bookIdx = (playerIdx + gartic.turnIndex) % gartic.players.length;
|
||||
return { bookIdx, book: gartic.books[bookIdx] };
|
||||
}
|
||||
|
||||
function garticTaskForTurn(turnIndex) {
|
||||
if (turnIndex === 0) return "prompt";
|
||||
return turnIndex % 2 === 1 ? "drawing" : "guess";
|
||||
}
|
||||
|
||||
function garticAdvanceTurn(room) {
|
||||
const g = room.gartic;
|
||||
if (!g) return { done: true };
|
||||
// collect submissions: for any player who didn't submit, place a placeholder
|
||||
for (const playerId of g.players) {
|
||||
if (g.submittedIds.has(playerId)) continue;
|
||||
const assign = garticAssignmentForPlayer(g, playerId);
|
||||
if (!assign) continue;
|
||||
const task = garticTaskForTurn(g.turnIndex);
|
||||
assign.book.pages.push({
|
||||
type: task,
|
||||
content: task === "drawing" ? "" : "...",
|
||||
authorId: playerId,
|
||||
authorName: (room.players.find((p) => p.id === playerId) || {}).nickname || "?",
|
||||
});
|
||||
}
|
||||
g.submittedIds = new Set();
|
||||
g.turnIndex++;
|
||||
if (g.turnIndex >= g.totalTurns) {
|
||||
g.phase = "done";
|
||||
return { done: true };
|
||||
}
|
||||
g.phase = garticTaskForTurn(g.turnIndex);
|
||||
g.endsAt = Date.now() + g.durationMs;
|
||||
return { done: false };
|
||||
}
|
||||
|
||||
function garticSubmit(room, playerId, type, data) {
|
||||
const g = room.gartic;
|
||||
if (!g || g.phase === "done") return { ok: false };
|
||||
const expected = garticTaskForTurn(g.turnIndex);
|
||||
if (type !== expected) return { ok: false, error: "wrong type" };
|
||||
if (g.submittedIds.has(playerId)) return { ok: false, error: "already" };
|
||||
const assign = garticAssignmentForPlayer(g, playerId);
|
||||
if (!assign) return { ok: false, error: "no book" };
|
||||
const player = room.players.find((p) => p.id === playerId);
|
||||
assign.book.pages.push({
|
||||
type,
|
||||
content: String(data || ""),
|
||||
authorId: playerId,
|
||||
authorName: player ? player.nickname : "?",
|
||||
});
|
||||
g.submittedIds.add(playerId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function garticAllSubmitted(room) {
|
||||
const g = room.gartic;
|
||||
if (!g) return false;
|
||||
return g.players.every((id) => g.submittedIds.has(id));
|
||||
}
|
||||
|
||||
// ---- Color Together --------------------------------------------------------
|
||||
|
||||
function colorInit(room) {
|
||||
room.color = {
|
||||
canvasType: room.settings.canvasType || "blank",
|
||||
templateId: room.settings.templateId || null,
|
||||
strokes: [],
|
||||
regionFills: {},
|
||||
};
|
||||
}
|
||||
|
||||
function colorAddStroke(room, stroke) {
|
||||
if (!room.color) return;
|
||||
// store last 1500 strokes max
|
||||
room.color.strokes.push(stroke);
|
||||
if (room.color.strokes.length > 1500) {
|
||||
room.color.strokes = room.color.strokes.slice(-1500);
|
||||
}
|
||||
}
|
||||
|
||||
function colorBucket(room, regionId, color) {
|
||||
if (!room.color) return;
|
||||
room.color.regionFills[regionId] = color;
|
||||
}
|
||||
|
||||
function colorReset(room, regionId) {
|
||||
if (!room.color) return;
|
||||
if (regionId) {
|
||||
delete room.color.regionFills[regionId];
|
||||
} else {
|
||||
room.color.strokes = [];
|
||||
room.color.regionFills = {};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WORDS,
|
||||
makeRoom,
|
||||
makePlayer,
|
||||
publicRoomState,
|
||||
// skribbl
|
||||
skribblInit,
|
||||
skribblNextRound,
|
||||
skribblPickWord,
|
||||
skribblHandleGuess,
|
||||
skribblShouldEndRound,
|
||||
skribblRevealHint,
|
||||
// gartic
|
||||
garticInit,
|
||||
garticAssignmentForPlayer,
|
||||
garticTaskForTurn,
|
||||
garticAdvanceTurn,
|
||||
garticSubmit,
|
||||
garticAllSubmitted,
|
||||
// color
|
||||
colorInit,
|
||||
colorAddStroke,
|
||||
colorBucket,
|
||||
colorReset,
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
const { customAlphabet } = require("nanoid");
|
||||
|
||||
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const makeCode = customAlphabet(codeAlphabet, 6);
|
||||
const makeId = customAlphabet(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
21
|
||||
);
|
||||
|
||||
const rooms = new Map();
|
||||
|
||||
function genCode() {
|
||||
let c;
|
||||
do {
|
||||
c = makeCode();
|
||||
} while (rooms.has(c));
|
||||
return c;
|
||||
}
|
||||
|
||||
function getRoom(code) {
|
||||
return rooms.get(String(code || "").toUpperCase());
|
||||
}
|
||||
|
||||
function setRoom(code, room) {
|
||||
rooms.set(code.toUpperCase(), room);
|
||||
}
|
||||
|
||||
function deleteRoom(code) {
|
||||
rooms.delete(String(code || "").toUpperCase());
|
||||
}
|
||||
|
||||
function allRooms() {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
// Idle cleanup — drop rooms with no active sockets older than 60min
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [code, room] of rooms.entries()) {
|
||||
const active = room.players.some((p) => p.connected);
|
||||
if (!active && now - (room.lastActiveAt || 0) > 60 * 60 * 1000) {
|
||||
rooms.delete(code);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
module.exports = { genCode, getRoom, setRoom, deleteRoom, allRooms, makeId };
|
||||
@@ -0,0 +1,457 @@
|
||||
// Wires socket.io events to the game-state machine.
|
||||
const { genCode, getRoom, setRoom, makeId } = require("./room-store.js");
|
||||
const G = require("./game-state.js");
|
||||
const { normalize } = require("./word-utils.js");
|
||||
|
||||
function escape(text) {
|
||||
return String(text || "")
|
||||
.slice(0, 200)
|
||||
.replace(/[<>]/g, (c) => (c === "<" ? "<" : ">"));
|
||||
}
|
||||
|
||||
function broadcastState(io, room) {
|
||||
io.to(room.code).emit("room:state", G.publicRoomState(room));
|
||||
}
|
||||
|
||||
function chatSystem(io, room, text) {
|
||||
const msg = { id: makeId(), fromId: null, fromName: "system", text, kind: "system" };
|
||||
room.chat.push(msg);
|
||||
io.to(room.code).emit("chat:msg", msg);
|
||||
}
|
||||
|
||||
function pushChat(io, room, msg) {
|
||||
room.chat.push(msg);
|
||||
if (room.chat.length > 200) room.chat = room.chat.slice(-200);
|
||||
io.to(room.code).emit("chat:msg", msg);
|
||||
}
|
||||
|
||||
// ---- Skribbl flow control -------------------------------------------------
|
||||
|
||||
function clearTimers(room) {
|
||||
for (const t of Object.values(room.timers || {})) {
|
||||
if (t) clearTimeout(t);
|
||||
}
|
||||
room.timers = {};
|
||||
}
|
||||
|
||||
function startSkribblNext(io, room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return;
|
||||
const r = G.skribblNextRound(room);
|
||||
if (r.done) {
|
||||
room.phase = "results";
|
||||
clearTimers(room);
|
||||
io.to(room.code).emit("skribbl:gameOver", {
|
||||
finalScores: room.players.map((p) => ({ id: p.id, name: p.nickname, avatar: p.avatar, score: p.score })),
|
||||
});
|
||||
broadcastState(io, room);
|
||||
return;
|
||||
}
|
||||
// notify drawer with choices
|
||||
const drawerSocket = io.sockets.sockets.get(r.drawer.socketId);
|
||||
if (drawerSocket) drawerSocket.emit("skribbl:wordChoices", sk.wordChoices);
|
||||
chatSystem(io, room, `${r.drawer.nickname} is choosing a word...`);
|
||||
broadcastState(io, room);
|
||||
|
||||
// 15s pick timer — auto-pick first
|
||||
if (room.timers.pick) clearTimeout(room.timers.pick);
|
||||
room.timers.pick = setTimeout(() => {
|
||||
if (sk.phase === "choosing") {
|
||||
const auto = sk.wordChoices[0];
|
||||
const pick = G.skribblPickWord(room, sk.drawerId, auto);
|
||||
if (pick.ok) startSkribblDrawing(io, room);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function startSkribblDrawing(io, room) {
|
||||
const sk = room.skribbl;
|
||||
io.to(room.code).emit("skribbl:roundStart", {
|
||||
drawerId: sk.drawerId,
|
||||
wordMask: sk.wordMask,
|
||||
durationMs: sk.drawTimeSec * 1000,
|
||||
roundIndex: sk.roundIndex,
|
||||
totalRounds: sk.totalRounds,
|
||||
});
|
||||
broadcastState(io, room);
|
||||
|
||||
// hints at 1/3 and 2/3
|
||||
if (room.timers.hint1) clearTimeout(room.timers.hint1);
|
||||
if (room.timers.hint2) clearTimeout(room.timers.hint2);
|
||||
if (room.timers.end) clearTimeout(room.timers.end);
|
||||
const total = sk.drawTimeSec * 1000;
|
||||
room.timers.hint1 = setTimeout(() => {
|
||||
const m = G.skribblRevealHint(room);
|
||||
if (m) io.to(room.code).emit("skribbl:hint", { wordMask: m });
|
||||
broadcastState(io, room);
|
||||
}, Math.floor(total * 0.4));
|
||||
room.timers.hint2 = setTimeout(() => {
|
||||
const m = G.skribblRevealHint(room);
|
||||
if (m) io.to(room.code).emit("skribbl:hint", { wordMask: m });
|
||||
broadcastState(io, room);
|
||||
}, Math.floor(total * 0.7));
|
||||
room.timers.end = setTimeout(() => endSkribblRound(io, room), total);
|
||||
}
|
||||
|
||||
function endSkribblRound(io, room) {
|
||||
const sk = room.skribbl;
|
||||
if (!sk) return;
|
||||
clearTimers(room);
|
||||
const word = sk.word;
|
||||
sk.phase = "between";
|
||||
io.to(room.code).emit("skribbl:roundEnd", {
|
||||
word,
|
||||
scores: room.players.map((p) => ({ id: p.id, name: p.nickname, score: p.score })),
|
||||
});
|
||||
chatSystem(io, room, `The word was: ${word}`);
|
||||
broadcastState(io, room);
|
||||
// 4-second pause then next
|
||||
room.timers.between = setTimeout(() => startSkribblNext(io, room), 4000);
|
||||
}
|
||||
|
||||
// ---- Gartic flow ---------------------------------------------------------
|
||||
|
||||
function startGarticTurn(io, room) {
|
||||
const g = room.gartic;
|
||||
if (!g) return;
|
||||
if (g.phase === "done") {
|
||||
room.phase = "results";
|
||||
clearTimers(room);
|
||||
io.to(room.code).emit("gartic:bookComplete", { books: g.books });
|
||||
broadcastState(io, room);
|
||||
return;
|
||||
}
|
||||
g.endsAt = Date.now() + g.durationMs;
|
||||
const task = G.garticTaskForTurn(g.turnIndex);
|
||||
// emit per-player task with their assigned book content
|
||||
for (const playerId of g.players) {
|
||||
const player = room.players.find((p) => p.id === playerId);
|
||||
if (!player || !player.connected || !player.socketId) continue;
|
||||
const assign = G.garticAssignmentForPlayer(g, playerId);
|
||||
if (!assign) continue;
|
||||
const lastPage = assign.book.pages[assign.book.pages.length - 1];
|
||||
const content = lastPage ? lastPage.content : "";
|
||||
const sock = io.sockets.sockets.get(player.socketId);
|
||||
if (sock) {
|
||||
sock.emit("gartic:turn", {
|
||||
turnIndex: g.turnIndex,
|
||||
totalTurns: g.totalTurns,
|
||||
task,
|
||||
content,
|
||||
durationMs: g.durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
broadcastState(io, room);
|
||||
if (room.timers.gartic) clearTimeout(room.timers.gartic);
|
||||
room.timers.gartic = setTimeout(() => {
|
||||
G.garticAdvanceTurn(room);
|
||||
startGarticTurn(io, room);
|
||||
}, g.durationMs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findPlayerBySession(token) {
|
||||
if (!token) return null;
|
||||
const { allRooms } = require("./room-store.js");
|
||||
for (const room of allRooms().values()) {
|
||||
const p = room.players.find((pl) => pl.sessionToken === token);
|
||||
if (p) return { room, player: p };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function registerHandlers(io) {
|
||||
io.on("connection", (socket) => {
|
||||
socket.data = { roomCode: null, playerId: null };
|
||||
|
||||
socket.on("room:create", (payload, ack) => {
|
||||
try {
|
||||
const { nickname, avatar, mode, settings } = payload || {};
|
||||
if (!["skribbl", "gartic", "color"].includes(mode)) {
|
||||
return ack && ack({ ok: false, error: "bad mode" });
|
||||
}
|
||||
const code = genCode();
|
||||
const player = G.makePlayer({ nickname, avatar });
|
||||
player.socketId = socket.id;
|
||||
const room = G.makeRoom({ code, hostId: player.id, mode, settings });
|
||||
room.players.push(player);
|
||||
room.lastActiveAt = Date.now();
|
||||
setRoom(code, room);
|
||||
socket.join(code);
|
||||
socket.data.roomCode = code;
|
||||
socket.data.playerId = player.id;
|
||||
ack && ack({ ok: true, code, sessionToken: player.sessionToken, playerId: player.id });
|
||||
chatSystem(io, room, `${player.nickname} created the room`);
|
||||
broadcastState(io, room);
|
||||
} catch (e) {
|
||||
console.error("room:create err", e);
|
||||
ack && ack({ ok: false, error: "server error" });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("room:join", (payload, ack) => {
|
||||
try {
|
||||
const { code, nickname, avatar, sessionToken } = payload || {};
|
||||
const room = getRoom(code);
|
||||
if (!room) return ack && ack({ ok: false, error: "room not found" });
|
||||
// reconnect via session token
|
||||
let player = sessionToken
|
||||
? room.players.find((p) => p.sessionToken === sessionToken)
|
||||
: null;
|
||||
if (player) {
|
||||
player.connected = true;
|
||||
player.socketId = socket.id;
|
||||
if (nickname) player.nickname = String(nickname).slice(0, 20);
|
||||
if (avatar) player.avatar = String(avatar);
|
||||
} else {
|
||||
if (room.players.length >= 12) {
|
||||
return ack && ack({ ok: false, error: "room full" });
|
||||
}
|
||||
player = G.makePlayer({ nickname, avatar });
|
||||
player.socketId = socket.id;
|
||||
room.players.push(player);
|
||||
chatSystem(io, room, `${player.nickname} joined`);
|
||||
}
|
||||
room.lastActiveAt = Date.now();
|
||||
socket.join(room.code);
|
||||
socket.data.roomCode = room.code;
|
||||
socket.data.playerId = player.id;
|
||||
ack && ack({
|
||||
ok: true,
|
||||
code: room.code,
|
||||
sessionToken: player.sessionToken,
|
||||
playerId: player.id,
|
||||
mode: room.mode,
|
||||
});
|
||||
// send chat history
|
||||
for (const m of room.chat.slice(-30)) socket.emit("chat:msg", m);
|
||||
if (room.color) {
|
||||
socket.emit("color:state", {
|
||||
strokes: room.color.strokes,
|
||||
regions: room.color.regionFills,
|
||||
});
|
||||
}
|
||||
broadcastState(io, room);
|
||||
} catch (e) {
|
||||
console.error("room:join err", e);
|
||||
ack && ack({ ok: false, error: "server error" });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("room:leave", () => {
|
||||
const code = socket.data.roomCode;
|
||||
if (!code) return;
|
||||
const room = getRoom(code);
|
||||
if (!room) return;
|
||||
const p = room.players.find((pl) => pl.id === socket.data.playerId);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
p.socketId = null;
|
||||
}
|
||||
socket.leave(code);
|
||||
socket.data.roomCode = null;
|
||||
socket.data.playerId = null;
|
||||
broadcastState(io, room);
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
const code = socket.data.roomCode;
|
||||
if (!code) return;
|
||||
const room = getRoom(code);
|
||||
if (!room) return;
|
||||
const p = room.players.find((pl) => pl.id === socket.data.playerId);
|
||||
if (p) {
|
||||
p.connected = false;
|
||||
p.socketId = null;
|
||||
}
|
||||
room.lastActiveAt = Date.now();
|
||||
broadcastState(io, room);
|
||||
});
|
||||
|
||||
socket.on("chat:send", (payload) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room) return;
|
||||
const player = room.players.find((p) => p.id === socket.data.playerId);
|
||||
if (!player) return;
|
||||
const text = escape(payload && payload.text);
|
||||
if (!text.trim()) return;
|
||||
// skribbl: check guesses
|
||||
if (room.mode === "skribbl" && room.phase === "playing" && room.skribbl) {
|
||||
const result = G.skribblHandleGuess(room, player.id, text);
|
||||
if (result.kind === "correct") {
|
||||
// public message
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text: `guessed the word! +${result.points}`,
|
||||
kind: "correct",
|
||||
});
|
||||
broadcastState(io, room);
|
||||
if (G.skribblShouldEndRound(room)) {
|
||||
endSkribblRound(io, room);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.kind === "close") {
|
||||
// private hint to guesser
|
||||
socket.emit("chat:msg", {
|
||||
id: makeId(),
|
||||
fromId: null,
|
||||
fromName: "system",
|
||||
text: `'${text}' is close!`,
|
||||
kind: "close",
|
||||
});
|
||||
// still broadcast their guess to others
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text,
|
||||
kind: "chat",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// suppress messages from solved guessers? broadcast normal
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text,
|
||||
kind: "chat",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// default chat
|
||||
pushChat(io, room, {
|
||||
id: makeId(),
|
||||
fromId: player.id,
|
||||
fromName: player.nickname,
|
||||
text,
|
||||
kind: "chat",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("game:start", (payload, ack) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room) return ack && ack({ ok: false, error: "no room" });
|
||||
if (room.hostId !== socket.data.playerId)
|
||||
return ack && ack({ ok: false, error: "not host" });
|
||||
if (room.players.filter((p) => p.connected).length < 2 && room.mode !== "color") {
|
||||
return ack && ack({ ok: false, error: "need 2+ players" });
|
||||
}
|
||||
room.phase = "playing";
|
||||
if (room.mode === "skribbl") {
|
||||
G.skribblInit(room);
|
||||
startSkribblNext(io, room);
|
||||
} else if (room.mode === "gartic") {
|
||||
G.garticInit(room);
|
||||
// turn 0 (prompt) — start
|
||||
room.gartic.phase = G.garticTaskForTurn(0);
|
||||
startGarticTurn(io, room);
|
||||
} else if (room.mode === "color") {
|
||||
G.colorInit(room);
|
||||
broadcastState(io, room);
|
||||
}
|
||||
broadcastState(io, room);
|
||||
ack && ack({ ok: true });
|
||||
});
|
||||
|
||||
// Skribbl-specific
|
||||
socket.on("skribbl:pickWord", (payload, ack) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.skribbl) return ack && ack({ ok: false });
|
||||
const r = G.skribblPickWord(
|
||||
room,
|
||||
socket.data.playerId,
|
||||
(payload || {}).word
|
||||
);
|
||||
if (!r.ok) return ack && ack({ ok: false, error: r.error });
|
||||
if (room.timers.pick) clearTimeout(room.timers.pick);
|
||||
startSkribblDrawing(io, room);
|
||||
ack && ack({ ok: true });
|
||||
});
|
||||
|
||||
socket.on("skribbl:stroke", (data) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.skribbl) return;
|
||||
if (room.skribbl.drawerId !== socket.data.playerId) return;
|
||||
socket.to(code).emit("skribbl:stroke", data);
|
||||
});
|
||||
|
||||
socket.on("skribbl:clear", () => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.skribbl) return;
|
||||
if (room.skribbl.drawerId !== socket.data.playerId) return;
|
||||
socket.to(code).emit("skribbl:clear");
|
||||
});
|
||||
|
||||
// Gartic
|
||||
socket.on("gartic:submit", (payload, ack) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.gartic) return ack && ack({ ok: false });
|
||||
const { type, data } = payload || {};
|
||||
const r = G.garticSubmit(room, socket.data.playerId, type, data);
|
||||
if (!r.ok) return ack && ack({ ok: false, error: r.error });
|
||||
ack && ack({ ok: true });
|
||||
broadcastState(io, room);
|
||||
if (G.garticAllSubmitted(room)) {
|
||||
if (room.timers.gartic) clearTimeout(room.timers.gartic);
|
||||
G.garticAdvanceTurn(room);
|
||||
startGarticTurn(io, room);
|
||||
}
|
||||
});
|
||||
|
||||
// Color Together
|
||||
socket.on("color:stroke", (data) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.color) return;
|
||||
G.colorAddStroke(room, data);
|
||||
socket.to(code).emit("color:strokeBroadcast", data);
|
||||
});
|
||||
|
||||
socket.on("color:bucket", (payload) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.color) return;
|
||||
const { regionId, color } = payload || {};
|
||||
if (!regionId || !color) return;
|
||||
G.colorBucket(room, regionId, color);
|
||||
io.to(code).emit("color:bucketBroadcast", { regionId, color });
|
||||
});
|
||||
|
||||
socket.on("color:reset", (payload) => {
|
||||
const code = socket.data.roomCode;
|
||||
const room = code && getRoom(code);
|
||||
if (!room || !room.color) return;
|
||||
G.colorReset(room, (payload || {}).regionId);
|
||||
io.to(code).emit("color:state", {
|
||||
strokes: room.color.strokes,
|
||||
regions: room.color.regionFills,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("presence:cursor", (data) => {
|
||||
const code = socket.data.roomCode;
|
||||
if (!code) return;
|
||||
socket.to(code).emit("presence:cursors", [
|
||||
{
|
||||
playerId: socket.data.playerId,
|
||||
x: (data && data.x) || 0,
|
||||
y: (data && data.y) || 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerHandlers };
|
||||
@@ -0,0 +1,56 @@
|
||||
// Word masking and hint helpers for Skribbl mode.
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function maskWord(word, revealed = new Set()) {
|
||||
// produce a mask like "_ _ _ _" preserving spaces
|
||||
return word
|
||||
.split("")
|
||||
.map((ch, i) => {
|
||||
if (ch === " ") return " ";
|
||||
if (revealed.has(i)) return ch.toLowerCase();
|
||||
return "_";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function pickRevealIndex(word, revealed) {
|
||||
const candidates = [];
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
if (word[i] === " ") continue;
|
||||
if (revealed.has(i)) continue;
|
||||
candidates.push(i);
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[Math.floor(Math.random() * candidates.length)];
|
||||
}
|
||||
|
||||
function isCloseGuess(guess, word) {
|
||||
const g = normalize(guess);
|
||||
const w = normalize(word);
|
||||
if (!g || !w || g === w) return false;
|
||||
if (Math.abs(g.length - w.length) > 2) return false;
|
||||
// simple Levenshtein
|
||||
const m = g.length, n = w.length;
|
||||
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = g[i - 1] === w[j - 1] ? 0 : 1;
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + cost
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[m][n] <= 2;
|
||||
}
|
||||
|
||||
module.exports = { normalize, maskWord, pickRevealIndex, isCloseGuess };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
yellow: "#FFD23F",
|
||||
coral: "#FF5C5C",
|
||||
mint: "#4ECDC4",
|
||||
lavender: "#A593E0",
|
||||
sky: "#5BCEFA",
|
||||
dark: "#2D2D2D",
|
||||
cream: "#FFF8E7",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Fredoka", "system-ui", "sans-serif"],
|
||||
display: ["Fredoka", "system-ui", "sans-serif"],
|
||||
},
|
||||
boxShadow: {
|
||||
card: "0 6px 0 rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08)",
|
||||
btn: "0 5px 0 rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)",
|
||||
chunky: "0 3px 0 rgba(0,0,0,0.15)",
|
||||
"btn-hover":
|
||||
"0 7px 0 rgba(0,0,0,0.18), 0 4px 10px rgba(0,0,0,0.14)",
|
||||
},
|
||||
borderRadius: {
|
||||
chunk: "22px",
|
||||
pill: "999px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"viewports": {
|
||||
"mobile": {"width": 375, "height": 812},
|
||||
"tablet": {"width": 768, "height": 1024},
|
||||
"desktop": {"width": 1440, "height": 900}
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"path": "/",
|
||||
"name": "landing",
|
||||
"selectors": [
|
||||
"[data-testid=create-room-cta]",
|
||||
"[data-testid=join-room-cta]",
|
||||
"[data-testid=mode-card-skribbl]",
|
||||
"[data-testid=mode-card-gartic]",
|
||||
"[data-testid=mode-card-color]"
|
||||
],
|
||||
"actions": []
|
||||
},
|
||||
{
|
||||
"path": "/create",
|
||||
"name": "create-room",
|
||||
"selectors": [
|
||||
"[data-testid=nickname-input]",
|
||||
"[data-testid=mode-skribbl]",
|
||||
"[data-testid=mode-gartic]",
|
||||
"[data-testid=mode-color]",
|
||||
"[data-testid=create-submit]"
|
||||
],
|
||||
"actions": [
|
||||
{"type": "fill", "selector": "[data-testid=nickname-input]", "value": "TestUser"},
|
||||
{"type": "click", "selector": "[data-testid=mode-skribbl]"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "/join",
|
||||
"name": "join-room",
|
||||
"selectors": [
|
||||
"[data-testid=room-code-input]",
|
||||
"[data-testid=nickname-input]",
|
||||
"[data-testid=join-submit]"
|
||||
],
|
||||
"actions": [
|
||||
{"type": "fill", "selector": "[data-testid=room-code-input]", "value": "ABC123"},
|
||||
{"type": "fill", "selector": "[data-testid=nickname-input]", "value": "Tester"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "/room/TESTRM",
|
||||
"name": "lobby",
|
||||
"selectors": [
|
||||
"[data-testid=room-pill]",
|
||||
"[data-testid=panel-players]",
|
||||
"[data-testid=room-code]"
|
||||
],
|
||||
"actions": [],
|
||||
"note": "Will redirect to /join?code=TESTRM unless a session exists. Test with a real created room."
|
||||
},
|
||||
{
|
||||
"path": "/room/TESTRM/play",
|
||||
"name": "play",
|
||||
"selectors": [
|
||||
"[data-testid=room-pill]"
|
||||
],
|
||||
"actions": [],
|
||||
"note": "Requires an active game session."
|
||||
},
|
||||
{
|
||||
"path": "/room/TESTRM/results",
|
||||
"name": "results",
|
||||
"selectors": [
|
||||
"[data-testid=scoreboard-final]"
|
||||
],
|
||||
"actions": [],
|
||||
"note": "Visible after a Skribbl/Color game finishes."
|
||||
},
|
||||
{
|
||||
"path": "/api/health",
|
||||
"name": "health-api",
|
||||
"selectors": [],
|
||||
"actions": [],
|
||||
"note": "GET expects {status:'ok', uptime:N}"
|
||||
}
|
||||
],
|
||||
"apiTests": [
|
||||
{"method": "GET", "path": "/api/health", "expectStatus": 200, "expectJsonContains": {"status": "ok"}},
|
||||
{"method": "GET", "path": "/api/room/NOPE/exists", "expectStatus": 200, "expectJsonContains": {"exists": false}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// OpenTelemetry auto-instrumentation. Imported FIRST in server.js.
|
||||
const process = require("process");
|
||||
|
||||
try {
|
||||
const { NodeSDK } = require("@opentelemetry/sdk-node");
|
||||
const {
|
||||
getNodeAutoInstrumentations,
|
||||
} = require("@opentelemetry/auto-instrumentations-node");
|
||||
const {
|
||||
OTLPTraceExporter,
|
||||
} = require("@opentelemetry/exporter-trace-otlp-http");
|
||||
|
||||
const endpointBase =
|
||||
process.env.SIGNOZ_OTEL_ENDPOINT || "http://100.64.0.10:4318";
|
||||
const tracesEndpoint = `${endpointBase.replace(/\/$/, "")}/v1/traces`;
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
serviceName: "skribbl-gartic-color",
|
||||
traceExporter: new OTLPTraceExporter({ url: tracesEndpoint }),
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
"@opentelemetry/instrumentation-fs": { enabled: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
sdk.start();
|
||||
console.log(
|
||||
`[otel] tracing initialised (endpoint=${tracesEndpoint}, service=skribbl-gartic-color)`
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[otel] sdk.start failed (continuing without tracing):", err?.message || err);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
sdk.shutdown().catch(() => {}).finally(() => process.exit(0));
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[otel] tracing dependencies not available, continuing without telemetry:",
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "server.js", "tracing.js"]
|
||||
}
|
||||