Files
joke-app/apps/web/src/games/catchmind/index.tsx
2026-10-06 13:35:21 +09:00

1250 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 그림 맞히기 화면 (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<string, string> = Object.fromEntries(REACTIONS);
const CATEGORY_KO: Record<string, string> = {
animal: '동물',
food: '음식',
object: '물건',
vehicle: '탈것',
nature: '자연',
place: '장소',
job: '직업',
sport: '운동·놀이',
body: '몸',
season: '계절·명절',
custom: '추가 단어',
};
const CATEGORY_ICON: Record<string, string> = {
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<string, PublicUser>, id: string) => players[id]?.nickname ?? '나간 사람';
const userOf = (players: Record<string, PublicUser>, 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<HTMLCanvasElement | null>): 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<CanvasOp, { k: 'p' }> } | { 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<typeof setInterval> | null = null;
private cs = Date.now() * 1000;
readonly sent = new Set<number>();
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<string, PublicUser> }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<Drawing>(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<StrokeSender>(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<number | null>(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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className="cm-stage">
<div className="cm-paper" data-draw={v.canDraw}>
<canvas
ref={canvasRef}
role="img"
aria-label={strokeLabel}
data-strokes={strokes}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onLostPointerCapture={onUp}
onContextMenu={(e) => v.canDraw && e.preventDefault()}
/>
<Overlay v={v} me={me} act={act} players={players} onSave={save} />
</div>
{amDrawer && v.phase === 'drawing' && (
<div className="cm-tools" role="toolbar" aria-label="그리기 도구">
<div className="cm-colors" role="group" aria-label="색">
{PALETTE.map(([hex, name], i) => (
<button
key={hex}
type="button"
className="cm-color"
style={{ background: hex }}
aria-label={name}
aria-pressed={!tool.eraser && tool.c === i}
onClick={() => setTool({ ...tool, c: i, eraser: false })}
/>
))}
</div>
<div className="cm-row">
{WIDTHS.map((w, i) => (
<button key={w} type="button" className="cm-tool" aria-label={`굵기 ${WIDTH_KO[i]}`} aria-pressed={tool.w === i} onClick={() => setTool({ ...tool, w: i })}>
<span className="cm-dot" style={{ width: Math.max(4, w * 0.75), height: Math.max(4, w * 0.75), color: tool.eraser ? 'var(--text-sub)' : PALETTE[tool.c]![0] === '#ffffff' ? '#9ca3af' : PALETTE[tool.c]![0] }} />
</button>
))}
<button type="button" className="cm-tool" aria-label="지우개" aria-pressed={tool.eraser} onClick={() => setTool({ ...tool, eraser: !tool.eraser })}>
🧽<span className="lbl"> 지우개</span>
</button>
<button type="button" className="cm-tool" aria-label="되돌리기" onClick={undo} disabled={strokes === 0}>
↶<span className="lbl"> 되돌리기</span>
</button>
<button type="button" className="cm-tool" aria-label="전체 지우기" onClick={clear} disabled={strokes === 0}>
🗑<span className="lbl"> 전체 지우기</span>
</button>
</div>
<div className="cm-react" role="group" aria-label="반응 보내기">
{REACTIONS.map(([code, label]) => (
<button key={code} type="button" className="btn btn-secondary btn-sm" onClick={() => act({ type: 'reaction', code })}>
{label}
</button>
))}
</div>
{!wide && <span className="small muted">그리는 동안에는 채팅 대신 반응 버튼만 쓸 수 있어요.</span>}
</div>
)}
</div>
);
}
const END_TEXT: Record<TurnEndReason, string> = {
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<string, PublicUser>; onSave: () => void }) {
const [pop, setPop] = useState<number | null>(null);
const prevRole = useRef(v.role);
const prevTurn = useRef(v.turnNo);
const popTimer = useRef<ReturnType<typeof setTimeout> | 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 ? (
<div className="cm-pop" role="status">
정답!<small>+{pop}점</small>
</div>
) : null;
return (
<>
<OverlayMain v={v} act={act} players={players} onSave={onSave} />
{popEl}
</>
);
}
function OverlayMain({ v, act, players, onSave }: { v: CatchmindView; act: (a: unknown) => void; players: Record<string, PublicUser>; onSave: () => void }) {
if (v.phase === 'choosing') {
if (v.choices) {
return (
<div className="cm-over">
<h2>그릴 단어를 골라 주세요</h2>
<div className="cm-choices">
{v.choices.map((c, i) => (
<button key={c.word} type="button" className="btn btn-primary" onClick={() => act({ type: 'chooseWord', index: i })} aria-label={`${c.word} (${CATEGORY_KO[c.category]})`}>
<span className="ic" aria-hidden>
{CATEGORY_ICON[c.category]}
</span>
{c.word}
</button>
))}
</div>
<span className="small">10초 안에 고르지 않으면 첫 번째 단어로 그려요.</span>
</div>
);
}
return (
<div className="cm-over">
<h2>{nick(players, v.drawer)}님이 단어를 고르고 있어요</h2>
<span>곧 그림이 시작돼요!</span>
</div>
);
}
if (v.phase === 'reveal' || v.phase === 'ended') {
const tp = v.turnPoints ?? {};
return (
<div className="cm-over">
{v.endReason && <span>{END_TEXT[v.endReason]}</span>}
<h2>정답은</h2>
<span className="word">{v.word || '(고르기 전)'}</span>
<div className="pts">
{Object.entries(tp)
.filter(([, p]) => p > 0)
.sort((a, b) => b[1] - a[1])
.map(([id, p]) => (
<span key={id}>
{nick(players, id)} +{p}
</span>
))}
{Object.values(tp).every((p) => p === 0) && <span>이번 차례에는 점수가 없어요</span>}
</div>
{v.word && (
<button type="button" className="btn btn-secondary btn-sm" onClick={onSave}>
🖼 그림 저장(PNG)
</button>
)}
</div>
);
}
return null;
}
// ---------------------------------------------------------------- hint + timer
function HintRow({ v }: { v: CatchmindView }) {
if (v.phase === 'choosing') return <div className="cm-hint muted">단어를 고르는 중…</div>;
const hint = v.hint;
return (
<div className="cm-hint">
{hint ? (
<span role="text" aria-label={`힌트: ${hint.filter((c) => c !== ' ').length}글자, ${hint.map((c) => (c === '○' ? '빈칸' : c === ' ' ? '띄어쓰기' : c)).join(' ')}`} style={{ display: 'inline-flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center' }}>
{hint.map((c, i) => (
<span key={i} className={`ch${c === ' ' ? ' sp' : ''}${c !== '○' && c !== ' ' && !/^[ㄱ-ㅎ]$/.test(c) ? ' open' : ''}`} aria-hidden>
{c === ' ' ? '' : c}
</span>
))}
</span>
) : (
<span className="muted">힌트 없음</span>
)}
{v.word && v.phase === 'drawing' && <span className="answer">정답: {v.word}</span>}
</div>
);
}
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 (
<div className="row" style={{ gap: 8, flexWrap: 'nowrap', alignItems: 'center' }}>
<div className="cm-bar" style={{ flex: 1 }} role="timer" aria-label={`${what} 남은 시간 ${sec}초`}>
<i style={{ width: `${frac * 100}%`, background: color }} />
</div>
<strong style={{ fontVariantNumeric: 'tabular-nums', minWidth: 40, textAlign: 'right', color }} aria-hidden>
{sec}초
</strong>
</div>
);
}
// ---------------------------------------------------------------- scoreboard
function ScoreStrip({ v, me, players }: { v: CatchmindView; me: string | null; players: Record<string, PublicUser> }) {
return (
<div className="cm-strip" role="list" aria-label="점수판">
{v.order.map((id) => {
const solved = v.solvers.some((s) => s.id === id);
return (
<div key={id} className="p" role="listitem" aria-label={`${nick(players, id)}${id === me ? '(나)' : ''} ${v.scores[id] ?? 0}점${id === v.drawer ? ', 출제자' : ''}${solved ? ', 맞힘' : ''}`} style={{ opacity: v.left.includes(id) ? 0.45 : 1 }}>
<Avatar user={userOf(players, id)} size={36} />
{solved && (
<span className="cm-badge ok" aria-hidden>
✓
</span>
)}
{id === v.drawer && v.phase !== 'ended' && (
<span className="cm-badge pen" aria-hidden>
🖍
</span>
)}
<span className="nm" aria-hidden style={{ fontWeight: id === me ? 900 : 700 }}>
{nick(players, id)}
</span>
<span className="sc" aria-hidden>
{v.scores[id] ?? 0}점
</span>
</div>
);
})}
</div>
);
}
function ScoreList({ v, me, players }: { v: CatchmindView; me: string | null; players: Record<string, PublicUser> }) {
const rows = [...v.order].sort((a, b) => (v.scores[b] ?? 0) - (v.scores[a] ?? 0));
return (
<ol className="cm-score" aria-label="점수판">
{rows.map((id) => {
const solved = v.solvers.some((s) => s.id === id);
return (
<li key={id} style={{ opacity: v.left.includes(id) ? 0.5 : 1 }} aria-label={`${nick(players, id)} ${v.scores[id] ?? 0}점, 맞힌 횟수 ${v.correctCounts[id] ?? 0}번${id === v.drawer ? ', 출제자' : ''}${solved ? ', 이번 차례 맞힘' : ''}`}>
<Avatar user={userOf(players, id)} size={26} />
<span className="nm" style={{ fontWeight: id === me ? 900 : 600 }} aria-hidden>
{id === v.drawer && v.phase !== 'ended' ? '🖍 ' : ''}
{nick(players, id)}
{id === me ? ' (나)' : ''}
{v.left.includes(id) ? ' · 나감' : v.away.includes(id) ? ' · 자리 비움' : ''}
</span>
{solved && (
<span className="badge success" aria-hidden>
✓ 맞힘
</span>
)}
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{v.scores[id] ?? 0}점
</strong>
</li>
);
})}
</ol>
);
}
// ---------------------------------------------------------------- guess / chat panel
function chatLine(m: ChatView, me: string | null, players: Record<string, PublicUser>, 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: (
<>
<b>{who}</b>
{m.text} · 아까워요!
</>
),
};
case 'blocked':
return {
cls: 'blocked',
body: (
<>
<b>{who}</b>
{m.text} · {m.note === 'chosung' ? '초성만 쓴 말은 다른 사람에게 보이지 않아요' : '정답이 들어간 말은 보낼 수 없어요'}
</>
),
};
default:
return {
cls: '',
body: (
<>
{m.solversOnly && <span className="cm-tag">맞힌 사람끼리</span>}
<b>{who}</b>
{m.text}
</>
),
};
}
}
function GuessPanel({ v, me, players, act, pending }: { v: CatchmindView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean }) {
const [text, setText] = useState('');
const [close, setClose] = useState(false);
const listRef = useRef<HTMLOListElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const lastId = v.chat.at(-1)?.id ?? 0;
const seenClose = useRef(lastId);
const closeTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<section className="cm-chat" aria-label={v.canGuess && v.role !== 'solver' ? '정답 입력' : '게임 채팅'}>
<ol ref={listRef} className="cm-chat-list" aria-live="polite">
{v.chat.length === 0 && <li className="small muted">아직 아무도 말하지 않았어요.</li>}
{v.chat.map((m) => {
const { cls, body } = chatLine(m, me, players, v.drawer);
return (
<li key={m.id} className={`cm-line ${cls}`}>
{body}
</li>
);
})}
</ol>
{close && (
<span className="cm-close" role="status">
아까워요! 거의 다 왔어요
</span>
)}
<form
className="cm-form cm-chat-form-sticky"
onSubmit={(e) => {
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('');
}}
>
<input
ref={inputRef}
className="input"
value={text}
onChange={(e) => 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()}
/>
<button className="btn btn-primary" disabled={!enabled || !text.trim() || pending} style={{ minWidth: 72 }}>
{v.canGuess && v.role !== 'solver' ? '맞히기' : '보내기'}
</button>
</form>
{v.canReport && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => act({ type: 'report' })} style={{ alignSelf: 'flex-start' }}>
🚩 글자를 썼어요 ({v.reports}/{v.reportsNeeded})
</button>
)}
{v.reportedByMe && v.phase === 'drawing' && (
<span className="small muted">
신고했어요 ({v.reports}/{v.reportsNeeded}). 과반이 되면 이번 차례가 끝나요.
</span>
)}
</section>
);
}
// ---------------------------------------------------------------- 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 (
<div className="cm-away" role="status">
<span>자리 비움으로 표시돼 출제 차례를 건너뛰고 있어요.</span>
<button type="button" className="btn btn-primary btn-sm" onClick={() => act({ type: 'back' })}>
돌아왔어요
</button>
</div>
);
}
function CatchmindBoard({ view: v, me, players, act, pending }: BoardProps<CatchmindView>) {
const wide = useWide();
return (
<div className="cm">
<style>{CSS}</style>
{!wide && <ScoreStrip v={v} me={me} players={players} />}
<div className="cm-top">
<span className="cm-pill">
{v.round}/{v.rounds}라운드 · {Math.min(v.turnNo, v.totalTurns)}/{v.totalTurns}번째
</span>
{v.phase === 'ended' ? (
<span className="cm-pill">게임이 끝났어요</span>
) : (
<span className="cm-pill">🖍 {v.role === 'drawer' ? '내가 그려요' : `${nick(players, v.drawer)}님이 그려요`}</span>
)}
</div>
<AwayBanner v={v} me={me} act={act} />
<HintRow v={v} />
<TimeBar v={v} />
<Paper v={v} me={me} act={act} players={players} />
{!wide && <GuessPanel v={v} me={me} players={players} act={act} pending={pending} />}
</div>
);
}
function CatchmindSide({ view: v, me, players, act, pending }: BoardProps<CatchmindView>) {
const wide = useWide();
return (
<div className="cm-side stack" style={{ gap: 12 }}>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 8, padding: 12 }}>
<strong>점수{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<ScoreList v={v} me={me} players={players} />
</div>
{wide && <GuessPanel v={v} me={me} players={players} act={act} pending={pending} />}
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.id}>
{r.rank}등 {nick(players, r.id)}
<span className="muted small">
{' '}
· {r.score}점 · {r.correct}번 맞힘
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.history.length > 0 && (
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>지난 문제 {v.history.length}개</summary>
<ol style={{ margin: '8px 0 0', paddingLeft: 20, display: 'grid', gap: 3 }}>
{[...v.history].reverse().map((h) => (
<li key={h.no} className="small">
<strong>{h.word || '(고르기 전)'}</strong> · {nick(players, h.drawer)}님 그림 · {h.solvers.length ? `${h.solvers.map((s) => nick(players, s)).join(', ')} 맞힘` : '아무도 못 맞힘'}
</li>
))}
</ol>
</details>
)}
</div>
);
}
// ---------------------------------------------------------------- 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, PublicUser>): 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<T extends string | number | boolean>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
function Options({ value: o, onChange }: OptionsProps<CatchmindOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<CatchmindOptions>) => onChange({ ...o, ...patch });
const cats = o.categories as Record<string, boolean>;
const customCount = o.customWords.split(/\r?\n/).filter((l) => /^[가-힣A-Za-z0-9]{1,10}$/.test(l.trim())).length;
return (
<div className="stack" style={{ gap: 16 }}>
<button type="button" className="btn btn-soft" onClick={() => set({ difficulty: 'easy', hints: 'full', drawSeconds: 120, wordChoice: true })}>
🧒 어린이·어르신용으로 맞추기(쉬운 단어, 힌트 많이, 120초)
</button>
<Field label="맞히기 방식" help={o.guessMode === 'multi' ? '여러 명이 맞힐 수 있고, 먼저 맞힐수록 점수가 커요(10·8·6·4점).' : '첫 정답자가 나오면 바로 끝나요. 정답자 10점, 출제자 5점.'}>
<Seg
label="맞히기 방식"
value={o.guessMode}
options={[
['multi', '여러 명이 맞히기'],
['firstOnly', '첫 정답자만'],
]}
onChange={(guessMode) => set({ guessMode, answererDraws: guessMode === 'firstOnly' ? o.answererDraws : false })}
/>
</Field>
<Field label="라운드 수" help="모두 한 번씩 그리면 1라운드예요.">
<Seg
label="라운드 수"
value={o.rounds}
options={[
[1, '1라운드'],
[2, '2라운드'],
[3, '3라운드'],
]}
onChange={(rounds) => set({ rounds })}
/>
</Field>
<Field label="그리기 시간">
<Seg
label="그리기 시간"
value={o.drawSeconds}
options={[
[60, '60초'],
[80, '80초'],
[100, '100초'],
[120, '120초'],
]}
onChange={(drawSeconds) => set({ drawSeconds })}
/>
</Field>
<Field label="단어 난이도">
<Seg
label="단어 난이도"
value={o.difficulty}
options={[
['easy', '쉬운 단어만'],
['normal', '보통 단어만'],
['mixed', '섞어서'],
]}
onChange={(difficulty) => set({ difficulty })}
/>
</Field>
<Field label="힌트" help="50%가 지나면 초성, 75%가 지나면 한 글자를 보여 줘요(힌트 많이).">
<Seg
label="힌트"
value={o.hints}
options={[
['full', '많이'],
['basic', '글자 수만'],
['none', '없음'],
]}
onChange={(hints) => set({ hints })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="단어 분야" help="모두 끄면 전체 분야를 써요.">
<div className="row" style={{ gap: 6 }} role="group" aria-label="단어 분야">
{CATEGORY_KEYS.map((k) => (
<button key={k} type="button" className="btn btn-secondary btn-sm" aria-pressed={cats[k]} style={cats[k] ? { background: 'var(--primary-soft)', borderColor: 'var(--primary)' } : undefined} onClick={() => set({ categories: { ...o.categories, [k]: !cats[k] } })}>
{CATEGORY_ICON[k]} {CATEGORY_KO[k]}
{cats[k] ? ' ✓' : ''}
</button>
))}
</div>
</Field>
<Field label="단어 3개 중 고르기">
<Seg label="단어 3개 중 고르기" value={o.wordChoice} options={onOff} onChange={(wordChoice) => set({ wordChoice })} />
</Field>
<Field label="추가 단어" help={`한 줄에 하나씩, 1~10자 한글·영문·숫자만 써요(최대 200개). 지금 ${customCount}개`}>
<textarea className="input" rows={4} value={o.customWords} maxLength={3000} onChange={(e) => set({ customWords: e.target.value })} aria-label="추가 단어" placeholder={'우리 학교\n담임 선생님'} />
</Field>
<Field label="추가 단어만 쓰기">
<Seg label="추가 단어만 쓰기" value={o.customOnly} options={onOff} onChange={(customOnly) => set({ customOnly })} />
</Field>
<Field label='"아까워요" 알림' help="거의 맞힌 사람에게만 살짝 알려 줘요.">
<Seg label="아까워요 알림" value={o.closeHint} options={onOff} onChange={(closeHint) => set({ closeHint })} />
</Field>
<Field label='"글자를 썼어요" 신고' help="맞히는 사람 과반이 신고하면 그 차례가 끝나고 출제자는 0점이에요.">
<Seg label="글자 신고" value={o.letterReport} options={onOff} onChange={(letterReport) => set({ letterReport })} />
</Field>
{o.guessMode === 'firstOnly' && (
<Field label="정답자가 다음 출제자">
<Seg label="정답자가 다음 출제자" value={o.answererDraws} options={onOff} onChange={(answererDraws) => set({ answererDraws })} />
</Field>
)}
<Field label="빨리 맞히면 보너스" help="남은 시간 비율 × 5점을 더 받아요.">
<Seg label="빨리 맞히면 보너스" value={o.speedBonus} options={onOff} onChange={(speedBonus) => set({ speedBonus })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: CatchmindOptions): string {
const hint = o.hints === 'full' ? '힌트 많이' : o.hints === 'basic' ? '글자 수 힌트' : '힌트 없음';
const diff = o.difficulty === 'easy' ? '쉬운 단어' : o.difficulty === 'normal' ? '보통 단어' : '';
return [o.guessMode === 'multi' ? '여러 명 맞히기' : '첫 정답자만', `${o.rounds}라운드`, `${o.drawSeconds}초`, hint, diff, o.customOnly ? '추가 단어만' : ''].filter(Boolean).join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: CatchmindOptions }) {
const o = options;
return (
<div className="stack">
<p>
한 사람(출제자)이 제시어를 <strong>그림으로</strong> 그리고, 나머지는 그림을 보며 <strong>채팅으로 정답</strong>을 맞혀요. 차례대로 돌아가며 모두 한 번씩 그리면 1라운드예요
{o ? `(이 방은 ${o.rounds}라운드, 그리기 ${o.drawSeconds}초)` : ''}.
</p>
<p>
출제자는 단어 3개 중 하나를 10초 안에 골라요. 그리는 동안에는 글자를 쓰면 안 되고, 채팅 대신 "거의 다 왔어요" 같은 반응 버튼만 쓸 수 있어요. 글자를 쓰면 맞히는 사람들이 신고할 수 있어요.
</p>
<p>
{o?.guessMode === 'firstOnly'
? '첫 정답자가 나오면 바로 끝나요. 정답자 10점, 출제자 5점이에요.'
: '먼저 맞힐수록 점수가 커요: 10점, 8점, 6점, 그다음부터 4점. 출제자는 맞힌 사람 1명마다 2점(최대 12점)을 받아요.'}{' '}
띄어쓰기·문장부호는 무시하고, "사과요"처럼 끝에 붙인 말도 정답으로 봐요. 거의 맞히면 나에게만 "아까워요!"가 떠요.
</p>
<p>시간이 지나면 힌트가 나와요: 처음엔 글자 수, 절반이 지나면 초성, 3/4이 지나면 한 글자. 점수가 같으면 많이 맞힌 사람이 앞서요.</p>
</div>
);
}
const ui: GameUI<CatchmindView, CatchmindOptions> = {
Board: CatchmindBoard,
Side: CatchmindSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
ownChat: true,
};
export default ui;