Files
joke-app/apps/web/src/games/dobble/index.tsx
EJClaw 953ae90546 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>
2026-10-06 00:33:29 +09:00

855 lines
42 KiB
TypeScript

/** 도블 화면 (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;