diff --git a/apps/server/src/rooms/catchmind.room.test.ts b/apps/server/src/rooms/catchmind.room.test.ts new file mode 100644 index 0000000..9b8234b --- /dev/null +++ b/apps/server/src/rooms/catchmind.room.test.ts @@ -0,0 +1,191 @@ +/** 그림 맞히기 through a real Room: stroke streaming, resync on reconnect, drawer disconnect, rate limit (docs/games/catchmind.md §5.5, §8.5, §10). */ +import { describe, expect, test } from 'bun:test'; +import type { ServerMessage } from '@bg/shared'; +import { GAMES } from '@bg/games'; +import type { CanvasOp, CatchmindState, CatchmindView } from '@bg/games/catchmind'; +import { openDb } from '../db'; +import { Room, type Conn, type RoomConfig } from './room'; +import { RoomStore } from './store'; + +type Msgs = ServerMessage[]; +type StateMsg = Extract; + +function harness(players: string[]) { + const db = openDb(':memory:'); + const store = new RoomStore(db); + let now = 1_000_000; + const clock = { now: () => now, advance: (ms: number) => (now += ms) }; + const games = { catchmind: GAMES.catchmind! }; + const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now }; + const config: RoomConfig = { + gameId: 'catchmind', + options: games.catchmind.defaultOptions, + maxPlayers: players.length, + visibility: 'private', + allowSpectators: true, + chatEnabled: true, + chatFilter: true, + graceSec: 60, + }; + const room = new Room('room1', '482913', config, players[0]!, deps); + store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'catchmind', status: 'lobby', visibility: 'private', config, stateJson: '{}', now }); + const conn = (p: string) => { + const msgs: Msgs = []; + const c: Conn & { msgs: Msgs } = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} }; + return c; + }; + const conns: Record = {}; + for (const p of players) { + conns[p] = conn(p); + room.join(conns[p], 'player'); + } + expect(room.start(players[0]!)).toBeNull(); + room.clearTimer(); + const state = () => room.game!.state as CatchmindState; + let cs = 100; + const act = (p: string, a: unknown) => { + room.act(p, ++cs, a); + room.clearTimer(); + return cs; + }; + return { room, db, clock, conns, conn, state, act }; +} + +const drawOps = (stroke: number): CanvasOp[] => [ + { k: 'b', s: stroke, c: 3, w: 1, e: 0 }, + { k: 'p', s: stroke, q: 0, d: [100, 100, 10, 0, 10, 5] }, + { k: 'p', s: stroke, q: 1, d: [130, 120, 4, 4] }, + { k: 'e', s: stroke }, +]; +const lastState = (m: Msgs) => m.filter((x): x is StateMsg => x.t === 'state').at(-1)!; +const pointCount = (ops: CanvasOp[]) => ops.reduce((n, o) => n + (o.k === 'p' ? o.d.length / 2 : 0), 0); + +describe('그림 맞히기 room flow', () => { + test('그림은 stream 메시지로만 중계(전체 view 재전송 없음), 재접속한 사람은 그림 기록 전체를 받음', () => { + const { room, db, clock, conns, conn, state, act } = harness(['A', 'B', 'C']); + const d = state().turn.drawer; + const [g1, g2] = state().order.filter((p) => p !== d) as [string, string]; + act(d, { type: 'chooseWord', index: 0 }); + expect(state().turn.phase).toBe('drawing'); + const before = conns[g1]!.msgs.length; + const ops = [...drawOps(1), ...drawOps(2)]; + for (const op of ops) { + clock.advance(50); + act(d, { type: 'draw', op }); + } + const got = conns[g1]!.msgs.slice(before); + expect(got.every((m) => m.t === 'stream')).toBe(true); + expect(got).toHaveLength(ops.length); + const relayed = got.flatMap((m) => (m.t === 'stream' ? m.events : [])) as { type: string; n: number; op: CanvasOp }[]; + expect(relayed.map((e) => e.n)).toEqual(ops.map((_, i) => i)); + expect(relayed.map((e) => e.op)).toEqual(ops); + // The drawer's echo carries its cs (ack). + expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'stream', cs: expect.any(Number) }); + // Regular views carry only the count. + act(g1, { type: 'guess', text: '모르겠어요' }); + const v = lastState(conns[g2]!.msgs).view as CatchmindView; + expect(v.canvas).toBeNull(); + expect(v.canvasCount).toBe(ops.length); + + // g2 drops, more strokes are drawn, g2 comes back → the join view has the whole drawing. + room.disconnect(conns[g2]!); + room.clearTimer(); + for (const op of drawOps(3)) { + clock.advance(50); + act(d, { type: 'draw', op }); + } + const back = conn(g2); + room.join(back, 'player'); + room.clearTimer(); + const sv = lastState(back.msgs).view as CatchmindView; + expect(sv.canvas).toEqual(state().turn.canvas); + expect(sv.canvas!.filter((o) => o.k === 'b')).toHaveLength(3); + expect(pointCount(sv.canvas!)).toBe(pointCount(state().turn.canvas)); + expect(sv.canvasCount).toBe(12); + // Explicit resync too (client gap detection). + room.sendSync(back); + expect((lastState(back.msgs).view as CatchmindView).canvas).toHaveLength(12); + + // Streamed actions are logged in batches with the next snapshot. + const drawLogs = () => db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM room_log WHERE data_json LIKE '%\"draw\"%'").get()!.n; + expect(drawLogs()).toBe(12); // the join wrote the pending ones + clock.advance(50); + act(d, { type: 'draw', op: { k: 'b', s: 4, c: 0, w: 0, e: 0 } }); + expect(drawLogs()).toBe(12); // not yet written + room.flushStream(); + expect(drawLogs()).toBe(13); + const seqs = db.query<{ seq: number }, []>('SELECT seq FROM room_log ORDER BY seq').all().map((r) => r.seq); + expect(new Set(seqs).size).toBe(seqs.length); + const snap = db.query<{ state_json: string }, []>('SELECT state_json FROM room_state').get()!; + expect(JSON.parse(snap.state_json).game.state.turn.canvas).toHaveLength(13); + }); + + test('초당 31번째 그림 메시지는 거절, 출제자가 아니면 거절', () => { + const { clock, conns, state, act } = harness(['A', 'B']); + const d = state().turn.drawer; + const g = state().order.find((p) => p !== d)!; + act(d, { type: 'chooseWord', index: 1 }); + const cs = act(g, { type: 'draw', op: { k: 'b', s: 1, c: 0, w: 0, e: 0 } }); + expect(conns[g]!.msgs.at(-1)).toEqual({ t: 'reject', cs, reason: '그림은 출제자만 그릴 수 있어요.' }); + for (let s = 1; s <= 30; s++) { + clock.advance(10); + act(d, { type: 'draw', op: { k: 'b', s, c: 0, w: 0, e: 0 } }); + } + expect(state().turn.canvas).toHaveLength(30); + const c31 = act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } }); + expect(conns[d]!.msgs.at(-1)).toMatchObject({ t: 'reject', cs: c31, reason: expect.stringContaining('너무 빨리') }); + clock.advance(1000); + act(d, { type: 'draw', op: { k: 'b', s: 31, c: 0, w: 0, e: 0 } }); + expect(state().turn.canvas).toHaveLength(31); + }); + + test('출제자 연결 끊김: 고르기 중이면 바로 자동 선택, 그리기 10초 뒤 턴 종료·정답 공개·다음 출제자', () => { + const { room, clock, conns, state } = harness(['A', 'B', 'C']); + const d = state().turn.drawer; + const g = state().order.find((p) => p !== d)!; + room.disconnect(conns[d]!); + room.clearTimer(); + expect(room.effectiveDeadline()).toBe(clock.now()); + room.onTimer(); + room.clearTimer(); + expect(state().turn.phase).toBe('drawing'); + const word = state().turn.word!.word; + expect(room.effectiveDeadline()).toBe(clock.now() + 10_000); + clock.advance(9_999); + room.onTimer(); + room.clearTimer(); + expect(state().turn.phase).toBe('drawing'); + clock.advance(1); + room.onTimer(); + room.clearTimer(); + expect(state().turn.phase).toBe('reveal'); + expect(state().turn.endReason).toBe('drawerAway'); + expect(state().scores[d]).toBe(0); + expect((lastState(conns[g]!.msgs).view as CatchmindView).word).toBe(word); + clock.advance(5_000); + room.onTimer(); + room.clearTimer(); + expect(state().turn.phase).toBe('choosing'); + expect(state().turn.drawer).not.toBe(d); + expect(state().away).toEqual([d]); + }); + + test('연결된 출제자는 힌트·시간 만료 때만 tick(10초 지나도 끝나지 않음)', () => { + const { room, clock, state, act } = harness(['A', 'B']); + const d = state().turn.drawer; + act(d, { type: 'chooseWord', index: 0 }); + const t0 = state().turn.startedAt; + const ends = state().turn.endsAt; + expect(room.effectiveDeadline()).toBe(t0 + (ends - t0) / 2); + clock.advance(20_000); + room.onTimer(); + room.clearTimer(); + expect(state().turn.phase).toBe('drawing'); + clock.advance(ends - t0); + for (let i = 0; i < 3 && state().turn.phase === 'drawing'; i++) { + room.onTimer(); + room.clearTimer(); + } + expect(state().turn.endReason).toBe('time'); + }); +}); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 3616f09..660d3c3 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -6,7 +6,7 @@ import { ulid } from 'ulid'; import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine'; import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared'; import { sha256Hex } from '../auth/sessions'; -import type { RoomStore } from './store'; +import type { LogEntry, RoomStore } from './store'; import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings'; export interface Conn { @@ -83,6 +83,8 @@ export interface RoomDeps { const CHAT_HISTORY = 30; const ERROR_LIMIT = 3; +/** Streaming actions (drawing) are logged in batches: at most this long between snapshot writes. */ +const STREAM_FLUSH_MS = 1000; export class Room { status: 'lobby' | 'playing' | 'finished' | 'paused' | 'broken' = 'lobby'; @@ -104,6 +106,9 @@ export class Room { chatLog: Extract[] = []; lastActiveAt: number; private timer: ReturnType | null = null; + /** Log entries of streaming actions not yet written (flushed with the next snapshot). */ + private pendingLogs: LogEntry[] = []; + private flushTimer: ReturnType | null = null; private recentCs = new Map(); private lastChatAt = new Map(); private consecutiveErrors = 0; @@ -217,17 +222,39 @@ export class Room { private persist(log: { actorId: string | null; kind: string; data: unknown } | null): void { const nextSeq = this.seq + 1; + const entry = log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null; + const logs = this.pendingLogs.length ? [...this.pendingLogs, ...(entry ? [entry] : [])] : entry; this.deps.store.save( this.id, nextSeq, JSON.stringify(this.snapshot()), { hostId: this.hostId, gameId: this.config.gameId, status: this.status, visibility: this.config.visibility, config: this.config }, - log ? { seq: nextSeq, gameNo: this.gameNo, at: this.now(), ...log } : null, + logs, ); + this.pendingLogs = []; + if (this.flushTimer) clearTimeout(this.flushTimer); + this.flushTimer = null; this.seq = nextSeq; this.lastActiveAt = this.now(); } + /** + * Streaming actions (e.g. 그림 맞히기 strokes, ~20/s) get a seq and a log entry right away, but the snapshot + * is written at most every STREAM_FLUSH_MS (or with the next regular persist) instead of once per message. + */ + private persistStream(log: { actorId: string | null; kind: string; data: unknown }): void { + this.seq += 1; + this.pendingLogs.push({ seq: this.seq, gameNo: this.gameNo, at: this.now(), ...log }); + this.lastActiveAt = this.now(); + this.flushTimer ??= setTimeout(() => this.flushStream(), STREAM_FLUSH_MS); + } + + /** Writes pending streamed log entries and the snapshot. Public for tests and shutdown. */ + flushStream(): void { + this.flushTimer = null; + if (this.pendingLogs.length) this.persist(null); + } + // ---------------------------------------------------------------- views roomView(): RoomView { @@ -264,13 +291,14 @@ export class Room { }; } - private stateMessage(viewer: string, cs?: number): ServerMessage | null { + /** `sync`: full view for (re)join / resync (`syncView`, e.g. the whole drawing). */ + private stateMessage(viewer: string, cs?: number, sync = false): ServerMessage | null { if (!this.game) return null; const def = this.def; const asPlayer = this.game.players.includes(viewer) ? viewer : null; let view: unknown; try { - view = def.view(this.game.state, asPlayer); + view = sync && def.syncView ? def.syncView(this.game.state, asPlayer) : def.view(this.game.state, asPlayer); } catch (err) { this.deps.log?.('error', 'view failed', { room: this.code, viewer, err: String(err) }); return { t: 'error', code: 'view-failed', message: '화면을 그리는 중 오류가 발생했어요.' }; @@ -345,7 +373,7 @@ export class Room { } this.persist(wasSeated ? null : { actorId: u, kind: 'join', data: { as } }); this.broadcastRoom(); - const st = this.stateMessage(u); + const st = this.stateMessage(u, undefined, true); if (st) conn.send(st); if (this.status === 'finished' && this.lastResult) conn.send({ t: 'result', seq: this.seq, result: this.lastResult }); for (const m of this.chatLog) conn.send(m); @@ -670,10 +698,11 @@ export class Room { const def = this.def; let next: { state: unknown; events: unknown[] }; const rng = new SeededRng(g.rng); + const now = this.now(); try { - const v = def.validate(g.state, userId, action); + const v = def.validate(g.state, userId, action, { now }); if (!v.ok) return v.reason; - next = def.apply(g.state, userId, action, { rng, now: this.now() }); + next = def.apply(g.state, userId, action, { rng, now }); } catch (err) { this.consecutiveErrors++; this.deps.log?.('error', 'game apply failed', { room: this.code, game: def.id, seq: this.seq, action, err: String(err), stack: (err as Error)?.stack }); @@ -690,8 +719,15 @@ export class Room { g.state = next.state; g.rng = rng.state(); g.deadlineFloor = null; - this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action }); - this.broadcastState(next.events, userId, cs); + if (!system && def.isStreamAction?.(action)) { + // Relay the (public) events only; views are not recomputed per stroke batch (docs/games/catchmind.md §5.5). + this.persistStream({ actorId: userId, kind: 'act', data: action }); + const msg: ServerMessage = { t: 'stream', seq: this.seq, events: next.events }; + for (const c of this.conns.values()) c.send(c.userId === userId && cs !== undefined ? { ...msg, cs } : msg); + } else { + this.persist({ actorId: userId, kind: system ? 'auto' : 'act', data: action }); + this.broadcastState(next.events, userId, cs); + } const result = def.result(g.state); if (result) this.finishGame(result); else this.scheduleTimer(); @@ -768,12 +804,13 @@ export class Room { const base = def.deadline?.(g.state) ?? null; const notBefore = def.timeoutNotBefore?.(g.state) ?? null; const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state); + const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000; let best: number | null = null; for (const u of timed) { let d = base; const p = this.presence.get(u); if (p && !p.connected) { - const grace = p.since + this.config.graceSec * 1000; + const grace = p.since + graceMs; d = d === null ? grace : Math.min(d, grace); } if (d === null) continue; @@ -807,11 +844,12 @@ export class Room { const base = def.deadline?.(g.state) ?? null; const notBefore = def.timeoutNotBefore?.(g.state) ?? null; const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state); + const graceMs = def.disconnectGraceMs?.(g.state) ?? this.config.graceSec * 1000; for (const u of timed) { let d = base; const p = this.presence.get(u); if (p && !p.connected) { - const grace = p.since + this.config.graceSec * 1000; + const grace = p.since + graceMs; d = d === null ? grace : Math.min(d, grace); } if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor); @@ -833,7 +871,7 @@ export class Room { sendSync(conn: Conn): void { conn.send({ t: 'room', seq: this.seq, room: this.roomView() }); - const st = this.stateMessage(conn.userId); + const st = this.stateMessage(conn.userId, undefined, true); if (st) conn.send(st); } @@ -852,6 +890,7 @@ export class Room { } shutdown(): void { + this.flushStream(); this.clearTimer(); for (const c of this.conns.values()) { c.send({ t: 'bye', reason: 'restart' }); diff --git a/apps/server/src/rooms/store.ts b/apps/server/src/rooms/store.ts index ff7838c..b2532bd 100644 --- a/apps/server/src/rooms/store.ts +++ b/apps/server/src/rooms/store.ts @@ -44,20 +44,20 @@ export class RoomStore { })(); } - /** Writes the log entry (optional), latest room snapshot and room metadata in one transaction. */ + /** Writes the log entries (optional), latest room snapshot and room metadata in one transaction. */ save( roomId: string, seq: number, stateJson: string, meta: { hostId: string; gameId: string; status: string; visibility: string; config: unknown }, - log: LogEntry | null, + log: LogEntry | LogEntry[] | null, now = Date.now(), ): void { this.db.transaction(() => { - if (log) { + for (const e of log === null ? [] : Array.isArray(log) ? log : [log]) { this.db .query('INSERT INTO room_log (room_id, seq, game_no, actor_id, kind, data_json, at) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run(roomId, log.seq, log.gameNo, log.actorId, log.kind, JSON.stringify(log.data ?? null), log.at); + .run(roomId, e.seq, e.gameNo, e.actorId, e.kind, JSON.stringify(e.data ?? null), e.at); } this.db.query('UPDATE room_state SET seq = ?, state_json = ?, updated_at = ? WHERE room_id = ?').run(seq, stateJson, now, roomId); this.db diff --git a/apps/web/src/games/catchmind/index.tsx b/apps/web/src/games/catchmind/index.tsx new file mode 100644 index 0000000..ac8483d --- /dev/null +++ b/apps/web/src/games/catchmind/index.tsx @@ -0,0 +1,1244 @@ +/** 그림 맞히기 화면 (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'; + +// ---------------------------------------------------------------- constants (mirror packages/games/src/catchmind/types.ts) + +const BOARD = 1000; +const PALETTE: [string, string][] = [ + ['#111111', '검정'], + ['#ffffff', '흰색'], + ['#9ca3af', '회색'], + ['#ef4444', '빨강'], + ['#f97316', '주황'], + ['#facc15', '노랑'], + ['#22c55e', '초록'], + ['#38bdf8', '하늘'], + ['#2563eb', '파랑'], + ['#8b5cf6', '보라'], + ['#ec4899', '분홍'], + ['#92400e', '갈색'], +]; +const WIDTHS = [4, 8, 16, 32]; +const WIDTH_KO = ['가늘게', '보통', '굵게', '아주 굵게']; +const REACTIONS: [string, string][] = [ + ['almost', '거의 다 왔어요'], + ['similar', '비슷해요'], + ['no', '아니에요'], +]; +const REACTION_KO: Record = Object.fromEntries(REACTIONS); +const CATEGORY_KO: Record = { + animal: '동물', + food: '음식', + object: '물건', + vehicle: '탈것', + nature: '자연', + place: '장소', + job: '직업', + sport: '운동·놀이', + body: '몸', + season: '계절·명절', + custom: '추가 단어', +}; +const CATEGORY_ICON: Record = { + animal: '🐾', + food: '🍙', + object: '🧸', + vehicle: '🚗', + nature: '🌳', + place: '🏫', + job: '🧑‍🍳', + sport: '⚽', + body: '✋', + season: '🎑', + custom: '✏️', +}; +const CATEGORY_KEYS = Object.keys(CATEGORY_KO).filter((k) => k !== 'custom'); +const GUESS_MAX = 40; +const CHAT_MAX = 100; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '나간 사람'; +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '나간 사람', avatar: null, kind: 'guest' }; + +const CSS = ` +.cm { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 8px; align-self: center; } +.cm-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: space-between; font-weight: 700; } +.cm-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; } +.cm-hint { display: flex; justify-content: center; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 48px; } +.cm-hint .ch { min-width: 40px; height: 44px; padding: 0 4px; border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); display: inline-flex; align-items: center; justify-content: center; + font-size: 1.6rem; font-weight: 900; color: var(--text); } +.cm-hint .ch.sp { background: none; box-shadow: none; min-width: 14px; } +.cm-hint .ch.open { color: var(--primary); } +.cm-hint .answer { font-size: 1.35rem; font-weight: 900; color: var(--success); padding: 4px 14px; border-radius: 12px; background: var(--success-soft); } +.cm-bar { position: relative; height: 10px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } +.cm-bar i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; transition: width .25s linear; } +.cm-stage { display: flex; gap: 12px; align-items: flex-start; justify-content: center; } +.cm-paper { position: relative; width: min(100%, calc(100vh - 290px)); min-width: 280px; aspect-ratio: 1 / 1; border-radius: 14px; background: #fff; box-shadow: var(--shadow); + border: 3px solid var(--border); overflow: hidden; flex: 0 0 auto; } +.cm-paper canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: auto; } +.cm-paper[data-draw='true'] canvas { touch-action: none; cursor: crosshair; } +.cm-over { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 16px; text-align: center; + background: rgba(17,24,39,.55); color: #fff; z-index: 2; } +.cm-over h2 { color: #fff; font-size: 1.5rem; margin: 0; } +.cm-over .word { font-size: 2.2rem; font-weight: 900; background: #fff; color: #111827; padding: 4px 18px; border-radius: 14px; } +.cm-over .pts { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; max-width: 100%; } +.cm-over .pts span { background: rgba(255,255,255,.15); border-radius: 999px; padding: 3px 10px; font-weight: 700; } +.cm-choices { display: grid; gap: 10px; width: min(100%, 360px); } +.cm-choices button { min-height: 64px; font-size: 1.35rem; font-weight: 900; display: flex; align-items: center; justify-content: center; gap: 10px; } +.cm-choices .ic { font-size: 1.6rem; } +.cm-pop { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%); z-index: 3; background: var(--success); color: #fff; font-weight: 900; font-size: 2.4rem; + padding: 10px 28px; border-radius: 20px; box-shadow: 0 8px 24px rgba(0,0,0,.3); animation: cm-pop .5s ease; pointer-events: none; white-space: nowrap; } +.cm-pop small { display: block; font-size: 1rem; text-align: center; } +.cm-tools { display: flex; flex-direction: column; gap: 10px; width: 132px; flex: 0 0 auto; } +.cm-colors { display: grid; grid-template-columns: repeat(3, 36px); gap: 6px; } +.cm-color { width: 36px; height: 36px; border-radius: 50%; border: 2px solid var(--border); padding: 0; cursor: pointer; position: relative; } +.cm-color[aria-pressed='true'] { outline: 3px solid var(--primary); outline-offset: 2px; } +.cm-color:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.cm-row { display: flex; flex-wrap: wrap; gap: 6px; } +.cm-tool { min-width: 40px; min-height: 40px; border-radius: 10px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 800; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; gap: 4px; padding: 0 8px; font-size: .92rem; } +.cm-tool[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); } +.cm-tool:disabled { opacity: .45; cursor: not-allowed; } +.cm-tool:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.cm-dot { display: inline-block; border-radius: 50%; background: currentColor; } +.cm-react { display: grid; gap: 6px; } +.cm-react button { min-height: 40px; font-size: .9rem; } +.cm-strip { display: flex; gap: 8px; overflow-x: auto; padding: 2px 2px 6px; scrollbar-width: thin; } +.cm-strip .p { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 56px; position: relative; font-size: .78rem; font-weight: 700; } +.cm-strip .p .nm { max-width: 64px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cm-strip .p .sc { font-variant-numeric: tabular-nums; } +.cm-badge { position: absolute; top: -4px; right: 2px; width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #fff; } +.cm-badge.ok { background: var(--success); } +.cm-badge.pen { background: var(--primary); } +.cm-score { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; } +.cm-score li { display: flex; align-items: center; gap: 6px; } +.cm-score .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cm-chat { display: flex; flex-direction: column; gap: 8px; background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px; } +.cm-chat-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; overflow-y: auto; min-height: 120px; max-height: 260px; } +.cm-side .cm-chat-list { max-height: 340px; } +.cm-line { font-size: .95rem; line-height: 1.35; word-break: break-word; } +.cm-line b { margin-right: 6px; } +.cm-line.sys { color: var(--success); font-weight: 800; } +.cm-line.close { color: var(--warning); } +.cm-line.blocked { color: var(--text-sub); font-style: italic; } +.cm-line.react { color: var(--primary); font-weight: 700; } +.cm-tag { display: inline-block; border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; background: var(--success-soft); color: var(--success); margin-right: 4px; } +.cm-form { display: flex; gap: 6px; } +.cm-form .input { flex: 1; min-height: 48px; font-size: 1.05rem; } +.cm-close { color: var(--warning); font-weight: 900; font-size: .95rem; animation: cm-shake .4s ease; } +.cm-away { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; background: var(--warning-soft); color: var(--text); border-radius: 12px; padding: 8px 12px; } +@keyframes cm-pop { 0% { transform: translate(-50%, -50%) scale(.5); opacity: 0; } 70% { transform: translate(-50%, -50%) scale(1.08); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } } +@keyframes cm-shake { 0%,100% { transform: none; } 25% { transform: translateX(-4px); } 75% { transform: translateX(4px); } } +@media (max-width: 1099px) { + .cm-stage { flex-direction: column; align-items: stretch; } + .cm-paper { width: 100%; min-width: 0; } + .cm-tools { width: 100%; } + .cm-colors { grid-template-columns: repeat(6, 44px); gap: 8px; justify-content: center; } + .cm-color { width: 44px; height: 44px; } + .cm-row { justify-content: center; flex-wrap: nowrap; gap: 5px; } + .cm-tool { min-width: 44px; min-height: 44px; padding: 0 4px; font-size: 1.1rem; } + .cm-tool .lbl { display: none; } + .cm-react { grid-template-columns: repeat(3, 1fr); } + .cm-chat-form-sticky { position: sticky; bottom: 0; background: var(--surface); padding-top: 4px; z-index: 1; } + .cm-hint .ch { min-width: 34px; height: 40px; font-size: 1.35rem; } +} +@media (prefers-reduced-motion: reduce) { + .cm-pop, .cm-close { animation: none; } + .cm-bar i { transition: none; } +} +`; + +// ---------------------------------------------------------------- drawing model + renderer + +interface Stroke { + s: number; + c: number; + w: number; + e: 0 | 1; + pts: number[]; + closed: boolean; +} + +/** Strokes of the current turn, rebuilt from canvas ops (§5.5). `count` = server ops applied (gap detection). */ +class Drawing { + turn = -1; + count = 0; + strokes: Stroke[] = []; + /** Bumped on undo / clear / reset: the cached layer of finished strokes must be redrawn. */ + epoch = 0; + version = 0; + + reset(turn: number, ops: readonly CanvasOp[]): void { + this.turn = turn; + this.count = 0; + this.strokes = []; + this.epoch++; + for (const op of ops) this.apply(op); + this.version++; + } + + apply(op: CanvasOp): void { + this.count++; + const last = this.strokes.at(-1); + switch (op.k) { + case 'b': + if (last) last.closed = true; + this.strokes.push({ s: op.s, c: op.c, w: op.w, e: op.e, pts: [], closed: false }); + break; + case 'p': { + if (!last || last.s !== op.s) break; + let x = 0; + let y = 0; + for (let i = 0; i + 1 < op.d.length; i += 2) { + x = i === 0 ? op.d[0]! : x + op.d[i]!; + y = i === 0 ? op.d[1]! : y + op.d[i + 1]!; + last.pts.push(x, y); + } + break; + } + case 'e': + if (last && last.s === op.s) last.closed = true; + break; + case 'u': + this.strokes.pop(); + this.epoch++; + break; + case 'x': + this.strokes = []; + this.epoch++; + break; + } + this.version++; + } + + /** Drawer's local echo: points go straight onto the open stroke. */ + addPoint(x: number, y: number): void { + const last = this.strokes.at(-1); + if (last && !last.closed) { + last.pts.push(x, y); + this.version++; + } + } +} + +function paintStroke(ctx: CanvasRenderingContext2D, st: Stroke, k: number): void { + const p = st.pts; + const n = p.length / 2; + if (n === 0) return; + const color = st.e ? '#ffffff' : (PALETTE[st.c]?.[0] ?? '#111111'); + const lw = (WIDTHS[st.w] ?? 8) * k; + if (n === 1) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(p[0]! * k, p[1]! * k, lw / 2, 0, Math.PI * 2); + ctx.fill(); + return; + } + ctx.strokeStyle = color; + ctx.lineWidth = lw; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(p[0]! * k, p[1]! * k); + // Midpoint quadratic smoothing (§5.5 수신 측 렌더링). + for (let i = 1; i < n - 1; i++) { + const mx = ((p[i * 2]! + p[i * 2 + 2]!) / 2) * k; + const my = ((p[i * 2 + 1]! + p[i * 2 + 3]!) / 2) * k; + ctx.quadraticCurveTo(p[i * 2]! * k, p[i * 2 + 1]! * k, mx, my); + } + ctx.lineTo(p[(n - 1) * 2]! * k, p[(n - 1) * 2 + 1]! * k); + ctx.stroke(); +} + +/** Renders `drawing` into the canvas on animation frames; finished strokes are cached on an offscreen layer. */ +function useRenderer(drawing: Drawing, canvasRef: React.RefObject): void { + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const base = document.createElement('canvas'); + let baseCount = 0; + let baseEpoch = -1; + let seen = -1; + let size = 0; + let raf = 0; + const resize = () => { + const px = Math.max(1, Math.round(canvas.getBoundingClientRect().width * Math.min(2, window.devicePixelRatio || 1))); + if (px !== size) { + size = px; + canvas.width = canvas.height = base.width = base.height = px; + baseEpoch = -1; + seen = -1; + } + }; + const frame = () => { + raf = requestAnimationFrame(frame); + if (seen === drawing.version) return; + seen = drawing.version; + const k = size / BOARD; + const bctx = base.getContext('2d')!; + if (baseEpoch !== drawing.epoch || baseCount > drawing.strokes.length) { + bctx.fillStyle = '#ffffff'; + bctx.fillRect(0, 0, size, size); + baseCount = 0; + baseEpoch = drawing.epoch; + } + while (baseCount < drawing.strokes.length && drawing.strokes[baseCount]!.closed) paintStroke(bctx, drawing.strokes[baseCount++]!, k); + const ctx = canvas.getContext('2d')!; + ctx.drawImage(base, 0, 0); + for (let i = baseCount; i < drawing.strokes.length; i++) paintStroke(ctx, drawing.strokes[i]!, k); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(canvas); + raf = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [drawing, canvasRef]); +} + +// ---------------------------------------------------------------- stroke sender (drawer) + +type Queued = { op: Exclude } | { op: { k: 'p'; s: number; q: number }; pts: number[]; born: number }; + +/** + * Batches points (§5.5: every 50 ms or 32 points, stroke begin/end at once) and paces messages with a token bucket + * that stays under the gateway limit (20 msg/s, burst 40) — when short of tokens, points keep merging into the + * pending batch (up to 64) instead of being dropped. + */ +class StrokeSender { + private queue: Queued[] = []; + private tokens = 16; + private last = performance.now(); + private timer: ReturnType | null = null; + private cs = Date.now() * 1000; + readonly sent = new Set(); + stroke = 0; + private batch = 0; + + constructor(private onSendFail: () => void) {} + + begin(c: number, w: number, e: 0 | 1, x: number, y: number): void { + this.stroke += 1; + this.batch = 0; + this.queue.push({ op: { k: 'b', s: this.stroke, c, w, e } }); + this.point(x, y); + this.pump(); + } + + point(x: number, y: number): void { + const tail = this.queue.at(-1); + if (tail && tail.op.k === 'p' && 'pts' in tail && tail.op.s === this.stroke && tail.pts.length < 128) tail.pts.push(x, y); + else this.queue.push({ op: { k: 'p', s: this.stroke, q: this.batch++ }, pts: [x, y], born: performance.now() }); + this.ensureTimer(); + } + + end(): void { + this.queue.push({ op: { k: 'e', s: this.stroke } }); + this.pump(); + } + + simple(k: 'u' | 'x'): void { + this.queue.push({ op: { k } }); + this.pump(); + } + + /** Server state after a (re)sync. */ + resetTo(nextStroke: number): void { + this.queue = []; + this.stroke = nextStroke - 1; + this.batch = 0; + } + + get busy(): boolean { + return this.queue.length > 0; + } + + private ensureTimer(): void { + this.timer ??= setInterval(() => this.pump(), 25); + } + + pump(): void { + const now = performance.now(); + this.tokens = Math.min(20, this.tokens + ((now - this.last) / 1000) * 14); + this.last = now; + while (this.queue.length > 0 && this.tokens >= 1) { + const head = this.queue[0]!; + if ('pts' in head && this.queue.length === 1 && head.pts.length < 64 && now - head.born < 50) break; // still filling + this.queue.shift(); + let op: CanvasOp; + if ('pts' in head) { + const d: number[] = [head.pts[0]!, head.pts[1]!]; + for (let i = 2; i < head.pts.length; i += 2) d.push(head.pts[i]! - head.pts[i - 2]!, head.pts[i + 1]! - head.pts[i - 1]!); + op = { ...head.op, d }; + } else op = head.op; + const cs = ++this.cs; + this.sent.add(cs); + if (this.sent.size > 400) this.sent.delete(this.sent.values().next().value!); + if (!socket.send({ t: 'act', cs, a: { type: 'draw', op } })) { + this.queue = []; + this.onSendFail(); + break; + } + this.tokens -= 1; + } + if (this.queue.length === 0 && this.timer) { + clearInterval(this.timer); + this.timer = null; + } else if (this.queue.length > 0) this.ensureTimer(); + } + + dispose(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } +} + +// ---------------------------------------------------------------- canvas board + +function Paper({ v, me, act, players }: { v: CatchmindView; me: string | null; act: (a: unknown) => void; players: Record }) { + const canvasRef = useRef(null); + const drawingRef = useRef(null as unknown as Drawing); + drawingRef.current ??= new Drawing(); + const drawing = drawingRef.current; + const [tool, setTool] = useState({ c: 0, w: 1, eraser: false }); + const toolRef = useRef(tool); + toolRef.current = tool; + const viewRef = useRef(v); + viewRef.current = v; + const lastSync = useRef(0); + const [strokes, setStrokes] = useState(0); + const countRaf = useRef(0); + const refreshCount = () => { + if (countRaf.current) return; + countRaf.current = requestAnimationFrame(() => { + countRaf.current = 0; + setStrokes(drawing.strokes.length); + }); + }; + const requestSync = () => { + const now = Date.now(); + if (now - lastSync.current < 800) return; + lastSync.current = now; + socket.send({ t: 'sync' }); + }; + const senderRef = useRef(null as unknown as StrokeSender); + senderRef.current ??= new StrokeSender(() => requestSync()); + const sender = senderRef.current; + const amDrawer = v.role === 'drawer'; + useRenderer(drawing, canvasRef); + + // Streamed canvas ops (+ rejects of my own draw messages → resync). + useEffect(() => { + const off = socket.subscribe((m: ServerMessage) => { + if (m.t === 'stream') { + const cur = viewRef.current; + for (const ev of m.events as { type?: string; turn?: number; n?: number; op?: CanvasOp }[]) { + if (ev.type !== 'canvasOp' || ev.turn === undefined || ev.n === undefined || !ev.op) continue; + if (ev.turn > drawing.turn) drawing.reset(ev.turn, []); + if (ev.turn < drawing.turn) continue; + // The drawer already shows its own strokes locally. + if (cur.role === 'drawer' && cur.turnNo === ev.turn) continue; + if (ev.n === drawing.count) drawing.apply(ev.op); + else if (ev.n > drawing.count) requestSync(); + } + refreshCount(); + } else if (m.t === 'reject' && sender.sent.has(m.cs)) { + sender.resetTo(viewRef.current.nextStroke); + requestSync(); + } + }); + return () => { + off(); + }; + }, [drawing, sender]); + + // Views: full drawing on (re)join / resync, otherwise only counts (§5.5). + useEffect(() => { + if (v.canvas) { + drawing.reset(v.turnNo, v.canvas); + sender.resetTo(v.nextStroke); + } else if (v.turnNo !== drawing.turn) { + if (v.turnNo > drawing.turn) { + drawing.reset(v.turnNo, []); + sender.resetTo(v.nextStroke); + if (v.canvasCount > 0) requestSync(); + } + } else if (!amDrawer && v.canvasCount > drawing.count) requestSync(); + else if (amDrawer && !sender.busy && v.canvasCount === 0 && drawing.strokes.length === 0) sender.resetTo(v.nextStroke); + setStrokes(drawing.strokes.length); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [v]); + + useEffect(() => () => sender.dispose(), [sender]); + + // Pointer input for the drawer. + const active = useRef(null); + const lastPt = useRef<[number, number]>([0, 0]); + const toBoard = (e: { clientX: number; clientY: number }): [number, number] => { + const r = canvasRef.current!.getBoundingClientRect(); + const x = Math.round(((e.clientX - r.left) / r.width) * BOARD); + const y = Math.round(((e.clientY - r.top) / r.height) * BOARD); + return [Math.max(0, Math.min(BOARD, x)), Math.max(0, Math.min(BOARD, y))]; + }; + const onDown = (e: React.PointerEvent) => { + if (!viewRef.current.canDraw || active.current !== null || e.button > 0) return; + e.preventDefault(); + canvasRef.current!.setPointerCapture(e.pointerId); + active.current = e.pointerId; + const [x, y] = toBoard(e); + lastPt.current = [x, y]; + const t = toolRef.current; + const ex: 0 | 1 = t.eraser ? 1 : 0; + sender.begin(t.c, t.w, ex, x, y); + drawing.apply({ k: 'b', s: sender.stroke, c: t.c, w: t.w, e: ex }); + drawing.addPoint(x, y); + }; + const onMove = (e: React.PointerEvent) => { + if (active.current !== e.pointerId) return; + const native = e.nativeEvent; + const list = typeof native.getCoalescedEvents === 'function' ? native.getCoalescedEvents() : []; + for (const pe of list.length ? list : [native]) { + const [x, y] = toBoard(pe); + const [lx, ly] = lastPt.current; + if ((x - lx) ** 2 + (y - ly) ** 2 < 4) continue; // §5.5: drop points closer than 2 + lastPt.current = [x, y]; + sender.point(x, y); + drawing.addPoint(x, y); + } + }; + const onUp = (e: React.PointerEvent) => { + if (active.current !== e.pointerId) return; + active.current = null; + sender.end(); + drawing.apply({ k: 'e', s: sender.stroke }); + setStrokes(drawing.strokes.length); + }; + const undo = () => { + if (drawing.strokes.length === 0 || active.current !== null) return; + sender.simple('u'); + drawing.apply({ k: 'u' }); + setStrokes(drawing.strokes.length); + }; + const clear = () => { + if (drawing.strokes.length === 0 || active.current !== null) return; + sender.simple('x'); + drawing.apply({ k: 'x' }); + setStrokes(0); + }; + const save = () => { + const c = canvasRef.current; + if (!c) return; + const a = document.createElement('a'); + a.href = c.toDataURL('image/png'); + a.download = `그림-${v.word ?? v.turnNo}.png`; + a.click(); + }; + + const wide = useWide(); + const strokeLabel = `그림판, 획 ${drawing.strokes.length}개${v.canDraw ? '. 마우스나 손가락으로 그려 주세요' : ''}`; + return ( +
+
+ v.canDraw && e.preventDefault()} + /> + +
+ {amDrawer && v.phase === 'drawing' && ( +
+
+ {PALETTE.map(([hex, name], i) => ( +
+
+ {WIDTHS.map((w, i) => ( + + ))} + + + +
+
+ {REACTIONS.map(([code, label]) => ( + + ))} +
+ {!wide && 그리는 동안에는 채팅 대신 반응 버튼만 쓸 수 있어요.} +
+ )} +
+ ); +} + +const END_TEXT: Record = { + time: '시간이 다 됐어요', + allSolved: '모두 맞혔어요!', + first: '가장 먼저 맞힌 사람이 나왔어요!', + report: '글자를 썼다는 신고로 끝났어요(출제자 0점)', + drawerAway: '출제자의 연결이 끊겼어요', + drawerLeft: '출제자가 나갔어요', +}; + +function Overlay({ v, me, act, players, onSave }: { v: CatchmindView; me: string | null; act: (a: unknown) => void; players: Record; onSave: () => void }) { + const [pop, setPop] = useState(null); + const prevRole = useRef(v.role); + const prevTurn = useRef(v.turnNo); + const popTimer = useRef | null>(null); + useEffect(() => { + if (prevTurn.current === v.turnNo && prevRole.current === 'guesser' && v.role === 'solver') { + setPop(v.solvers.find((s) => s.id === me)?.points ?? 0); + sounds.ding(); + haptic(60); + if (popTimer.current) clearTimeout(popTimer.current); + popTimer.current = setTimeout(() => setPop(null), 1600); + } + prevRole.current = v.role; + prevTurn.current = v.turnNo; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [v.role, v.turnNo, me]); + useEffect(() => () => void (popTimer.current && clearTimeout(popTimer.current)), []); + + const popEl = + pop !== null ? ( +
+ 정답!+{pop}점 +
+ ) : null; + return ( + <> + + {popEl} + + ); +} + +function OverlayMain({ v, act, players, onSave }: { v: CatchmindView; act: (a: unknown) => void; players: Record; onSave: () => void }) { + if (v.phase === 'choosing') { + if (v.choices) { + return ( +
+

그릴 단어를 골라 주세요

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

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

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

정답은

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