1250 lines
52 KiB
TypeScript
1250 lines
52 KiB
TypeScript
/** 그림 맞히기 화면 (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;
|