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"]
|
||||||
|
}
|
||||||