M8: 도블 규칙·화면·테스트

- packages/games/src/dobble: 7차 사영평면 덱(57장·57그림, 55장 모드), 카드별 레이아웃 6템플릿,
  미니게임 4종(탑 쌓기·우물·뜨거운 감자·독이 든 선물), 서버 도착 순서 판정과 "늦었어요",
  틀림 잠금(serverTs 기준), 지연 보정 light(60ms 창·보정 상한 80ms), 45초 정체 힌트,
  자리 비움 3회 → 우물·뜨거운 감자 기권, 봇 의심(방장에게만), 순위 점수 3·2·1
- 테스트: §10 체크리스트 전부, 시드 1,000판 + 엔진 randomPlayout(카드 보존·비공개 누출·결정성·onTimeout 합법),
  방 단위 테스트(같은 ms 두 정답, 시스템 행동 거절, 지연 보정 창, 끊긴 사람 기권)
- 화면 apps/web/src/games/dobble: 자체 제작 SVG 그림 57종, 두 원형 카드(PC 좌우·휴대폰 위아래),
  누르는 즉시 판정 표시(찾았어요·틀렸어요 흔들림+잠금 원·늦었어요), 카드가 막 바뀐 직후 탭은 늦음 처리,
  상대 고르기(뜨거운 감자·독이 든 선물), 핑 표시, 키보드 조작, 라이트/다크, 움직임 줄이기
- 등록: GAMES, CATALOG(도블), 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구, 탑 쌓기 55장을 실제 탭으로 끝까지(오답 1회 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 00:33:29 +09:00
parent bc199de10b
commit 953ae90546
13 changed files with 3674 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
/** 도블 through a real Room: arrival order, "늦었어요" rejects, the latency window and away players (docs/games/dobble.md §5.3, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { answerArt, pairsFor, type DobbleOptions, type DobbleState } from '@bg/games/dobble';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[], opts: Partial<DobbleOptions> = {}) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { dobble: GAMES.dobble! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'dobble',
options: games.dobble.optionsSchema.parse(opts),
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: 'dobble', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as DobbleState;
/** The correct claim of `p` (first pair), as the client would send it. */
const right = (p: string) => {
const pair = pairsFor(state(), p)[0]!;
return { type: 'claim', base: pair.base, symbol: answerArt(state(), p, pair)!, ...(pair.target ? { target: pair.target } : {}), ...(pair.mine !== null ? { mine: pair.mine } : {}) };
};
return { room, clock, conns, state, right };
}
describe('도블 room flow', () => {
test('같은 가운데 카드에 같은 ms 두 정답: 큐에서 먼저 처리된 사람만 가져가고, 나중 사람은 "늦었어요" 거절(잠금 없음)', () => {
const { room, conns, state, right } = harness(['A', 'B', 'C']);
const a = right('A');
const b = right('B');
const towerB = state().piles.B!.length;
room.act('B', 1, b);
room.act('A', 1, a);
room.clearTimer();
expect(state().piles.B).toHaveLength(towerB + 1);
expect(state().piles.A).toHaveLength(1);
expect(state().lockedUntil.A).toBe(0);
expect(conns.A!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 찾았어요.' }]);
// Nobody's view carries the answer or hidden cards; the hit is public.
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastHit: { player: string } }).lastHit).toMatchObject({ player: 'B' });
});
test('클라이언트가 tick/resolve 같은 시스템 행동을 보내면 거절', () => {
const { room, conns } = harness(['A', 'B']);
room.act('A', 1, { type: 'tick' });
room.act('A', 2, { type: 'leave' });
expect(conns.A!.msgs.filter((m) => m.t === 'reject').map((m) => (m as { reason: string }).reason)).toEqual(['잘못된 요청이에요.', '잘못된 요청이에요.']);
});
test('지연 보정 light: 창(60ms)은 방 타이머가 닫고, 보정 시각이 빠른 사람이 이겨요', () => {
const { room, clock, state, right } = harness(['A', 'B'], { latencyComp: 'light' });
// RTT averages (normally fed by the claims): A 20ms → 10ms credit, B 200ms → 80ms credit (cap).
const s = state();
s.rtt.A = 20;
s.rtt.B = 200;
const a = right('A');
const b = right('B');
room.act('A', 1, a);
clock.advance(40);
room.act('B', 1, b);
expect(state().pendingWindow?.claims).toHaveLength(2);
expect(room.effectiveDeadline()).toBe(clock.now() - 40 + 60);
clock.set(room.effectiveDeadline()!);
room.onTimer();
room.clearTimer();
expect(state().pendingWindow).toBeNull();
// B arrived 40ms later but its adjusted time is 30ms earlier (credit 80 vs 10).
expect(state().lastHit).toMatchObject({ player: 'B' });
});
test('우물: 연결이 끊긴 사람은 유예(60초) 뒤 10초 간격으로 세 번 시간 초과 → 기권, 남은 사람끼리 계속(바쁜 반복 없음)', () => {
const { room, clock, conns, state, right } = harness(['A', 'B', 'C'], { miniGame: 'well' });
const t0 = clock.now();
room.disconnect(conns.C!);
room.clearTimer();
let fired = 0;
while (state().status.C === 'playing' && fired < 50) {
const d = room.effectiveDeadline()!;
if (d > clock.now()) clock.set(d);
room.onTimer();
room.clearTimer();
fired++;
// The others keep finding pictures now and then.
if (fired % 3 === 0 && pairsFor(state(), 'A').length) room.act('A', fired, right('A'));
room.clearTimer();
}
expect(state().status.C).toBe('forfeit');
const took = clock.now() - t0;
expect(took).toBeGreaterThanOrEqual(60_000 + 20_000);
expect(took).toBeLessThanOrEqual(60_000 + 45_000);
expect(fired).toBeLessThan(20);
expect(room.status).toBe('playing');
expect(state().phase).toBe('playing');
});
});

View File

@@ -0,0 +1,8 @@
import { expect, test } from 'bun:test';
import { SYMBOL_NAMES } from '@bg/games/dobble';
import { ICONS, ICON_NAMES } from './art';
test('도블 그림 57종: 그림과 이름이 규칙 모듈과 같은 순서', () => {
expect(ICONS).toHaveLength(57);
expect(ICON_NAMES).toEqual([...SYMBOL_NAMES]);
});

View File

