/** 도블 화면 (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 = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' }; const artName = (art: number | null | undefined) => (art === null || art === undefined ? '' : (ICON_NAMES[art] ?? '')); const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; const who = (players: Record, 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 (
{card.items.map((it, i) => { const d = it.r * 100; return ( ); })} {children}
); } /** Small look-only card (target picker, rules). */ function Thumb({ card }: { card: CardView | null }) { if (!card) return ; return ( {card.items.map((it, i) => { const d = it.r * 100; return ( ); })} ); } // ---------------------------------------------------------------- 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 ( {ms}ms ); } function seatLabel(v: DobbleView, s: SeatView, players: Record, 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) { const now = useServerNow(100); const phone = usePhone(); const rtt = useRoom((s) => s.rttMs); const [sel, setSel] = useState(null); const [bubble, setBubble] = useState(null); const [shake, setShake] = useState(false); const [hitArt, setHitArt] = useState(null); const [announce, setAnnounce] = useState(''); const prev = useRef(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) => (
{x.cap} {x.card ? ( {withLock && lockedLeft > 0 && ( 잠깐! {Math.ceil(lockedLeft / 1000)}초 )} ) : (
{emptyText(x.side)}
)}
); 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 (
{MINI_KO[mode]} {remaining && {remaining}} {mine && 내 카드 · {countText(mode, mine)}}
{v.options.showRtt && rtt !== null && ( = 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${rtt}밀리초`}> {rtt >= 200 ? `연결이 느려요 · ${rtt}ms` : `핑 ${rtt}ms`} )}
{others.length > 0 && (
    {others.map((s) => { const tag = statusTag(s); const canPick = targetMode && pickable.has(s.id); const inner = ( <> {targetMode && } {!targetMode && } {targetMode && } {nick(players, s.id)} {countText(mode, s)} {s.lockedUntil > now && s.status === 'playing' ? ' · 잠금' : ''} {v.options.showRtt && } {tag && {tag.text}} ); return (
  • {canPick ? ( ) : (
    {inner}
    )}
  • ); })}
)}
{slot(a, false)} {slot(b, true)} {bubble && (
{bubble.art !== undefined && bubble.art !== null && } {bubble.text}
)} {countdown > 0 && (
{countdown} {v.phase === 'roundEnd' ? '다음 라운드를 준비해요' : '두 카드에 똑같이 있는 그림을 찾아요!'}
)}
{targetMode && v.pairs.length > 1 && playing && (
{mode === 'poisonedGift' ? '위에서 선물을 줄 사람을 골라요.' : '위에서 카드를 넘길 사람을 골라요.'} 지금: {targetSeat ? nick(players, targetSeat.id) : ''}님
)} {!phone && pair && playing && ( Tab 키로 그림을 고르고 Enter 키로 눌러요 )}

{announce}

); } // ---------------------------------------------------------------- side function logText(e: LogEntry, mode: MiniGame, players: Record, 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) { const mode = v.options.miniGame; return ( <>
도블{v.phase === 'ended' ? ' · 게임 끝' : ''} {rulesLine(v.options)}
    {v.seats.map((s) => { const sus = v.suspects.includes(s.id); const tag = statusTag(s); return (
  1. {nick(players, s.id)} {s.id === me ? ' (나)' : ''} {tag ? ` · ${tag.text}` : ''} {sus && ( ⚠ 의심 )} {v.options.showRtt && s.rtt !== null && ( {s.rtt} )} ✓{s.correct} ✗{s.wrong} {mode === 'hotPotato' ? `벌점 ${s.penalty}` : `${s.cards}장`}
  2. ); })}
{v.suspects.length > 0 && ⚠ 의심: 카드가 바뀌고 0.2초 안에 5번 연속으로 맞혔어요. 방장에게만 보여요.}
{v.result && (
최종 순위 {v.result.ranking.map((r) => ( {r.rank}등 {nick(players, r.id)} {' '} · {r.penalty !== undefined ? `벌점 ${r.penalty}장` : `${r.cards}장`} · {r.points}점 · 찾기 {v.result!.stats[r.id]?.correct ?? 0} · 틀림 {v.result!.stats[r.id]?.wrong ?? 0} ))} {v.result.summary}
)} {v.log.length > 0 && (
기록
    {[...v.log].reverse().map((e) => (
  1. {logText(e, mode, players, me)}
  2. ))}
)} ); } // ---------------------------------------------------------------- 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 { 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({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { return (
{options.map(([k, l]) => ( ))}
); } function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { return (
{label} {children} {help && {help}}
); } const onOff: [boolean, string][] = [ [true, '켜기'], [false, '끄기'], ]; const MINI_HELP: Record = { tower: '내 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 가져와요. 카드를 가장 많이 모으면 1등!', well: '내 카드와 가운데 카드의 같은 그림을 찾으면 내 카드를 가운데에 내려놓아요. 먼저 다 비우면 1등!', hotPotato: '내 카드와 다른 사람 카드의 같은 그림을 찾으면 내 카드 묶음을 그 사람에게 넘겨요. 마지막에 남은 사람이 벌점!', poisonedGift: '다른 사람 카드와 가운데 카드의 같은 그림을 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등!', }; function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (
set({ miniGame })} /> {o.miniGame === 'hotPotato' && ( set({ hotPotatoRounds })} /> )} set({ wrongLockoutMs })} /> set({ symbolStyle })} /> {more && ( <> set({ deckSize })} /> set({ latencyComp })} /> set({ showRtt })} /> {o.miniGame === 'well' && ( set({ wellEndAtFirst })} /> )} {o.miniGame === 'tower' && ( set({ wrongGivesCard })} /> )} )}
); } 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 (

동그란 카드 57장에는 그림이 8개씩 있어요. 어느 두 카드를 골라도 똑같은 그림이 딱 하나 있어요. 그 그림을 누구보다 빨리 찾아 눌러요. 서버에 먼저 도착한 사람이 이겨요.

→ 같은 그림은 고양이!
  • 탑 쌓기: {MINI_HELP.tower}
  • 우물: {MINI_HELP.well}
  • 뜨거운 감자: {MINI_HELP.hotPotato} 정한 라운드가 끝나면 벌점이 가장 적은 사람이 1등이에요.
  • 독이 든 선물: {MINI_HELP.poisonedGift}

틀린 그림을 누르면 {lock ? `${lock / 1000}초 동안 누를 수 없어요` : '벌칙은 없어요'}. 다른 사람이 먼저 찾아 카드가 바뀌었으면 "늦었어요"가 뜨고 벌칙은 없어요. 뜨거운 감자·독이 든 선물은 위에서 상대를 먼저 고르고 그림을 눌러요(상대가 1명이면 저절로 골라져요).

PC에서는 Tab 키로 그림을 옮겨 다니고 Enter 키로 누를 수 있어요. 순위 점수는 1등 3점, 2등 2점, 3등 1점이에요.

); } const ui: GameUI = { Board: DobbleBoard, Side: DobbleSide, statusText, isMyTurn, Options, optionsSummary, Rules, }; export default ui;