/** 그림 맞히기 화면 (docs/games/catchmind.md §5.5, §9). */ import { useEffect, useRef, useState, type ReactNode } from 'react'; import type { CanvasOp, CatchmindOptions, CatchmindView, ChatView, TurnEndReason } from '@bg/games/catchmind'; import type { PublicUser, ServerMessage } from '@bg/shared/lite'; import { Avatar } from '../../components/ui'; import { useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import { useWide } from '../../lib/media'; import { socket } from '../../net/socket'; import type { BoardProps, GameUI, OptionsProps } from '../types'; import { helpNow } from './help'; import { quickStart } from './quick'; // ---------------------------------------------------------------- constants (mirror packages/games/src/catchmind/types.ts) const BOARD = 1000; const PALETTE: [string, string][] = [ ['#111111', '검정'], ['#ffffff', '흰색'], ['#9ca3af', '회색'], ['#ef4444', '빨강'], ['#f97316', '주황'], ['#facc15', '노랑'], ['#22c55e', '초록'], ['#38bdf8', '하늘'], ['#2563eb', '파랑'], ['#8b5cf6', '보라'], ['#ec4899', '분홍'], ['#92400e', '갈색'], ]; const WIDTHS = [4, 8, 16, 32]; const WIDTH_KO = ['가늘게', '보통', '굵게', '아주 굵게']; const REACTIONS: [string, string][] = [ ['almost', '거의 다 왔어요'], ['similar', '비슷해요'], ['no', '아니에요'], ]; const REACTION_KO: Record = Object.fromEntries(REACTIONS); const CATEGORY_KO: Record = { animal: '동물', food: '음식', object: '물건', vehicle: '탈것', nature: '자연', place: '장소', job: '직업', sport: '운동·놀이', body: '몸', season: '계절·명절', custom: '추가 단어', }; const CATEGORY_ICON: Record = { animal: '🐾', food: '🍙', object: '🧸', vehicle: '🚗', nature: '🌳', place: '🏫', job: '🧑‍🍳', sport: '⚽', body: '✋', season: '🎑', custom: '✏️', }; const CATEGORY_KEYS = Object.keys(CATEGORY_KO).filter((k) => k !== 'custom'); const GUESS_MAX = 40; const CHAT_MAX = 100; const nick = (players: Record, id: string) => players[id]?.nickname ?? '나간 사람'; const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '나간 사람', avatar: null, kind: 'guest' }; const CSS = ` .cm { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 8px; align-self: center; } .cm-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: space-between; font-weight: 700; } .cm-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; } .cm-hint { display: flex; justify-content: center; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 48px; } .cm-hint .ch { min-width: 40px; height: 44px; padding: 0 4px; border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); display: inline-flex; align-items: center; justify-content: center; font-size: 1.6rem; font-weight: 900; color: var(--text); } .cm-hint .ch.sp { background: none; box-shadow: none; min-width: 14px; } .cm-hint .ch.open { color: var(--primary); } .cm-hint .answer { font-size: 1.35rem; font-weight: 900; color: var(--success); padding: 4px 14px; border-radius: 12px; background: var(--success-soft); } .cm-bar { position: relative; height: 10px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } .cm-bar i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; transition: width .25s linear; } .cm-stage { display: flex; gap: 12px; align-items: flex-start; justify-content: center; } .cm-paper { position: relative; width: min(100%, calc(100vh - 290px)); min-width: 280px; aspect-ratio: 1 / 1; border-radius: 14px; background: #fff; box-shadow: var(--shadow); border: 3px solid var(--border); overflow: hidden; flex: 0 0 auto; } .cm-paper canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: auto; } .cm-paper[data-draw='true'] canvas { touch-action: none; cursor: crosshair; } .cm-over { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; text-align: center; background: rgba(17,24,39,.55); color: #fff; z-index: 2; } .cm-over h2 { color: #fff; font-size: 1.5rem; margin: 0; } .cm-over .word { font-size: 2.2rem; font-weight: 900; background: #fff; color: #111827; padding: 4px 18px; border-radius: 14px; } .cm-over .pts { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; max-width: 100%; } .cm-over .pts span { background: rgba(255,255,255,.15); border-radius: 999px; padding: 3px 10px; font-weight: 700; } .cm-choices { display: grid; gap: 10px; width: min(100%, 360px); } .cm-choices button { min-height: 64px; font-size: 1.35rem; font-weight: 900; display: flex; align-items: center; justify-content: center; gap: 10px; } .cm-choices .ic { font-size: 1.6rem; } .cm-pop { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%); z-index: 3; background: var(--success); color: #fff; font-weight: 900; font-size: 2.4rem; padding: 10px 28px; border-radius: 20px; box-shadow: 0 8px 24px rgba(0,0,0,.3); animation: cm-pop .5s ease; pointer-events: none; white-space: nowrap; } .cm-pop small { display: block; font-size: 1rem; text-align: center; } .cm-tools { display: flex; flex-direction: column; gap: 10px; width: 132px; flex: 0 0 auto; } .cm-colors { display: grid; grid-template-columns: repeat(3, 36px); gap: 6px; } .cm-color { width: 36px; height: 36px; border-radius: 50%; border: 2px solid var(--border); padding: 0; cursor: pointer; position: relative; } .cm-color[aria-pressed='true'] { outline: 3px solid var(--primary); outline-offset: 2px; } .cm-color:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } .cm-row { display: flex; flex-wrap: wrap; gap: 6px; } .cm-tool { min-width: 40px; min-height: 40px; border-radius: 10px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 800; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 0 8px; font-size: .92rem; } .cm-tool[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } .cm-tool:disabled { opacity: .45; cursor: not-allowed; } .cm-tool:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } .cm-dot { display: inline-block; border-radius: 50%; background: currentColor; } .cm-react { display: grid; gap: 6px; } .cm-react button { min-height: 40px; font-size: .9rem; } .cm-strip { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 6px; scrollbar-width: thin; } .cm-strip .p { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 56px; position: relative; font-size: .78rem; font-weight: 700; } .cm-strip .p .nm { max-width: 64px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cm-strip .p .sc { font-variant-numeric: tabular-nums; } .cm-badge { position: absolute; top: -4px; right: 2px; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #fff; } .cm-badge.ok { background: var(--success); } .cm-badge.pen { background: var(--primary); } .cm-score { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; } .cm-score li { display: flex; align-items: center; gap: 6px; } .cm-score .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cm-chat { display: flex; flex-direction: column; gap: 8px; background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px; } .cm-chat-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; overflow-y: auto; min-height: 120px; max-height: 260px; } .cm-side .cm-chat-list { max-height: 340px; } .cm-line { font-size: .95rem; line-height: 1.35; word-break: break-word; } .cm-line b { margin-right: 6px; } .cm-line.sys { color: var(--success); font-weight: 800; } .cm-line.close { color: var(--warning); } .cm-line.blocked { color: var(--text-sub); font-style: italic; } .cm-line.react { color: var(--primary); font-weight: 700; } .cm-tag { display: inline-block; border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; background: var(--success-soft); color: var(--success); margin-right: 4px; } .cm-form { display: flex; gap: 6px; } .cm-form .input { flex: 1; min-height: 48px; font-size: 1.05rem; } .cm-close { color: var(--warning); font-weight: 900; font-size: .95rem; animation: cm-shake .4s ease; } .cm-away { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; background: var(--warning-soft); color: var(--text); border-radius: 12px; padding: 8px 12px; } @keyframes cm-pop { 0% { transform: translate(-50%, -50%) scale(.5); opacity: 0; } 70% { transform: translate(-50%, -50%) scale(1.08); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } } @keyframes cm-shake { 0%,100% { transform: none; } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } @media (max-width: 1099px) { .cm-stage { flex-direction: column; align-items: stretch; } .cm-paper { width: 100%; min-width: 0; } .cm-tools { width: 100%; } .cm-colors { grid-template-columns: repeat(6, 44px); gap: 8px; justify-content: center; } .cm-color { width: 44px; height: 44px; } .cm-row { justify-content: center; flex-wrap: nowrap; gap: 5px; } .cm-tool { min-width: 44px; min-height: 44px; padding: 0 4px; font-size: 1.1rem; } .cm-tool .lbl { display: none; } .cm-react { grid-template-columns: repeat(3, 1fr); } .cm-chat-form-sticky { position: sticky; bottom: 0; background: var(--surface); padding-top: 4px; z-index: 1; } .cm-hint .ch { min-width: 34px; height: 40px; font-size: 1.35rem; } } @media (prefers-reduced-motion: reduce) { .cm-pop, .cm-close { animation: none; } .cm-bar i { transition: none; } } `; // ---------------------------------------------------------------- drawing model + renderer interface Stroke { s: number; c: number; w: number; e: 0 | 1; pts: number[]; closed: boolean; } /** Strokes of the current turn, rebuilt from canvas ops (§5.5). `count` = server ops applied (gap detection). */ class Drawing { turn = -1; count = 0; strokes: Stroke[] = []; /** Bumped on undo / clear / reset: the cached layer of finished strokes must be redrawn. */ epoch = 0; version = 0; reset(turn: number, ops: readonly CanvasOp[]): void { this.turn = turn; this.count = 0; this.strokes = []; this.epoch++; for (const op of ops) this.apply(op); this.version++; } apply(op: CanvasOp): void { this.count++; const last = this.strokes.at(-1); switch (op.k) { case 'b': if (last) last.closed = true; this.strokes.push({ s: op.s, c: op.c, w: op.w, e: op.e, pts: [], closed: false }); break; case 'p': { if (!last || last.s !== op.s) break; let x = 0; let y = 0; for (let i = 0; i + 1 < op.d.length; i += 2) { x = i === 0 ? op.d[0]! : x + op.d[i]!; y = i === 0 ? op.d[1]! : y + op.d[i + 1]!; last.pts.push(x, y); } break; } case 'e': if (last && last.s === op.s) last.closed = true; break; case 'u': this.strokes.pop(); this.epoch++; break; case 'x': this.strokes = []; this.epoch++; break; } this.version++; } /** Drawer's local echo: points go straight onto the open stroke. */ addPoint(x: number, y: number): void { const last = this.strokes.at(-1); if (last && !last.closed) { last.pts.push(x, y); this.version++; } } } function paintStroke(ctx: CanvasRenderingContext2D, st: Stroke, k: number): void { const p = st.pts; const n = p.length / 2; if (n === 0) return; const color = st.e ? '#ffffff' : (PALETTE[st.c]?.[0] ?? '#111111'); const lw = (WIDTHS[st.w] ?? 8) * k; if (n === 1) { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(p[0]! * k, p[1]! * k, lw / 2, 0, Math.PI * 2); ctx.fill(); return; } ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(p[0]! * k, p[1]! * k); // Midpoint quadratic smoothing (§5.5 수신 측 렌더링). for (let i = 1; i < n - 1; i++) { const mx = ((p[i * 2]! + p[i * 2 + 2]!) / 2) * k; const my = ((p[i * 2 + 1]! + p[i * 2 + 3]!) / 2) * k; ctx.quadraticCurveTo(p[i * 2]! * k, p[i * 2 + 1]! * k, mx, my); } ctx.lineTo(p[(n - 1) * 2]! * k, p[(n - 1) * 2 + 1]! * k); ctx.stroke(); } /** Renders `drawing` into the canvas on animation frames; finished strokes are cached on an offscreen layer. */ function useRenderer(drawing: Drawing, canvasRef: React.RefObject): void { useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const base = document.createElement('canvas'); let baseCount = 0; let baseEpoch = -1; let seen = -1; let size = 0; let raf = 0; const resize = () => { const px = Math.max(1, Math.round(canvas.getBoundingClientRect().width * Math.min(2, window.devicePixelRatio || 1))); if (px !== size) { size = px; canvas.width = canvas.height = base.width = base.height = px; baseEpoch = -1; seen = -1; } }; const frame = () => { raf = requestAnimationFrame(frame); if (seen === drawing.version) return; seen = drawing.version; const k = size / BOARD; const bctx = base.getContext('2d')!; if (baseEpoch !== drawing.epoch || baseCount > drawing.strokes.length) { bctx.fillStyle = '#ffffff'; bctx.fillRect(0, 0, size, size); baseCount = 0; baseEpoch = drawing.epoch; } while (baseCount < drawing.strokes.length && drawing.strokes[baseCount]!.closed) paintStroke(bctx, drawing.strokes[baseCount++]!, k); const ctx = canvas.getContext('2d')!; ctx.drawImage(base, 0, 0); for (let i = baseCount; i < drawing.strokes.length; i++) paintStroke(ctx, drawing.strokes[i]!, k); }; resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); raf = requestAnimationFrame(frame); return () => { cancelAnimationFrame(raf); ro.disconnect(); }; }, [drawing, canvasRef]); } // ---------------------------------------------------------------- stroke sender (drawer) type Queued = { op: Exclude } | { op: { k: 'p'; s: number; q: number }; pts: number[]; born: number }; /** * Batches points (§5.5: every 50 ms or 32 points, stroke begin/end at once) and paces messages with a token bucket * that stays under the gateway limit (20 msg/s, burst 40) — when short of tokens, points keep merging into the * pending batch (up to 64) instead of being dropped. */ class StrokeSender { private queue: Queued[] = []; private tokens = 16; private last = performance.now(); private timer: ReturnType | null = null; private cs = Date.now() * 1000; readonly sent = new Set(); stroke = 0; private batch = 0; constructor(private onSendFail: () => void) {} begin(c: number, w: number, e: 0 | 1, x: number, y: number): void { this.stroke += 1; this.batch = 0; this.queue.push({ op: { k: 'b', s: this.stroke, c, w, e } }); this.point(x, y); this.pump(); } point(x: number, y: number): void { const tail = this.queue.at(-1); if (tail && tail.op.k === 'p' && 'pts' in tail && tail.op.s === this.stroke && tail.pts.length < 128) tail.pts.push(x, y); else this.queue.push({ op: { k: 'p', s: this.stroke, q: this.batch++ }, pts: [x, y], born: performance.now() }); this.ensureTimer(); } end(): void { this.queue.push({ op: { k: 'e', s: this.stroke } }); this.pump(); } simple(k: 'u' | 'x'): void { this.queue.push({ op: { k } }); this.pump(); } /** Server state after a (re)sync. */ resetTo(nextStroke: number): void { this.queue = []; this.stroke = nextStroke - 1; this.batch = 0; } get busy(): boolean { return this.queue.length > 0; } private ensureTimer(): void { this.timer ??= setInterval(() => this.pump(), 25); } pump(): void { const now = performance.now(); this.tokens = Math.min(20, this.tokens + ((now - this.last) / 1000) * 14); this.last = now; while (this.queue.length > 0 && this.tokens >= 1) { const head = this.queue[0]!; if ('pts' in head && this.queue.length === 1 && head.pts.length < 64 && now - head.born < 50) break; // still filling this.queue.shift(); let op: CanvasOp; if ('pts' in head) { const d: number[] = [head.pts[0]!, head.pts[1]!]; for (let i = 2; i < head.pts.length; i += 2) d.push(head.pts[i]! - head.pts[i - 2]!, head.pts[i + 1]! - head.pts[i - 1]!); op = { ...head.op, d }; } else op = head.op; const cs = ++this.cs; this.sent.add(cs); if (this.sent.size > 400) this.sent.delete(this.sent.values().next().value!); if (!socket.send({ t: 'act', cs, a: { type: 'draw', op } })) { this.queue = []; this.onSendFail(); break; } this.tokens -= 1; } if (this.queue.length === 0 && this.timer) { clearInterval(this.timer); this.timer = null; } else if (this.queue.length > 0) this.ensureTimer(); } dispose(): void { if (this.timer) clearInterval(this.timer); this.timer = null; } } // ---------------------------------------------------------------- canvas board function Paper({ v, me, act, players }: { v: CatchmindView; me: string | null; act: (a: unknown) => void; players: Record }) { const canvasRef = useRef(null); const drawingRef = useRef(null as unknown as Drawing); drawingRef.current ??= new Drawing(); const drawing = drawingRef.current; const [tool, setTool] = useState({ c: 0, w: 1, eraser: false }); const toolRef = useRef(tool); toolRef.current = tool; const viewRef = useRef(v); viewRef.current = v; const lastSync = useRef(0); const [strokes, setStrokes] = useState(0); const countRaf = useRef(0); const refreshCount = () => { if (countRaf.current) return; countRaf.current = requestAnimationFrame(() => { countRaf.current = 0; setStrokes(drawing.strokes.length); }); }; const requestSync = () => { const now = Date.now(); if (now - lastSync.current < 800) return; lastSync.current = now; socket.send({ t: 'sync' }); }; const senderRef = useRef(null as unknown as StrokeSender); senderRef.current ??= new StrokeSender(() => requestSync()); const sender = senderRef.current; const amDrawer = v.role === 'drawer'; useRenderer(drawing, canvasRef); // Streamed canvas ops (+ rejects of my own draw messages → resync). useEffect(() => { const off = socket.subscribe((m: ServerMessage) => { if (m.t === 'stream') { const cur = viewRef.current; for (const ev of m.events as { type?: string; turn?: number; n?: number; op?: CanvasOp }[]) { if (ev.type !== 'canvasOp' || ev.turn === undefined || ev.n === undefined || !ev.op) continue; if (ev.turn > drawing.turn) drawing.reset(ev.turn, []); if (ev.turn < drawing.turn) continue; // The drawer already shows its own strokes locally. if (cur.role === 'drawer' && cur.turnNo === ev.turn) continue; if (ev.n === drawing.count) drawing.apply(ev.op); else if (ev.n > drawing.count) requestSync(); } refreshCount(); } else if (m.t === 'reject' && sender.sent.has(m.cs)) { sender.resetTo(viewRef.current.nextStroke); requestSync(); } }); return () => { off(); }; }, [drawing, sender]); // Views: full drawing on (re)join / resync, otherwise only counts (§5.5). useEffect(() => { if (v.canvas) { drawing.reset(v.turnNo, v.canvas); sender.resetTo(v.nextStroke); } else if (v.turnNo !== drawing.turn) { if (v.turnNo > drawing.turn) { drawing.reset(v.turnNo, []); sender.resetTo(v.nextStroke); if (v.canvasCount > 0) requestSync(); } } else if (!amDrawer && v.canvasCount > drawing.count) requestSync(); else if (amDrawer && !sender.busy && v.canvasCount === 0 && drawing.strokes.length === 0) sender.resetTo(v.nextStroke); setStrokes(drawing.strokes.length); // eslint-disable-next-line react-hooks/exhaustive-deps }, [v]); useEffect(() => () => sender.dispose(), [sender]); // Pointer input for the drawer. const active = useRef(null); const lastPt = useRef<[number, number]>([0, 0]); const toBoard = (e: { clientX: number; clientY: number }): [number, number] => { const r = canvasRef.current!.getBoundingClientRect(); const x = Math.round(((e.clientX - r.left) / r.width) * BOARD); const y = Math.round(((e.clientY - r.top) / r.height) * BOARD); return [Math.max(0, Math.min(BOARD, x)), Math.max(0, Math.min(BOARD, y))]; }; const onDown = (e: React.PointerEvent) => { if (!viewRef.current.canDraw || active.current !== null || e.button > 0) return; e.preventDefault(); canvasRef.current!.setPointerCapture(e.pointerId); active.current = e.pointerId; const [x, y] = toBoard(e); lastPt.current = [x, y]; const t = toolRef.current; const ex: 0 | 1 = t.eraser ? 1 : 0; sender.begin(t.c, t.w, ex, x, y); drawing.apply({ k: 'b', s: sender.stroke, c: t.c, w: t.w, e: ex }); drawing.addPoint(x, y); }; const onMove = (e: React.PointerEvent) => { if (active.current !== e.pointerId) return; const native = e.nativeEvent; const list = typeof native.getCoalescedEvents === 'function' ? native.getCoalescedEvents() : []; for (const pe of list.length ? list : [native]) { const [x, y] = toBoard(pe); const [lx, ly] = lastPt.current; if ((x - lx) ** 2 + (y - ly) ** 2 < 4) continue; // §5.5: drop points closer than 2 lastPt.current = [x, y]; sender.point(x, y); drawing.addPoint(x, y); } }; const onUp = (e: React.PointerEvent) => { if (active.current !== e.pointerId) return; active.current = null; sender.end(); drawing.apply({ k: 'e', s: sender.stroke }); setStrokes(drawing.strokes.length); }; const undo = () => { if (drawing.strokes.length === 0 || active.current !== null) return; sender.simple('u'); drawing.apply({ k: 'u' }); setStrokes(drawing.strokes.length); }; const clear = () => { if (drawing.strokes.length === 0 || active.current !== null) return; sender.simple('x'); drawing.apply({ k: 'x' }); setStrokes(0); }; const save = () => { const c = canvasRef.current; if (!c) return; const a = document.createElement('a'); a.href = c.toDataURL('image/png'); a.download = `그림-${v.word ?? v.turnNo}.png`; a.click(); }; const wide = useWide(); const strokeLabel = `그림판, 획 ${drawing.strokes.length}개${v.canDraw ? '. 마우스나 손가락으로 그려 주세요' : ''}`; return (
v.canDraw && e.preventDefault()} />
{amDrawer && v.phase === 'drawing' && (
{PALETTE.map(([hex, name], i) => (
{WIDTHS.map((w, i) => ( ))}
{REACTIONS.map(([code, label]) => ( ))}
{!wide && 그리는 동안에는 채팅 대신 반응 버튼만 쓸 수 있어요.}
)}
); } const END_TEXT: Record = { time: '시간이 다 됐어요', allSolved: '모두 맞혔어요!', first: '가장 먼저 맞힌 사람이 나왔어요!', report: '글자를 썼다는 신고로 끝났어요(출제자 0점)', drawerAway: '출제자의 연결이 끊겼어요', drawerLeft: '출제자가 나갔어요', }; function Overlay({ v, me, act, players, onSave }: { v: CatchmindView; me: string | null; act: (a: unknown) => void; players: Record; onSave: () => void }) { const [pop, setPop] = useState(null); const prevRole = useRef(v.role); const prevTurn = useRef(v.turnNo); const popTimer = useRef | null>(null); useEffect(() => { if (prevTurn.current === v.turnNo && prevRole.current === 'guesser' && v.role === 'solver') { setPop(v.solvers.find((s) => s.id === me)?.points ?? 0); sounds.ding(); haptic(60); if (popTimer.current) clearTimeout(popTimer.current); popTimer.current = setTimeout(() => setPop(null), 1600); } prevRole.current = v.role; prevTurn.current = v.turnNo; // eslint-disable-next-line react-hooks/exhaustive-deps }, [v.role, v.turnNo, me]); useEffect(() => () => void (popTimer.current && clearTimeout(popTimer.current)), []); const popEl = pop !== null ? (
정답!+{pop}점
) : null; return ( <> {popEl} ); } function OverlayMain({ v, act, players, onSave }: { v: CatchmindView; act: (a: unknown) => void; players: Record; onSave: () => void }) { if (v.phase === 'choosing') { if (v.choices) { return (

그릴 단어를 골라 주세요

{v.choices.map((c, i) => ( ))}
10초 안에 고르지 않으면 첫 번째 단어로 그려요.
); } return (

{nick(players, v.drawer)}님이 단어를 고르고 있어요

곧 그림이 시작돼요!
); } if (v.phase === 'reveal' || v.phase === 'ended') { const tp = v.turnPoints ?? {}; return (
{v.endReason && {END_TEXT[v.endReason]}}

정답은

{v.word || '(고르기 전)'}
{Object.entries(tp) .filter(([, p]) => p > 0) .sort((a, b) => b[1] - a[1]) .map(([id, p]) => ( {nick(players, id)} +{p} ))} {Object.values(tp).every((p) => p === 0) && 이번 차례에는 점수가 없어요}
{v.word && ( )}
); } return null; } // ---------------------------------------------------------------- hint + timer function HintRow({ v }: { v: CatchmindView }) { if (v.phase === 'choosing') return
단어를 고르는 중…
; const hint = v.hint; return (
{hint ? ( c !== ' ').length}글자, ${hint.map((c) => (c === '○' ? '빈칸' : c === ' ' ? '띄어쓰기' : c)).join(' ')}`} style={{ display: 'inline-flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center' }}> {hint.map((c, i) => ( {c === ' ' ? '' : c} ))} ) : ( 힌트 없음 )} {v.word && v.phase === 'drawing' && 정답: {v.word}}
); } function TimeBar({ v }: { v: CatchmindView }) { const now = useServerNow(250); if (v.phase === 'ended') return null; const left = Math.max(0, v.endsAt - now); const frac = Math.min(1, left / Math.max(1, v.phaseMs)); const sec = Math.ceil(left / 1000); const color = sec <= 10 ? 'var(--danger)' : sec <= 20 ? 'var(--warning)' : 'var(--success)'; const what = v.phase === 'choosing' ? '단어 고르기' : v.phase === 'drawing' ? '그리기' : '정답 공개'; return (
{sec}초
); } // ---------------------------------------------------------------- scoreboard function ScoreStrip({ v, me, players }: { v: CatchmindView; me: string | null; players: Record }) { return (
{v.order.map((id) => { const solved = v.solvers.some((s) => s.id === id); return (
{solved && ( ✓ )} {id === v.drawer && v.phase !== 'ended' && ( 🖍 )} {nick(players, id)} {v.scores[id] ?? 0}점
); })}
); } function ScoreList({ v, me, players }: { v: CatchmindView; me: string | null; players: Record }) { const rows = [...v.order].sort((a, b) => (v.scores[b] ?? 0) - (v.scores[a] ?? 0)); return (
    {rows.map((id) => { const solved = v.solvers.some((s) => s.id === id); return (
  1. {id === v.drawer && v.phase !== 'ended' ? '🖍 ' : ''} {nick(players, id)} {id === me ? ' (나)' : ''} {v.left.includes(id) ? ' · 나감' : v.away.includes(id) ? ' · 자리 비움' : ''} {solved && ( ✓ 맞힘 )} {v.scores[id] ?? 0}점
  2. ); })}
); } // ---------------------------------------------------------------- guess / chat panel function chatLine(m: ChatView, me: string | null, players: Record, drawer: string): { cls: string; body: ReactNode } { const who = m.from === me ? '나' : nick(players, m.from); switch (m.kind) { case 'correct': return { cls: 'sys', body: `🎉 ${m.from === me ? '내가' : `${nick(players, m.from)}님이`} 맞혔어요!` }; case 'reaction': return { cls: 'react', body: `🖍 ${m.from === drawer ? '출제자' : who}: ${REACTION_KO[m.text] ?? m.text}` }; case 'close': return { cls: 'close', body: ( <> {who} {m.text} · 아까워요! ), }; case 'blocked': return { cls: 'blocked', body: ( <> {who} {m.text} · {m.note === 'chosung' ? '초성만 쓴 말은 다른 사람에게 보이지 않아요' : '정답이 들어간 말은 보낼 수 없어요'} ), }; default: return { cls: '', body: ( <> {m.solversOnly && 맞힌 사람끼리} {who} {m.text} ), }; } } function GuessPanel({ v, me, players, act, pending }: { v: CatchmindView; me: string | null; players: Record; act: (a: unknown) => void; pending: boolean }) { const [text, setText] = useState(''); const [close, setClose] = useState(false); const listRef = useRef(null); const inputRef = useRef(null); const lastId = v.chat.at(-1)?.id ?? 0; const seenClose = useRef(lastId); const closeTimer = useRef | null>(null); useEffect(() => { listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); const mine = v.chat.some((m) => m.id > seenClose.current && m.from === me && m.kind === 'close'); seenClose.current = lastId; if (mine) { setClose(true); if (closeTimer.current) clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => setClose(false), 2200); } }, [lastId, v.chat, me]); useEffect(() => () => void (closeTimer.current && clearTimeout(closeTimer.current)), []); const enabled = v.canGuess || v.canChat; // Enter anywhere focuses this box (the room chat is closed during the game: ownChat). useEffect(() => { if (!enabled) return; const onKey = (e: KeyboardEvent) => { const t = e.target as HTMLElement; if (e.key === 'Enter' && !['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName)) { e.preventDefault(); e.stopPropagation(); inputRef.current?.focus(); } }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); }, [enabled]); const max = v.canGuess ? GUESS_MAX : CHAT_MAX; const placeholder = v.canGuess ? v.role === 'solver' ? '맞힌 사람끼리 이야기해요' : '정답을 입력해 주세요 (Enter)' : v.canChat ? '채팅을 입력해 주세요' : v.phase === 'ended' ? '게임이 끝났어요' : v.role === 'drawer' && v.phase !== 'reveal' ? '그리는 사람은 채팅할 수 없어요' : v.role === 'spectator' ? '관전 중이에요' : '지금은 입력할 수 없어요'; return (
    {v.chat.length === 0 &&
  1. 아직 아무도 말하지 않았어요.
  2. } {v.chat.map((m) => { const { cls, body } = chatLine(m, me, players, v.drawer); return (
  3. {body}
  4. ); })}
{close && ( 아까워요! 거의 다 왔어요 )}
{ e.preventDefault(); const t = text.trim(); if (!t || !enabled) return; act(v.canGuess ? { type: 'guess', text: t.slice(0, max) } : { type: 'chat', text: t.slice(0, max) }); setText(''); }} > setText(e.target.value)} maxLength={max} disabled={!enabled} placeholder={placeholder} aria-label={v.canGuess && v.role !== 'solver' ? '정답' : '채팅'} enterKeyHint="send" autoComplete="off" onKeyDown={(e) => e.key === 'Escape' && e.currentTarget.blur()} />
{v.canReport && ( )} {v.reportedByMe && v.phase === 'drawing' && ( 신고했어요 ({v.reports}/{v.reportsNeeded}). 과반이 되면 이번 차례가 끝나요. )}
); } // ---------------------------------------------------------------- board / side function AwayBanner({ v, me, act }: { v: CatchmindView; me: string | null; act: (a: unknown) => void }) { if (!me || !v.away.includes(me) || v.phase === 'ended') return null; return (
자리 비움으로 표시돼 출제 차례를 건너뛰고 있어요.
); } function CatchmindBoard({ view: v, me, players, act, pending }: BoardProps) { const wide = useWide(); return (
{!wide && }
{v.round}/{v.rounds}라운드 · {Math.min(v.turnNo, v.totalTurns)}/{v.totalTurns}번째 {v.phase === 'ended' ? ( 게임이 끝났어요 ) : ( 🖍 {v.role === 'drawer' ? '내가 그려요' : `${nick(players, v.drawer)}님이 그려요`} )}
{!wide && }
); } function CatchmindSide({ view: v, me, players, act, pending }: BoardProps) { const wide = useWide(); return (
점수{v.phase === 'ended' ? ' · 게임 끝' : ''}
{wide && } {v.result && (
최종 순위 {v.result.ranking.map((r) => ( {r.rank}등 {nick(players, r.id)} {' '} · {r.score}점 · {r.correct}번 맞힘 ))} {v.result.summary}
)} {v.history.length > 0 && (
지난 문제 {v.history.length}개
    {[...v.history].reverse().map((h) => (
  1. {h.word || '(고르기 전)'} · {nick(players, h.drawer)}님 그림 · {h.solvers.length ? `${h.solvers.map((s) => nick(players, s)).join(', ')} 맞힘` : '아무도 못 맞힘'}
  2. ))}
)}
); } // ---------------------------------------------------------------- status function isMyTurn(v: CatchmindView, me: string | null): boolean { return !!me && v.drawer === me && (v.phase === 'choosing' || v.phase === 'drawing'); } function statusText(v: CatchmindView, me: string | null, players: Record): string { if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요'; const d = nick(players, v.drawer); if (v.phase === 'reveal') return `정답은 "${v.word || '?'}"였어요 · 곧 다음 차례예요`; if (v.phase === 'choosing') return isMyTurn(v, me) ? '내 차례예요 · 그릴 단어를 골라 주세요' : `${d}님이 단어를 고르고 있어요`; if (isMyTurn(v, me)) return '내 차례예요 · 그림으로 설명해 주세요'; if (v.role === 'solver') return '맞혔어요! 다른 사람이 맞힐 때까지 기다려요'; if (v.role === 'spectator') return `${d}님이 그리고 있어요`; return `${d}님의 그림을 보고 정답을 입력해 주세요`; } // ---------------------------------------------------------------- options function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { return (
{options.map(([k, l]) => ( ))}
); } function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { return (
{label} {children} {help && {help}}
); } const onOff: [boolean, string][] = [ [true, '켜기'], [false, '끄기'], ]; function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); const cats = o.categories as Record; const customCount = o.customWords.split(/\r?\n/).filter((l) => /^[가-힣A-Za-z0-9]{1,10}$/.test(l.trim())).length; return (
set({ guessMode, answererDraws: guessMode === 'firstOnly' ? o.answererDraws : false })} /> set({ rounds })} /> set({ drawSeconds })} /> set({ difficulty })} /> set({ hints })} /> {more && ( <>
{CATEGORY_KEYS.map((k) => ( ))}
set({ wordChoice })} />