@@ -0,0 +1,556 @@
/**
* 도블 그림 57종 (docs/games/dobble.md §3.2) — all drawn here as simple SVG in a 100×100 box.
* Every picture has its own silhouette with a dark outline, so colour is only a secondary cue (colour-blind friendly).
* Index = icon id (same order as SYMBOL_NAMES in @bg/games/dobble).
*/
import type { ReactNode } from 'react';
const O = '#1f2937';
/** Outline props for filled shapes. */
const L = { stroke: O, strokeWidth: 3.5, strokeLinejoin: 'round', strokeLinecap: 'round' } as const;
/** Thick stroked lines (no fill). */
const W = (color: string, w = 6) => ({ stroke: color, strokeWidth: w, strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none' }) as const;
const rays = (n: number, r1: number, r2: number, cx = 50, cy = 50, a0 = 0) =>
Array.from({ length: n }, (_, i) => {
const a = a0 + (i * 2 * Math.PI) / n;
return `M${(cx + Math.cos(a) * r1).toFixed(1)} ${(cy + Math.sin(a) * r1).toFixed(1)} L${(cx + Math.cos(a) * r2).toFixed(1)} ${(cy + Math.sin(a) * r2).toFixed(1)}`;
}).join(' ');
const starPts = (n: number, ro: number, ri: number, cx = 50, cy = 52) =>
Array.from({ length: n * 2 }, (_, i) => {
const a = -Math.PI / 2 + (i * Math.PI) / n;
const r = i % 2 ? ri : ro;
return `${(cx + Math.cos(a) * r).toFixed(1)},${(cy + Math.sin(a) * r).toFixed(1)}`;
}).join(' ');
/** Korean names, same order as SYMBOL_NAMES in @bg/games/dobble (kept here so the screen does not pull in the rules module; a test checks they match). */
export const ICON_NAMES = [
'사과', '해', '달', '별', '하트', '물방울', '불꽃', '눈송이', '번개', '구름',
'나무', '꽃', '나뭇잎', '선인장', '버섯', '당근', '레몬', '체리', '고양이', '강아지',
'물고기', '새', '거북이', '무당벌레', '거미', '공룡', '토끼', '펭귄', '열쇠', '자물쇠',
'시계', '전구', '연필', '가위', '안경', '우산', '모자', '왕관', '종', '닻',
'자전거', '자동차', '로켓', '배', '공', '주사위', '음표', '카메라', '집', '성',
'선물상자', '촛불', '망치', '느낌표', '물음표', '눈사람', '무지개',
];
export const ICONS: (() => ReactNode)[] = [
// 0 사과
() => (
<>
<path d="M50 30 C36 18 13 26 14 52 C15 76 33 93 50 84 C67 93 85 76 86 52 C87 26 64 18 50 30 Z" fill="#ef4444" {...L} />
<path d="M50 30 L55 10" {...W('#78350f', 5)} />
<path d="M57 18 C64 6 78 8 80 12 C74 22 64 22 57 18 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<path d="M28 44 C30 38 34 35 38 34" {...W('#fecaca', 4)} />
</>
),
// 1 해
() => (
<>
<path d={rays(8, 28, 44)} {...W('#f59e0b', 7)} />
<circle cx="50" cy="50" r="22" fill="#fbbf24" {...L} />
</>
),
// 2 달
() => <path d="M60 10 A40 40 0 1 0 90 64 A31 31 0 1 1 60 10 Z" fill="#fde047" {...L} />,
// 3 별
() => <polygon points={starPts(5, 44, 19)} fill="#a78bfa" {...L} />,
// 4 하트
() => <path d="M50 86 C20 64 8 48 10 32 C12 16 32 8 50 26 C68 8 88 16 90 32 C92 48 80 64 50 86 Z" fill="#ec4899" {...L} />,
// 5 물방울
() => (
<>
<path d="M50 8 C44 20 22 44 22 64 A28 28 0 0 0 78 64 C78 44 56 20 50 8 Z" fill="#38bdf8" {...L} />
<path d="M36 62 C36 70 40 76 46 78" {...W('#e0f2fe', 5)} />
</>
),
// 6 불꽃
() => (
<>
<path d="M50 92 C26 92 16 74 22 56 C26 44 34 40 34 26 C44 32 46 40 46 46 C52 36 54 22 50 8 C70 20 84 42 80 64 C78 82 66 92 50 92 Z" fill="#f97316" {...L} />
<path d="M50 88 C38 88 34 78 38 70 C41 64 46 62 46 54 C54 60 62 68 60 78 C59 84 55 88 50 88 Z" fill="#fde047" />
</>
),
// 7 눈송이
() => (
<>
<path d={rays(6, 0, 42, 50, 50, Math.PI / 2)} {...W('#0284c7', 6)} />
<path d={[0, 1, 2, 3, 4, 5].map((i) => {
const a = Math.PI / 2 + (i * Math.PI) / 3;
const bx = 50 + Math.cos(a) * 26;
const by = 50 + Math.sin(a) * 26;
const p = (d: number) => `${(bx + Math.cos(a + d) * 12).toFixed(1)} ${(by + Math.sin(a + d) * 12).toFixed(1)}`;
return `M${p(0.9)} L${bx.toFixed(1)} ${by.toFixed(1)} L${p(-0.9)}`;
}).join(' ')} {...W('#0284c7', 5)} />
</>
),
// 8 번개
() => <polygon points="60,6 20,56 46,56 36,94 82,40 56,40 70,6" fill="#eab308" {...L} />,
// 9 구름
() => <path d="M24 76 C10 76 6 60 18 54 C14 40 30 30 40 38 C44 22 66 20 72 36 C86 32 96 48 88 58 C96 66 90 78 78 76 Z" fill="#cbd5e1" {...L} />,
// 10 나무 (pine)
() => (
<>
<rect x="44" y="76" width="12" height="18" fill="#92400e" {...L} />
<polygon points="50,6 24,40 38,40 18,62 34,62 12,80 88,80 66,62 82,62 62,40 76,40" fill="#16a34a" {...L} />
</>
),
// 11 꽃
() => (
<>
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 24} cy={50 + Math.sin(a) * 24} r="17" fill="#f472b6" stroke={O} strokeWidth="3" />;
})}
<circle cx="50" cy="50" r="13" fill="#fde047" stroke={O} strokeWidth="3" />
</>
),
// 12 나뭇잎
() => (
<>
<path d="M14 86 C14 40 40 12 88 12 C88 60 62 86 14 86 Z" fill="#4ade80" {...L} />
<path d="M14 86 L70 30 M40 60 L40 44 M54 46 L54 32 M40 60 L56 60 M54 46 L68 46" {...W('#166534', 3.5)} />
</>
),
// 13 선인장
() => (
<>
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W(O, 22)} />
<path d="M50 90 L50 16 M50 58 L28 58 L28 36 M50 48 L72 48 L72 28" {...W('#22c55e', 15)} />
<path d="M44 30 L40 28 M56 40 L60 38 M44 72 L40 70 M56 78 L60 76" {...W(O, 2.5)} />
<rect x="30" y="86" width="40" height="10" rx="3" fill="#c2410c" {...L} />
</>
),
// 14 버섯
() => (
<>
<path d="M40 54 L36 88 C36 92 64 92 64 88 L60 54 Z" fill="#fef3c7" {...L} />
<path d="M8 56 C8 26 28 12 50 12 C72 12 92 26 92 56 Z" fill="#dc2626" {...L} />
<circle cx="32" cy="34" r="6" fill="#fff" />
<circle cx="56" cy="24" r="5" fill="#fff" />
<circle cx="70" cy="42" r="6" fill="#fff" />
<circle cx="46" cy="44" r="4" fill="#fff" />
</>
),
// 15 당근
() => (
<>
<path d="M50 30 C44 18 34 10 26 10 M50 30 C50 16 52 8 56 4 M50 30 C58 18 70 12 78 14" {...W('#16a34a', 7)} />
<path d="M32 32 C40 26 60 26 68 32 L52 94 C51 97 49 97 48 94 Z" fill="#f97316" {...L} />
<path d="M40 46 L48 46 M54 60 L60 60 M44 72 L50 72" {...W('#9a3412', 3)} />
</>
),
// 16 레몬
() => (
<>
<path d="M6 50 C14 46 18 22 50 20 C82 22 86 46 94 50 C86 54 82 78 50 80 C18 78 14 54 6 50 Z" fill="#fde047" {...L} />
<path d="M30 40 C34 34 40 32 46 31" {...W('#fefce8', 5)} />
</>
),
// 17 체리
() => (
<>
<path d="M30 66 C34 40 46 22 62 10 M70 62 C66 40 64 22 62 10" {...W('#15803d', 5)} />
<path d="M62 10 C72 4 86 8 88 14 C78 20 68 18 62 10 Z" fill="#22c55e" {...L} strokeWidth={2.5} />
<circle cx="28" cy="72" r="18" fill="#dc2626" stroke={O} strokeWidth="3.5" />
<circle cx="72" cy="70" r="18" fill="#b91c1c" stroke={O} strokeWidth="3.5" />
<circle cx="22" cy="66" r="4" fill="#fecaca" />
<circle cx="66" cy="64" r="4" fill="#fecaca" />
</>
),
// 18 고양이
() => (
<>
<path d="M16 40 L18 8 L40 26 C46 24 54 24 60 26 L82 8 L84 40 C90 52 88 82 50 86 C12 82 10 52 16 40 Z" fill="#fb923c" {...L} />
<ellipse cx="36" cy="52" rx="5" ry="7" fill={O} />
<ellipse cx="64" cy="52" rx="5" ry="7" fill={O} />
<path d="M46 64 L54 64 L50 69 Z" fill="#be185d" />
<path d="M8 60 L32 64 M8 72 L32 70 M92 60 L68 64 M92 72 L68 70" {...W(O, 2.5)} />
</>
),
// 19 강아지
() => (
<>
<ellipse cx="50" cy="54" rx="28" ry="32" fill="#d6a46b" {...L} />
<path d="M26 30 C10 30 6 52 12 70 C20 72 26 60 28 46 Z M74 30 C90 30 94 52 88 70 C80 72 74 60 72 46 Z" fill="#78350f" {...L} />
<circle cx="40" cy="50" r="4.5" fill={O} />
<circle cx="60" cy="50" r="4.5" fill={O} />
<ellipse cx="50" cy="66" rx="8" ry="6" fill={O} />
<path d="M50 72 L50 78 M42 80 C46 84 54 84 58 80" {...W(O, 3)} />
</>
),
// 20 물고기
() => (
<>
<path d="M8 50 C24 24 58 22 74 50 C58 78 24 76 8 50 Z" fill="#14b8a6" {...L} />
<path d="M72 50 L94 30 L94 70 Z" fill="#0f766e" {...L} />
<circle cx="26" cy="46" r="5" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d="M40 36 C46 44 46 56 40 64" {...W('#99f6e4', 3.5)} />
</>
),
// 21 새
() => (
<>
<path d="M20 62 C20 36 40 24 58 30 C64 20 76 18 84 26 L96 30 L84 36 C84 64 66 80 44 80 L14 86 L26 72 C22 70 20 66 20 62 Z" fill="#60a5fa" {...L} />
<circle cx="74" cy="30" r="3.5" fill={O} />
<path d="M34 54 C44 46 58 50 62 60 C52 66 40 64 34 54 Z" fill="#1d4ed8" {...L} strokeWidth={2.5} />
</>
),
// 22 거북이
() => (
<>
<circle cx="86" cy="58" r="9" fill="#86efac" stroke={O} strokeWidth="3" />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W(O, 11)} />
<path d="M20 70 L16 84 M40 72 L38 86 M62 72 L64 86 M78 70 L82 82" {...W('#86efac', 6)} />
<path d="M10 70 C10 36 30 22 48 22 C68 22 84 36 84 70 Z" fill="#15803d" {...L} />
<path d="M48 32 L60 42 L56 58 L40 58 L36 42 Z M48 32 L48 24 M60 42 L76 40 M56 58 L64 70 M40 58 L32 70 M36 42 L20 42" {...W('#bbf7d0', 3)} />
</>
),
// 23 무당벌레
() => (
<>
<path d="M28 22 L20 10 M72 22 L80 10" {...W(O, 3.5)} />
<circle cx="50" cy="28" r="16" fill={O} />
<circle cx="50" cy="58" r="34" fill="#ef4444" stroke={O} strokeWidth="3.5" />
<path d="M50 26 L50 92" {...W(O, 3.5)} />
<circle cx="34" cy="48" r="6" fill={O} />
<circle cx="66" cy="48" r="6" fill={O} />
<circle cx="30" cy="70" r="5" fill={O} />
<circle cx="70" cy="70" r="5" fill={O} />
</>
),
// 24 거미
() => (
<>
<path d="M50 4 L50 30" {...W('#9ca3af', 2)} />
<path d="M40 50 L22 34 L12 44 M40 56 L16 52 L8 64 M42 62 L22 72 L18 88 M44 66 L36 82 L36 94 M60 50 L78 34 L88 44 M60 56 L84 52 L92 64 M58 62 L78 72 L82 88 M56 66 L64 82 L64 94" {...W(O, 4)} />
<circle cx="50" cy="38" r="10" fill="#374151" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="62" rx="15" ry="18" fill="#4b5563" stroke={O} strokeWidth="3" />
<circle cx="46" cy="36" r="2.5" fill="#fff" />
<circle cx="54" cy="36" r="2.5" fill="#fff" />
</>
),
// 25 공룡
() => (
<path
d="M88 16 C80 8 70 10 68 18 L64 46 C50 40 32 42 22 54 C14 62 8 64 2 62 C8 72 18 74 26 70 L26 88 L36 88 L38 74 L54 74 L56 88 L66 88 L66 66 C72 58 74 46 76 26 L86 26 C92 24 92 20 88 16 Z"
fill="#65a30d"
{...L}
/>
),
// 26 토끼
() => (
<>
<path d="M38 46 C28 30 26 6 34 4 C42 2 46 26 46 44 Z M62 46 C72 30 74 6 66 4 C58 2 54 26 54 44 Z" fill="#f5f5f4" {...L} />
<path d="M38 40 C33 28 32 14 35 10 M62 40 C67 28 68 14 65 10" {...W('#f9a8d4', 4)} />
<circle cx="50" cy="64" r="26" fill="#f5f5f4" stroke={O} strokeWidth="3.5" />
<circle cx="40" cy="60" r="4" fill={O} />
<circle cx="60" cy="60" r="4" fill={O} />
<path d="M46 70 L54 70 L50 75 Z" fill="#ec4899" />
</>
),
// 27 펭귄
() => (
<>
<ellipse cx="50" cy="54" rx="30" ry="40" fill="#111827" stroke={O} strokeWidth="3" />
<ellipse cx="50" cy="60" rx="20" ry="30" fill="#f8fafc" />
<circle cx="42" cy="34" r="4" fill="#fff" />
<circle cx="58" cy="34" r="4" fill="#fff" />
<circle cx="42" cy="34" r="2" fill={O} />
<circle cx="58" cy="34" r="2" fill={O} />
<path d="M44 42 L56 42 L50 50 Z" fill="#f97316" {...L} strokeWidth={2} />
<path d="M32 94 L44 94 L40 88 Z M56 94 L68 94 L60 88 Z" fill="#f97316" {...L} strokeWidth={2} />
</>
),
// 28 열쇠
() => (
<>
<circle cx="28" cy="50" r="18" fill="#f59e0b" stroke={O} strokeWidth="3.5" />
<circle cx="28" cy="50" r="7" fill="#fffbeb" stroke={O} strokeWidth="3" />
<path d="M46 44 L92 44 L92 56 L84 56 L84 66 L76 66 L76 56 L68 56 L68 64 L60 64 L60 56 L46 56 Z" fill="#f59e0b" {...L} />
</>
),
// 29 자물쇠
() => (
<>
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W(O, 14)} />
<path d="M30 46 L30 32 C30 6 70 6 70 32 L70 46" {...W('#94a3b8', 8)} />
<rect x="18" y="44" width="64" height="48" rx="8" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill={O} />
<path d="M50 64 L50 80" {...W(O, 6)} />
</>
),
// 30 시계
() => (
<>
<circle cx="50" cy="50" r="42" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="50" r="33" fill="#fff" stroke={O} strokeWidth="2.5" />
<path d={rays(12, 27, 31)} {...W(O, 3)} />
<path d="M50 50 L50 28 M50 50 L66 58" {...W(O, 5)} />
<circle cx="50" cy="50" r="4" fill="#dc2626" />
</>
),
// 31 전구
() => (
<>
<path d="M38 70 C38 58 18 50 20 32 C22 14 36 6 50 6 C64 6 78 14 80 32 C82 50 62 58 62 70 Z" fill="#fde047" {...L} />
<path d="M44 66 L44 50 L50 56 L56 50 L56 66" {...W('#ca8a04', 3)} />
<rect x="36" y="70" width="28" height="18" rx="3" fill="#9ca3af" {...L} />
<path d="M36 76 L64 76 M36 82 L64 82" {...W(O, 2.5)} />
<path d="M44 88 L56 88 L52 96 L48 96 Z" fill={O} />
</>
),
// 32 연필
() => (
<g transform="rotate(-45 50 50)">
<rect x="14" y="38" width="16" height="24" rx="4" fill="#f9a8d4" {...L} />
<rect x="28" y="38" width="8" height="24" fill="#9ca3af" {...L} />
<rect x="36" y="38" width="36" height="24" fill="#facc15" {...L} />
<path d="M72 38 L94 50 L72 62 Z" fill="#fde68a" {...L} />
<path d="M86 46 L94 50 L86 54 Z" fill={O} />
</g>
),
// 33 가위
() => (
<>
<path d="M36 62 L82 12 M64 62 L18 12" {...W(O, 10)} />
<path d="M36 62 L82 12 M64 62 L18 12" {...W('#cbd5e1', 5)} />
<circle cx="30" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="30" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="70" cy="76" r="13" fill="none" stroke={O} strokeWidth="9" />
<circle cx="70" cy="76" r="13" fill="none" stroke="#ef4444" strokeWidth="5" />
<circle cx="50" cy="45" r="4" fill={O} />
</>
),
// 34 안경
() => (
<>
<path d="M8 40 L4 30 M92 40 L96 30 M42 50 C46 44 54 44 58 50" {...W(O, 4)} />
<circle cx="26" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<circle cx="74" cy="52" r="17" fill="#bae6fd" stroke={O} strokeWidth="5" />
<path d="M18 46 L24 40 M66 46 L72 40" {...W('#fff', 3.5)} />
</>
),
// 35 우산
() => (
<>
<path d="M50 50 L50 82 C50 92 36 92 36 84" {...W(O, 6)} />
<path d="M6 52 C6 26 28 10 50 10 C72 10 94 26 94 52 C88 46 80 46 72 52 C66 46 56 46 50 52 C44 46 34 46 28 52 C20 46 12 46 6 52 Z" fill="#8b5cf6" {...L} />
<path d="M50 10 L50 50 M28 52 C30 30 40 16 50 10 M72 52 C70 30 60 16 50 10" {...W('#ddd6fe', 2.5)} />
</>
),
// 36 모자 (top hat)
() => (
<>
<path d="M28 70 L32 14 L68 14 L72 70 Z" fill="#1f2937" {...L} />
<path d="M30 56 L70 56 L71 68 L29 68 Z" fill="#dc2626" />
<ellipse cx="50" cy="74" rx="44" ry="10" fill="#374151" {...L} />
</>
),
// 37 왕관
() => (
<>
<path d="M10 30 L28 54 L50 18 L72 54 L90 30 L82 80 L18 80 Z" fill="#facc15" {...L} />
<rect x="18" y="80" width="64" height="10" fill="#eab308" {...L} />
<circle cx="50" cy="62" r="7" fill="#dc2626" stroke={O} strokeWidth="2.5" />
<circle cx="30" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
<circle cx="70" cy="68" r="5" fill="#2563eb" stroke={O} strokeWidth="2.5" />
</>
),
// 38 종
() => (
<>
<circle cx="50" cy="86" r="8" fill="#92400e" stroke={O} strokeWidth="3" />
<path d="M14 80 C22 72 22 60 24 44 C26 24 38 14 50 14 C62 14 74 24 76 44 C78 60 78 72 86 80 Z" fill="#f59e0b" {...L} />
<path d="M50 14 L50 6" {...W(O, 6)} />
<path d="M34 66 C34 50 36 36 44 28" {...W('#fde68a', 4)} />
</>
),
// 39 닻
() => (
<>
<circle cx="50" cy="16" r="9" fill="none" stroke="#1e3a8a" strokeWidth="6" />
<path d="M50 25 L50 88 M32 38 L68 38 M14 58 C16 78 32 88 50 88 C68 88 84 78 86 58 M14 58 L8 66 M14 58 L22 64 M86 58 L92 66 M86 58 L78 64" {...W('#1e3a8a', 7)} />
</>
),
// 40 자전거
() => (
<>
<circle cx="22" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<circle cx="78" cy="66" r="17" fill="none" stroke={O} strokeWidth="6" />
<path d="M22 66 L40 40 L66 40 L78 66 M40 40 L50 66 L66 40 M50 66 L22 66 M36 30 L48 30 M40 40 L37 30 M66 40 L62 26 L72 24" {...W('#e11d48', 5)} />
<circle cx="50" cy="66" r="4" fill={O} />
</>
),
// 41 자동차
() => (
<>
<path d="M6 66 L8 50 L24 46 L34 28 L68 28 L80 46 L94 50 L94 66 Z" fill="#2563eb" {...L} />
<path d="M38 34 L62 34 L72 46 L30 46 Z" fill="#bae6fd" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 46" {...W(O, 2.5)} />
<circle cx="26" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="74" cy="68" r="11" fill="#374151" stroke={O} strokeWidth="3" />
<circle cx="26" cy="68" r="4" fill="#d1d5db" />
<circle cx="74" cy="68" r="4" fill="#d1d5db" />
</>
),
// 42 로켓
() => (
<>
<path d="M36 66 L18 84 L20 62 Z M64 66 L82 84 L80 62 Z" fill="#dc2626" {...L} />
<path d="M42 80 L50 96 L58 80 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 4 C66 18 70 40 66 80 L34 80 C30 40 34 18 50 4 Z" fill="#e5e7eb" {...L} />
<path d="M50 4 C58 10 62 18 64 26 L36 26 C38 18 42 10 50 4 Z" fill="#dc2626" {...L} />
<circle cx="50" cy="46" r="9" fill="#38bdf8" stroke={O} strokeWidth="3" />
</>
),
// 43 배 (sailboat)
() => (
<>
<path d="M50 8 L50 66" {...W(O, 4)} />
<path d="M54 12 L84 58 L54 58 Z" fill="#f8fafc" {...L} />
<path d="M46 22 L22 58 L46 58 Z" fill="#fbbf24" {...L} />
<path d="M6 66 L94 66 L80 86 L20 86 Z" fill="#b45309" {...L} />
</>
),
// 44 공 (soccer ball)
() => (
<>
<circle cx="50" cy="50" r="42" fill="#fff" stroke={O} strokeWidth="3.5" />
<polygon points={starPts(5, 14, 14, 50, 50).split(' ').filter((_, i) => i % 2 === 0).join(' ')} fill={O} />
<path d={rays(5, 14, 30, 50, 50, -Math.PI / 2)} {...W(O, 3)} />
{[0, 1, 2, 3, 4].map((i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / 5;
return <circle key={i} cx={50 + Math.cos(a) * 36} cy={50 + Math.sin(a) * 36} r="7" fill={O} />;
})}
</>
),
// 45 주사위
() => (
<>
<rect x="12" y="12" width="76" height="76" rx="14" fill="#fff" {...L} />
{[
[30, 30],
[70, 30],
[50, 50],
[30, 70],
[70, 70],
].map(([x, y]) => (
<circle key={`${x}${y}`} cx={x} cy={y} r="7.5" fill="#dc2626" />
))}
</>
),
// 46 음표
() => (
<>
<path d="M62 72 L62 10 C70 22 86 26 84 46 C80 34 72 32 62 30" {...W(O, 7)} />
<ellipse cx="46" cy="74" rx="18" ry="13" transform="rotate(-24 46 74)" fill={O} />
</>
),
// 47 카메라
() => (
<>
<path d="M34 24 L40 14 L60 14 L66 24 Z" fill="#4b5563" {...L} />
<rect x="8" y="24" width="84" height="58" rx="10" fill="#6b7280" {...L} />
<circle cx="50" cy="53" r="20" fill="#e5e7eb" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="53" r="11" fill="#1e3a8a" />
<circle cx="46" cy="49" r="3" fill="#fff" />
<rect x="72" y="32" width="12" height="7" rx="2" fill="#fde047" stroke={O} strokeWidth="2" />
</>
),
// 48 집
() => (
<>
<rect x="20" y="46" width="60" height="44" fill="#fde68a" {...L} />
<path d="M8 50 L50 12 L92 50 Z" fill="#dc2626" {...L} />
<rect x="42" y="64" width="16" height="26" fill="#92400e" {...L} />
<rect x="26" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
<rect x="62" y="56" width="12" height="12" fill="#bae6fd" stroke={O} strokeWidth="2.5" />
</>
),
// 49 성
() => (
<>
<path d="M8 92 L8 30 L14 30 L14 22 L22 22 L22 30 L28 30 L28 22 L36 22 L36 46 L64 46 L64 22 L72 22 L72 30 L78 30 L78 22 L86 22 L86 30 L92 30 L92 92 Z" fill="#a8a29e" {...L} />
<path d="M40 92 L40 72 C40 62 60 62 60 72 L60 92 Z" fill="#57534e" {...L} />
<path d="M50 46 L50 8 M50 8 L66 14 L50 20" fill="#ef4444" {...L} strokeWidth={3} />
<rect x="18" y="44" width="8" height="12" fill={O} />
<rect x="74" y="44" width="8" height="12" fill={O} />
</>
),
// 50 선물상자
() => (
<>
<rect x="14" y="40" width="72" height="50" fill="#16a34a" {...L} />
<rect x="8" y="28" width="84" height="16" fill="#22c55e" {...L} />
<path d="M44 28 L56 28 L56 90 L44 90 Z" fill="#facc15" {...L} strokeWidth={2.5} />
<path d="M50 28 C38 8 18 14 26 26 C30 30 40 30 50 28 C60 30 70 30 74 26 C82 14 62 8 50 28 Z" fill="#facc15" {...L} />
</>
),
// 51 촛불
() => (
<>
<path d="M50 8 C40 20 40 32 50 34 C60 32 60 20 50 8 Z" fill="#f97316" {...L} strokeWidth={2.5} />
<path d="M50 34 L50 40" {...W(O, 3)} />
<rect x="36" y="40" width="28" height="48" rx="3" fill="#f8fafc" {...L} />
<path d="M36 50 C42 50 42 60 46 60 C50 60 50 50 54 50" {...W('#e0e7ff', 4)} />
<ellipse cx="50" cy="90" rx="30" ry="6" fill="#64748b" {...L} />
</>
),
// 52 망치
() => (
<g transform="rotate(-35 50 50)">
<rect x="44" y="32" width="12" height="62" rx="4" fill="#b45309" {...L} />
<path d="M18 14 L76 14 L82 20 L82 32 L18 32 C12 32 10 26 12 22 Z" fill="#6b7280" {...L} />
</g>
),
// 53 느낌표
() => (
<>
<path d="M38 8 L62 8 L56 64 L44 64 Z" fill="#ef4444" {...L} />
<circle cx="50" cy="82" r="10" fill="#ef4444" stroke={O} strokeWidth="3.5" />
</>
),
// 54 물음표
() => (
<>
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W(O, 18)} />
<path d="M26 34 C26 14 40 6 52 6 C68 6 78 18 76 32 C74 46 56 48 56 64" {...W('#3b82f6', 11)} />
<circle cx="56" cy="84" r="10" fill="#3b82f6" stroke={O} strokeWidth="3.5" />
</>
),
// 55 눈사람
() => (
<>
<path d="M24 60 L6 48 M76 60 L94 48 M10 51 L8 42 M90 51 L92 42" {...W('#78350f', 4)} />
<circle cx="50" cy="70" r="24" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="50" cy="30" r="17" fill="#f8fafc" stroke={O} strokeWidth="3.5" />
<circle cx="44" cy="26" r="2.6" fill={O} />
<circle cx="56" cy="26" r="2.6" fill={O} />
<path d="M50 32 L64 36 L50 37 Z" fill="#f97316" />
<circle cx="50" cy="62" r="3" fill={O} />
<circle cx="50" cy="74" r="3" fill={O} />
<path d="M36 44 C44 50 56 50 64 44" {...W('#dc2626', 6)} />
</>
),
// 56 무지개
() => (
<>
{['#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#8b5cf6'].map((c, i) => (
<path key={c} d={`M${8 + i * 6} 78 A${42 - i * 6} ${42 - i * 6} 0 0 1 ${92 - i * 6} 78`} {...W(c, 6.5)} strokeLinecap="butt" />
))}
<path d="M2 80 C2 70 18 68 22 76 C30 74 34 84 26 88 L6 88 C0 88 0 82 2 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
<path d="M98 80 C98 70 82 68 78 76 C70 74 66 84 74 88 L94 88 C100 88 100 82 98 80 Z" fill="#f8fafc" {...L} strokeWidth={2.5} />
</>
),
];
/** One icon in its own 100×100 SVG. */
export function SymbolArt({ art, className }: { art: number; className?: string }) {
const draw = ICONS[art];
return (
<svg viewBox="0 0 100 100" className={className} aria-hidden focusable="false">
{draw ? draw() : null}
</svg>
);
}

View File

@@ -0,0 +1,854 @@
/** 도블 화면 (docs/games/dobble.md §9): two round cards, tap the one picture they share. */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { CardView, ClaimPair, DobbleOptions, DobbleView, LogEntry, MiniGame, SeatView } from '@bg/games/dobble';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { socket, useRoom } from '../../net/socket';
import type { BoardProps, GameUI, OptionsProps } from '../types';
import { ICON_NAMES, SymbolArt } from './art';
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
const TAP_GRACE_MS = 250;
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? ''));
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 who = (players: Record<string, PublicUser>, me: string | null, id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
const CSS = `
.db { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; --cd: min(440px, 50vh, calc(50% - 14px)); }
.db-top { display: flex; flex-wrap: wrap; gap: 6px 8px; align-items: center; justify-content: space-between; font-weight: 700; }
.db-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.db-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .9rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-pill.warn { background: var(--warning-soft); color: var(--warning); }
.db-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.db-strip { display: flex; gap: 8px; list-style: none; margin: 0; padding: 2px 2px 4px; overflow-x: auto; justify-content: center; }
.db-seat { flex: none; display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); border: 3px solid transparent; color: var(--text); font: inherit; text-align: left; min-height: 48px; }
button.db-seat { cursor: pointer; }
.db-seat[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.db-seat[data-out='true'] { opacity: .55; }
.db-seat:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.db-seat .who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.db-seat .nm { font-weight: 800; font-size: .88rem; max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; }
.db-seat .meta { font-size: .78rem; font-weight: 700; color: var(--text-sub); font-variant-numeric: tabular-nums; white-space: nowrap; }
.db-seat .meta.rtt { display: inline-flex; gap: 3px; align-items: center; }
.db-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; color: #fff; background: #6b7280; }
.db-tag.ok { background: #15803d; }
.db-tag.warn { background: #f59e0b; color: #111827; }
.db-table { position: relative; display: flex; gap: 24px; justify-content: center; align-items: flex-start; padding: 14px; border-radius: 26px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); border: 6px solid var(--wood); box-shadow: var(--shadow); touch-action: manipulation; }
.db-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; width: var(--cd); }
.db-slot .cap { color: #fff; font-weight: 800; font-size: .95rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); display: flex; gap: 6px; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.db-card { position: relative; width: 100%; aspect-ratio: 1; border-radius: 50%; background: #fffdf6; border: 4px solid #d6c7a1; box-shadow: 0 6px 0 rgba(0,0,0,.18), 0 10px 22px rgba(0,0,0,.28); }
.db-card.empty { background: rgba(255,255,255,.1); border: 3px dashed rgba(255,255,255,.55); box-shadow: none; display: flex; align-items: center; justify-content: center; color: #fff; font-weight: 800; text-align: center; padding: 18%; }
.db-card[data-shake='true'] { animation: db-shake .4s ease; }
.db-sym { position: absolute; border-radius: 50%; border: 0; padding: 0; margin: 0; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.db-sym:disabled { cursor: default; }
@media (hover: hover) { .db-sym:hover:not(:disabled) { background: rgba(37, 99, 235, .08); } }
.db-sym:focus-visible { outline: 4px solid var(--focus); outline-offset: -2px; z-index: 2; }
.db-sym .ic { width: 80%; height: 80%; display: block; pointer-events: none; transform: rotate(var(--rot)) scale(var(--sc)); }
.db-sym .ic svg { width: 100%; height: 100%; display: block; }
.db-sym .ic.in { animation: db-in .25s ease-out; }
.db-sym[data-hint='true'] { box-shadow: 0 0 0 4px #f59e0b; animation: db-pulse .8s ease-in-out infinite; }
.db-sym[data-hit='true'] { box-shadow: 0 0 0 5px #16a34a; background: rgba(22,163,74,.14); }
.db-sym[data-hit='true'] .ic { transform: rotate(var(--rot)) scale(calc(var(--sc) * 1.25)); transition: transform .15s ease; }
.db-thumb { width: 44px; height: 44px; flex: none; position: relative; border-radius: 50%; background: #fffdf6; border: 2px solid #d6c7a1; }
.db-thumb .db-sym { cursor: inherit; }
.db-lock { position: absolute; inset: -8px; border-radius: 50%; pointer-events: none; display: flex; align-items: center; justify-content: center; z-index: 3; }
.db-lock b { background: rgba(17,24,39,.82); color: #fff; border-radius: 999px; padding: 6px 14px; font-size: 1.05rem; }
.db-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.4); color: #fff; border-radius: 20px;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; text-align: center; }
.db-count small { font-size: 1.1rem; padding: 0 12px; }
.db-bubble { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.15rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: db-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; display: flex; align-items: center; gap: 8px; }
.db-bubble .bi { width: 34px; height: 34px; flex: none; }
.db-bubble.ok { border: 4px solid #16a34a; }
.db-bubble.bad { border: 4px solid #dc2626; }
.db-bubble.late { border: 4px solid #6b7280; color: #374151; font-size: .98rem; }
.db-bubble.info { border: 4px solid #2563eb; }
.db-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 14px; display: flex; gap: 10px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 700; text-align: center; }
.db-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.db-sus { color: var(--warning); font-weight: 800; }
@keyframes db-shake { 0%,100% { transform: none; } 20% { transform: translateX(-10px); } 40% { transform: translateX(9px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(4px); } }
@keyframes db-in { from { opacity: 0; transform: rotate(var(--rot)) scale(calc(var(--sc) * .5)); } to { opacity: 1; transform: rotate(var(--rot)) scale(var(--sc)); } }
@keyframes db-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
@keyframes db-pulse { 0%,100% { box-shadow: 0 0 0 4px rgba(245,158,11,.9); } 50% { box-shadow: 0 0 0 8px rgba(245,158,11,.35); } }
@media (max-width: 719px) {
.db { --cd: min(80vw, calc(50vh - 158px), 330px); gap: 6px; }
.db-slot { position: relative; }
.db-slot .cap { position: absolute; top: -2px; left: 50%; transform: translateX(-50%); z-index: 2; background: rgba(0,0,0,.6); border-radius: 999px; padding: 1px 10px; font-size: .78rem; }
.db-table { flex-direction: column; align-items: center; gap: 8px; padding: 8px; border-width: 5px; border-radius: 22px; }
.db-pill { font-size: .8rem; padding: 2px 8px; }
.db-strip { justify-content: flex-start; }
.db-seat { padding: 3px 8px; gap: 6px; }
.db-seat .meta.rtt, .db-pill.mode { display: none; }
.db-seat .who { flex-direction: row; align-items: center; gap: 5px; }
.db-seat .nm { max-width: 72px; font-size: .8rem; }
.db-thumb { width: 38px; height: 38px; }
.db-bubble { font-size: .92rem; padding: 4px 12px; top: 50%; transform: translate(-50%, -50%); animation: none; width: max-content; }
.db-bubble .bi { width: 26px; height: 26px; }
.db-count { font-size: 4rem; }
}
@media (prefers-reduced-motion: reduce) {
.db-card[data-shake='true'], .db-sym .ic.in, .db-bubble, .db-sym[data-hint='true'] { animation: none; }
.db-sym[data-hit='true'] .ic { transition: none; }
}
`;
// ---------------------------------------------------------------- card
function usePhone(): boolean {
const q = '(max-width: 719px)';
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const on = () => setM(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return m;
}
/**
* A round card with 8 pictures. Buttons are keyed by slot so keyboard focus stays put when the card changes
* (the picture inside is keyed by art and pops in). `onPick` null = look only.
*/
function DobbleCard({
card,
label,
side,
onPick,
hint,
hit,
shake,
children,
}: {
card: CardView;
label: string;
side: string;
onPick: ((art: number) => void) | null;
hint?: number[];
hit?: number | null;
shake?: boolean;
children?: ReactNode;
}) {
return (
<div className="db-card" role="group" aria-label={label} data-card={side} data-key={card.key} data-shake={!!shake}>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<button
key={i}
type="button"
className="db-sym"
data-art={it.art}
data-hint={!!hint?.includes(it.art)}
data-hit={hit === it.art}
disabled={!onPick}
aria-label={`${artName(it.art)}${onPick ? ' 누르기' : ''}`}
style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (!onPick || e.button !== 0) return;
e.preventDefault();
onPick(it.art);
}}
onClick={(e) => {
// Keyboard activation; pointer presses were handled on pointerdown.
if (onPick && e.detail === 0) onPick(it.art);
}}
>
<span key={it.art} className="ic in" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</button>
);
})}
{children}
</div>
);
}
/** Small look-only card (target picker, rules). */
function Thumb({ card }: { card: CardView | null }) {
if (!card) return <span className="db-thumb" aria-hidden style={{ opacity: 0.4 }} />;
return (
<span className="db-thumb" aria-hidden>
{card.items.map((it, i) => {
const d = it.r * 100;
return (
<span key={i} className="db-sym" style={{ left: `${50 + it.x * 50 - d / 2}%`, top: `${50 + it.y * 50 - d / 2}%`, width: `${d}%`, height: `${d}%` }}>
<span className="ic" style={{ '--rot': `${it.rot}deg`, '--sc': it.scale } as CSSProperties}>
<SymbolArt art={it.art} />
</span>
</span>
);
})}
</span>
);
}
// ---------------------------------------------------------------- seats
function countText(mode: MiniGame, s: SeatView): string {
switch (mode) {
case 'tower':
return `탑 ${s.cards}장`;
case 'well':
return s.status === 'finished' ? '다 비웠어요' : `남은 카드 ${s.cards}장`;
case 'poisonedGift':
return `받은 카드 ${s.cards}장`;
case 'hotPotato':
return `${s.cards}장 · 벌점 ${s.penalty}`;
}
}
function statusTag(s: SeatView): { text: string; cls: string } | null {
if (s.status === 'finished') return { text: s.place ? `${s.place}등 완주` : '완주', cls: 'ok' };
if (s.status === 'escaped') return { text: '탈출', cls: 'ok' };
if (s.status === 'forfeit') return { text: '기권', cls: '' };
if (s.status === 'left') return { text: '나감', cls: '' };
return null;
}
function Rtt({ ms }: { ms: number | null }) {
if (ms === null) return null;
const color = ms < 80 ? '#16a34a' : ms < 200 ? '#eab308' : '#dc2626';
return (
<span className="meta rtt" aria-label={`연결 지연 ${ms}밀리초`}>
<i className="db-dot" style={{ background: color }} aria-hidden />
{ms}ms
</span>
);
}
function seatLabel(v: DobbleView, s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, countText(v.options.miniGame, s)];
const tag = statusTag(s);
if (tag) parts.push(tag.text);
return parts.join(', ');
}
// ---------------------------------------------------------------- board
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late' | 'info';
text: string;
art?: number | null;
}
function DobbleBoard({ view: v, me, players, act, pending }: BoardProps<DobbleView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [sel, setSel] = useState<string | null>(null);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [shake, setShake] = useState(false);
const [hitArt, setHitArt] = useState<number | null>(null);
const [announce, setAnnounce] = useState('');
const prev = useRef<DobbleView | null>(null);
const changed = useRef({ sig: '', at: 0 });
const mode = v.options.miniGame;
const targetMode = mode === 'hotPotato' || mode === 'poisonedGift';
const mine = v.seats.find((s) => s.id === me) ?? null;
const others = v.seats.filter((s) => s.id !== me);
const pair: ClaimPair | null = targetMode ? (v.pairs.find((p) => p.target === sel) ?? v.pairs[0] ?? null) : (v.pairs[0] ?? null);
// The opponent whose card is shown big (also for spectators / while waiting).
const targetId = targetMode ? (pair?.target ?? (others.find((s) => s.id === sel && s.top) ?? others.find((s) => s.status === 'playing' && s.top) ?? others.find((s) => s.top))?.id ?? null) : null;
const targetSeat = targetId ? (v.seats.find((s) => s.id === targetId) ?? null) : null;
// Clamped to the lock length: the client clock estimate may be a little off.
const lockedLeft = mine ? Math.min(v.options.wrongLockoutMs, Math.max(0, mine.lockedUntil - now)) : 0;
const playing = v.phase === 'playing';
const canTap = !!pair && playing && lockedLeft === 0 && !pending;
const claim = (art: number) => {
if (!pair) return;
// A tap right after a compared card changed was aimed at the old card (nobody reacts that fast): treat it as late, no penalty.
if (performance.now() - changed.current.at < TAP_GRACE_MS) {
setBubble({ key: `gr${Date.now()}`, kind: 'late', text: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
return;
}
if (!canTap) {
if (lockedLeft > 0) setBubble({ key: `lk${Date.now()}`, kind: 'late', text: `잠깐! ${Math.ceil(lockedLeft / 1000)}초 뒤에 다시 눌러요` });
return;
}
haptic(15);
act({
type: 'claim',
base: pair.base,
symbol: art,
...(pair.target ? { target: pair.target } : {}),
...(pair.mine !== null ? { mine: pair.mine } : {}),
...(rtt !== null ? { rtt } : {}),
});
};
// "늦었어요" rejects come back as `reject` messages.
useEffect(
() =>
socket.subscribe((m) => {
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
setAnnounce(m.reason);
}
}),
[],
);
// Diff against the previous view: my judgement, other people's finds, rounds.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const f = v.feedback;
if (f && f.seq !== p.feedback?.seq) {
if (f.kind === 'ok') {
sounds.ding();
haptic([30, 30, 60]);
setHitArt(f.art);
const b: Bubble = { key: `f${f.seq}`, kind: 'ok', text: '찾았어요!', art: f.art };
setBubble(b);
setAnnounce(`찾았어요! ${artName(f.art)}`);
} else if (f.kind === 'wrong') {
sounds.buzz();
haptic([90, 50, 90]);
setShake(true);
const lock = v.options.wrongLockoutMs ? ` · ${v.options.wrongLockoutMs / 1000}초 잠금` : '';
const b: Bubble = { key: `f${f.seq}`, kind: 'bad', text: `틀렸어요!${lock}`, art: f.art };
setAnnounce(`틀렸어요! ${artName(f.art)}는 같은 그림이 아니에요${lock}`);
setBubble(b);
} else if (f.kind === 'late') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '늦었어요! 다른 사람이 먼저 찾았어요.' });
setAnnounce('늦었어요');
} else if (f.kind === 'locked') {
setBubble({ key: `f${f.seq}`, kind: 'late', text: '잠깐! 조금 뒤에 다시 눌러요' });
}
return;
}
const h = v.lastHit;
if (h && h.seq !== p.lastHit?.seq && h.player !== me) {
sounds.stone();
let text = `${nick(players, h.player)}님이 찾았어요`;
if (mode === 'poisonedGift' && h.target === me) text = `${nick(players, h.player)}님이 나에게 카드를 줬어요`;
if (mode === 'hotPotato' && h.target === me) text = `${nick(players, h.player)}님이 카드 묶음을 넘겼어요!`;
setBubble({ key: `h${h.seq}`, kind: 'info', text, art: h.art });
setAnnounce(`${text} · ${artName(h.art)}`);
}
const r = v.lastRound;
if (r && r.round !== p.lastRound?.round) {
const text = r.loser ? `${r.round}라운드 끝! ${r.loser === me ? '내가' : `${nick(players, r.loser)}님이`} 카드 ${r.cards}장을 벌점으로 받아요` : `${r.round}라운드는 무효예요`;
setBubble({ key: `r${r.round}`, kind: r.loser === me ? 'bad' : 'info', text });
setAnnounce(text);
}
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1700);
return () => clearTimeout(t);
}, [bubble]);
useEffect(() => {
if (!shake) return;
const t = setTimeout(() => setShake(false), 450);
return () => clearTimeout(t);
}, [shake]);
useEffect(() => {
if (hitArt === null) return;
const t = setTimeout(() => setHitArt(null), 500);
return () => clearTimeout(t);
}, [hitArt]);
const countdown = v.phase === 'countdown' || v.phase === 'roundEnd' ? Math.max(0, Math.ceil((v.phaseEndsAt - now) / 1000)) : 0;
const hint = v.hintUntil > now ? v.hint : [];
const tapper = canTap || (!!pair && playing && lockedLeft > 0) ? claim : null;
// Which two cards are compared (§5.2): A = top/left, B = bottom/right.
const theirCap = (s: SeatView | null) => (s ? `${nick(players, s.id)}님 카드` : '상대 카드');
let a: { card: CardView | null; cap: string; side: string };
let b: { card: CardView | null; cap: string; side: string };
if (mode === 'hotPotato') {
a = { card: targetSeat?.top ?? null, cap: theirCap(targetSeat), side: 'target' };
b = { card: mine?.top ?? null, cap: '내 카드', side: 'mine' };
} else if (mode === 'poisonedGift') {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: targetSeat?.top ?? null, cap: `${theirCap(targetSeat)}에 줄 선물`, side: 'target' };
} else {
a = { card: v.center, cap: '가운데 카드', side: 'center' };
b = { card: mine?.top ?? null, cap: mine ? '내 카드' : '관전 중', side: 'mine' };
}
const emptyText = (side: string): string => {
if (v.phase === 'countdown') return '곧 공개돼요';
if (side === 'mine') {
if (!mine) return '관전 중이에요';
if (mine.status === 'finished') return `다 비웠어요! ${mine.place ?? ''}등`;
if (mine.status === 'escaped') return '이번 라운드 탈출!';
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요';
}
if (v.phase === 'roundEnd') return '다음 라운드를 준비해요';
return '카드가 없어요';
};
const slot = (x: typeof a, withLock: boolean) => (
<div className="db-slot">
<span className="cap">{x.cap}</span>
{x.card ? (
<DobbleCard card={x.card} label={x.cap} side={x.side} onPick={tapper} hint={hint} hit={hitArt} shake={withLock && shake}>
{withLock && lockedLeft > 0 && (
<span
className="db-lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.45) ${(lockedLeft / Math.max(1, v.options.wrongLockoutMs)) * 360}deg, transparent 0)` }}
>
<b>잠깐! {Math.ceil(lockedLeft / 1000)}초</b>
</span>
)}
</DobbleCard>
) : (
<div className="db-card empty" data-card={x.side}>
{emptyText(x.side)}
</div>
)}
</div>
);
const sig = `${a.card?.key ?? '-'}|${b.card?.key ?? '-'}`;
if (changed.current.sig !== sig) changed.current = { sig, at: performance.now() };
const remaining = mode === 'well' ? null : mode === 'hotPotato' ? `${v.round}/${v.rounds}라운드` : `남은 카드 ${v.drawCount + (v.center ? 1 : 0)}장`;
const pickable = new Set(v.pairs.map((p) => p.target));
return (
<div className="db">
<style>{CSS}</style>
<div className="db-top">
<div className="grp">
<span className="db-pill mode">{MINI_KO[mode]}</span>
{remaining && <span className="db-pill">{remaining}</span>}
{mine && <span className="db-pill">내 카드 · {countText(mode, mine)}</span>}
</div>
<div className="grp">
{v.options.showRtt && rtt !== null && (
<span className={`db-pill${rtt >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}>
<i className="db-dot" style={{ background: rtt < 80 ? '#16a34a' : rtt < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`}
</span>
)}
</div>
</div>
{others.length > 0 && (
<ul className="db-strip" aria-label={targetMode ? '누구 카드에서 찾을지 고르기' : '다른 사람'}>
{others.map((s) => {
const tag = statusTag(s);
const canPick = targetMode && pickable.has(s.id);
const inner = (
<>
{targetMode && <Thumb card={s.top} />}
{!targetMode && <Avatar user={userOf(players, s.id)} size={28} />}
<span className="who">
<span className="nm">
{targetMode && <Avatar user={userOf(players, s.id)} size={16} />}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{nick(players, s.id)}</span>
</span>
<span className="meta">
{countText(mode, s)}
{s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''}
</span>
{v.options.showRtt && <Rtt ms={s.rtt} />}
</span>
{tag && <span className={`db-tag ${tag.cls}`}>{tag.text}</span>}
</>
);
return (
<li key={s.id}>
{canPick ? (
<button
type="button"
className="db-seat"
data-seat={s.id}
aria-pressed={targetId === s.id}
aria-label={`${seatLabel(v, s, players, me)} · 이 사람 카드 고르기`}
onClick={() => setSel(s.id)}
>
{inner}
</button>
) : (
<div className="db-seat" data-seat={s.id} data-out={s.status !== 'playing'} aria-label={seatLabel(v, s, players, me)} aria-pressed={targetMode ? targetId === s.id : undefined}>
{inner}
</div>
)}
</li>
);
})}
</ul>
)}
<div className="db-table" role="region" aria-label="카드 테이블">
{slot(a, false)}
{slot(b, true)}
{bubble && (
<div key={bubble.key} className={`db-bubble ${bubble.kind}`} data-bubble={bubble.key} aria-hidden>
{bubble.art !== undefined && bubble.art !== null && <SymbolArt art={bubble.art} className="bi" />}
<span>{bubble.text}</span>
</div>
)}
{countdown > 0 && (
<div className="db-count" aria-hidden>
{countdown}
<small>{v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}</small>
</div>
)}
</div>
{targetMode && v.pairs.length > 1 && playing && (
<div className="db-panel small" style={{ fontWeight: 600 }}>
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: <strong>{targetSeat ? nick(players, targetSeat.id) : ''}님</strong>
</div>
)}
{!phone && pair && playing && (
<span className="small muted" style={{ textAlign: 'center' }}>
Tab 키로 그림을 고르고 Enter 키로 눌러요
</span>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, mode: MiniGame, players: Record<string, PublicUser>, me: string | null): string {
switch (e.t) {
case 'hit': {
const n = who(players, me, e.p);
const t = e.target === 'center' ? '' : e.target === me ? '나' : `${nick(players, e.target)}님`;
if (mode === 'tower') return `${n} '${artName(e.art)}'를 찾아 가운데 카드를 가져갔어요`;
if (mode === 'well') return `${n} '${artName(e.art)}'를 찾아 카드를 가운데에 내려놓았어요`;
if (mode === 'poisonedGift') return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드를 줬어요`;
return `${n} '${artName(e.art)}'를 찾아 ${t}에게 카드 묶음을 넘겼어요`;
}
case 'done':
return `${who(players, me, e.p)} 카드를 다 비웠어요 · ${e.place}등`;
case 'round':
return e.loser ? `${e.round}라운드 끝 · ${who(players, me, e.loser)} 벌점 ${e.cards}장을 받았어요` : `${e.round}라운드는 무효예요`;
case 'out':
return e.why === 'left' ? `${who(players, me, e.p)} 나갔어요` : `${who(players, me, e.p)} 오래 자리를 비워 기권했어요`;
case 'hint':
return '45초 동안 아무도 못 찾아서 같은 그림을 살짝 보여 줬어요';
}
}
function rulesLine(o: DobbleOptions): string {
const parts = [MINI_KO[o.miniGame], `${o.deckSize}장`];
if (o.miniGame === 'hotPotato') parts.push(`${o.hotPotatoRounds}라운드`);
parts.push(o.wrongLockoutMs ? `틀리면 ${o.wrongLockoutMs / 1000}초 잠금` : '틀려도 잠금 없음');
if (o.symbolStyle === 'easy') parts.push('그림 크기 같게');
if (o.latencyComp === 'light') parts.push('지연 보정');
if (o.miniGame === 'well' && o.wellEndAtFirst) parts.push('첫 완주에 끝');
if (o.miniGame === 'tower' && o.wrongGivesCard) parts.push('틀리면 1장 반납');
return parts.join(' · ');
}
function DobbleSide({ view: v, me, players }: BoardProps<DobbleView>) {
const mode = v.options.miniGame;
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>도블{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => {
const sus = v.suspects.includes(s.id);
const tag = statusTag(s);
return (
<li
key={s.id}
className="row"
style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'forfeit' || s.status === 'left' ? 0.55 : 1 }}
aria-label={`${seatLabel(v, s, players, me)}, 찾기 ${s.correct}번, 틀림 ${s.wrong}번${sus ? ', 너무 빨리 맞혀서 확인이 필요해요' : ''}`}
>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{tag ? ` · ${tag.text}` : ''}
</span>
{sus && (
<span className="db-sus small" aria-hidden title="카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요">
⚠ 의심
</span>
)}
{v.options.showRtt && s.rtt !== null && (
<span className="small" aria-hidden style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
<i className="db-dot" style={{ background: s.rtt < 80 ? '#16a34a' : s.rtt < 200 ? '#eab308' : '#dc2626' }} />
{s.rtt}
</span>
)}
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
</strong>
</li>
);
})}
</ol>
{v.suspects.length > 0 && <span className="small muted">⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.</span>}
</div>
{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.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="db-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, mode, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: DobbleView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.pairs.length > 0;
}
function statusText(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return v.round > 1 ? `${v.round}라운드 곧 시작해요!` : '곧 시작해요! 두 카드에 똑같이 있는 그림을 찾아요';
if (v.phase === 'roundEnd') return '라운드가 끝났어요 · 다음 라운드를 준비해요';
const mine = v.seats.find((s) => s.id === me);
if (mine?.status === 'finished') return `카드를 다 비웠어요! ${mine.place ?? ''}등 · 남은 게임을 지켜봐요`;
if (mine?.status === 'escaped') return '이번 라운드는 탈출했어요 · 다음 라운드를 기다려요';
if (mine && (mine.status === 'forfeit' || mine.status === 'left')) return '게임에서 빠졌어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) {
return '지금 찾아요! 같은 그림을 눌러요';
}
const leader = v.lastHit ? nick(players, v.lastHit.player) : null;
return leader ? `${leader}님이 방금 찾았어요 · 다 같이 찾는 중이에요` : '다 같이 같은 그림을 찾는 중이에요';
}
// ---------------------------------------------------------------- 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, '끄기'],
];
const MINI_HELP: Record<MiniGame, string> = {
tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!',
well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!',
hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!',
poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!',
};
function Options({ value: o, onChange }: OptionsProps<DobbleOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<DobbleOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="미니게임" help={MINI_HELP[o.miniGame]}>
<Seg
label="미니게임"
value={o.miniGame}
options={[
['tower', '탑 쌓기'],
['well', '우물'],
['hotPotato', '뜨거운 감자'],
['poisonedGift', '독이 든 선물'],
]}
onChange={(miniGame) => set({ miniGame })}
/>
</Field>
{o.miniGame === 'hotPotato' && (
<Field label="뜨거운 감자 라운드 수">
<Seg
label="뜨거운 감자 라운드 수"
value={o.hotPotatoRounds}
options={[
[3, '3라운드'],
[5, '5라운드'],
[7, '7라운드'],
]}
onChange={(hotPotatoRounds) => set({ hotPotatoRounds })}
/>
</Field>
)}
<Field label="틀린 그림을 눌렀을 때 잠금" help="잠금 동안에는 맞는 그림을 눌러도 받아 주지 않아요.">
<Seg
label="틀린 그림을 눌렀을 때 잠금"
value={o.wrongLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongLockoutMs) => set({ wrongLockoutMs })}
/>
</Field>
<Field label="그림 크기·회전" help={o.symbolStyle === 'easy' ? '어린이 모드: 그림 크기가 같고 돌아가 있지 않아요.' : '그림마다 크기와 방향이 달라요.'}>
<Seg
label="그림 크기·회전"
value={o.symbolStyle}
options={[
['classic', '원작처럼'],
['easy', '쉽게'],
]}
onChange={(symbolStyle) => set({ symbolStyle })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드 장수">
<Seg
label="카드 장수"
value={o.deckSize}
options={[
[57, '57장'],
[55, '55장'],
]}
onChange={(deckSize) => set({ deckSize })}
/>
</Field>
<Field label="지연 보정" help="켜면 0.06초 동안 모인 정답 가운데, 연결 지연의 절반(최대 0.08초)을 빼 준 시각이 가장 빠른 사람이 이겨요.">
<Seg
label="지연 보정"
value={o.latencyComp}
options={[
['off', '끄기'],
['light', '가볍게'],
]}
onChange={(latencyComp) => set({ latencyComp })}
/>
</Field>
<Field label="각자 핑 표시">
<Seg label="각자 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
</Field>
{o.miniGame === 'well' && (
<Field label="우물: 처음 다 비운 사람이 나오면 바로 끝내기">
<Seg label="우물 첫 완주에 끝내기" value={o.wellEndAtFirst} options={onOff} onChange={(wellEndAtFirst) => set({ wellEndAtFirst })} />
</Field>
)}
{o.miniGame === 'tower' && (
<Field label="탑 쌓기: 틀리면 내 탑 맨 위 카드를 반납">
<Seg label="틀리면 카드 반납" value={o.wrongGivesCard} options={onOff} onChange={(wrongGivesCard) => set({ wrongGivesCard })} />
</Field>
)}
</>
)}
</div>
);
}
function optionsSummary(o: DobbleOptions): string {
return rulesLine(o);
}
// ---------------------------------------------------------------- rules
/** Two tiny sample cards that share only the 고양이 (slots of layout template 0). */
const SLOTS0: [number, number][] = [[-0.448, 0.516], [-0.683, -0.028], [0.011, -0.014], [0.18, -0.659], [0.628, -0.271], [0.603, 0.322], [-0.404, -0.552], [0.124, 0.672]];
const sample = (id: number, arts: number[]): CardView => ({ id, key: `r${id}`, items: arts.map((art, i) => ({ art, x: SLOTS0[i]![0], y: SLOTS0[i]![1], r: 0.287, scale: 1, rot: 0 })) });
const SAMPLE: CardView[] = [sample(-1, [0, 1, 18, 31, 44, 11, 28, 56]), sample(-2, [3, 41, 20, 38, 18, 50, 35, 17])];
function Rules({ options }: { options?: DobbleOptions }) {
const lock = options?.wrongLockoutMs ?? 1000;
return (
<div className="stack">
<style>{CSS}</style>
<p>
동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 <strong>똑같은 그림이 딱 하나</strong> 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.
</p>
<div className="row" style={{ gap: 10, alignItems: 'center' }}>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[0]!} label="예시 카드 1" side="r1" onPick={null} hint={[18]} />
</span>
<span style={{ width: 96, display: 'inline-block' }}>
<DobbleCard card={SAMPLE[1]!} label="예시 카드 2" side="r2" onPick={null} hint={[18]} />
</span>
<span>→ 같은 그림은 고양이!</span>
</div>
<ul>
<li>
<strong>탑 쌓기</strong>: {MINI_HELP.tower}
</li>
<li>
<strong>우물</strong>: {MINI_HELP.well}
</li>
<li>
<strong>뜨거운 감자</strong>: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
</li>
<li>
<strong>독이 든 선물</strong>: {MINI_HELP.poisonedGift}
</li>
</ul>
<p>
틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).
</p>
<p>PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.</p>
</div>
);
}
const ui: GameUI<DobbleView, DobbleOptions> = {
Board: DobbleBoard,
Side: DobbleSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -25,6 +25,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'las-vegas': () => import('./las-vegas'),
'cockroach-poker': () => import('./cockroach-poker'),
'ninety-nine': () => import('./ninety-nine'),
dobble: () => import('./dobble'),
};
const cache = new Map<string, GameUI>();

View File

@@ -22,6 +22,7 @@ export const GAME_ICON: Record<string, string> = {
'las-vegas': '⚅',
'cockroach-poker': '🐞',
'ninety-nine': '💯',
dobble: '👀',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */