/** 할리갈리 화면 (docs/games/halli-galli.md §9). */ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import type { Fruit, FruitCount, HalliGalliOptions, HalliGalliView, LogEntry, SeatView, TopCard } from '@bg/games/halli-galli'; 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 { helpNow } from './help'; import { quickStart } from './quick'; const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum']; const FRUIT_KO: Record = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' }; const FRUIT_INK: Record = { strawberry: '#e11d48', banana: '#ca8a04', lime: '#4d7c0f', plum: '#7e22ce' }; 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 cardName = (c: Pick) => `${FRUIT_KO[c.fruit]} ${c.count}개`; const CSS = ` .hg { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; } .hg-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; justify-content: space-between; font-weight: 700; } .hg-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } .hg-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; } .hg-pill.warn { background: var(--warning-soft); color: var(--warning); } .hg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } .hg-table { position: relative; width: 100%; height: clamp(440px, 64vh, 600px); border-radius: 28px; border: 8px solid var(--wood); box-shadow: var(--shadow); background: radial-gradient(ellipse at center, rgba(255,255,255,.14), rgba(255,255,255,0) 62%), var(--felt); overflow: hidden; touch-action: manipulation; } .hg-seat { position: absolute; transform: translate(-50%, -50%); width: var(--sw); display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 5px 5px 6px; border-radius: 14px; background: rgba(0,0,0,.28); color: #fff; border: 3px solid transparent; z-index: 2; } .hg-seat[data-me='true'] { background: rgba(0,0,0,.42); } .hg-seat[data-turn='true'] { border-color: #fde047; animation: hg-blink 1s ease-in-out infinite; } .hg-seat[data-out='true'] { opacity: .6; } .hg-seat .nm { display: flex; align-items: center; gap: 4px; max-width: 100%; font-weight: 800; font-size: .86rem; line-height: 1.2; } .hg-seat .nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hg-pile { position: relative; width: var(--cw); height: calc(var(--cw) * 1.4); } .hg-pile .stack { position: absolute; inset: 0; border-radius: 9px; } .hg-pile .empty { position: absolute; inset: 0; border-radius: 9px; border: 2px dashed rgba(255,255,255,.45); } .hg-pile .shadow1 { position: absolute; inset: 0; transform: translate(3px, 3px); border-radius: 9px; background: #e7e2d3; border: 1px solid #b9b2a0; } .hg-pile .face { position: absolute; inset: 0; animation: hg-flip .22s ease-out; } .hg-pile .gauge { position: absolute; right: -12px; bottom: -8px; width: 30px; height: 30px; } .hg-meta { display: flex; gap: 6px; align-items: center; font-size: .82rem; font-weight: 800; font-variant-numeric: tabular-nums; } .hg-back { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; vertical-align: -2px; } .hg-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; } .hg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; z-index: 3; } .hg-bell { width: var(--bell); height: var(--bell); border-radius: 50%; border: 0; padding: 0; background: radial-gradient(circle at 40% 35%, #fff7d1, #fbbf24 45%, #b45309 100%); box-shadow: 0 8px 0 #7c2d12, 0 12px 24px rgba(0,0,0,.35); cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center; transition: transform .06s ease, box-shadow .06s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; } .hg-bell .art { width: 70%; height: 70%; display: block; pointer-events: none; } .hg-bell .art svg { width: 100%; height: 100%; display: block; } .hg-bell .art[data-ring='true'] { animation: hg-ring .45s ease-in-out; } .hg-bell:active:not(:disabled) { transform: translateY(6px) scale(.97); box-shadow: 0 2px 0 #7c2d12, 0 4px 10px rgba(0,0,0,.35); } .hg-bell:disabled { cursor: not-allowed; filter: grayscale(.85) brightness(.85); } .hg-bell:focus-visible { outline: 4px solid var(--focus); outline-offset: 6px; } .hg-bell .lock { position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; } .hg-bell .lbl { position: absolute; bottom: 14%; left: 0; right: 0; text-align: center; font-weight: 900; color: #451a03; font-size: calc(var(--bell) * .11); pointer-events: none; } .hg-keys { color: var(--text-sub); font-weight: 600; font-size: .85rem; } .hg-pot { position: absolute; left: 50%; top: -2px; transform: translateX(-50%); color: #fff; font-weight: 800; font-size: .78rem; z-index: 4; pointer-events: none; background: #1e3a8a; border: 2px solid #93c5fd; border-radius: 999px; padding: 1px 8px; white-space: nowrap; } .hg-bubble { position: absolute; left: 50%; top: 2px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px; font-weight: 900; font-size: 1.25rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: hg-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; } .hg-bubble.ok { border: 4px solid #16a34a; } .hg-bubble.bad { border: 4px solid #dc2626; } .hg-bubble.late { border: 4px solid #6b7280; font-size: .95rem; } .hg-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.35); color: #fff; font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; } .hg-count small { font-size: 1.1rem; } .hg-ghost { position: absolute; width: var(--cw); height: calc(var(--cw) * 1.4); transform: translate(-50%, -50%); z-index: 5; pointer-events: none; animation: hg-fly .6s cubic-bezier(.5,.1,.3,1) forwards; animation-delay: var(--d, 0ms); left: var(--fx); top: var(--fy); } .hg-mine { display: flex; gap: 10px; align-items: stretch; } .hg-mine .info { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 8px 12px; display: flex; flex-direction: column; justify-content: center; min-width: 96px; } .hg-mine .info b { font-size: 1.5rem; font-variant-numeric: tabular-nums; } .hg-flipbtn { flex: 1; min-height: 64px; font-size: 1.25rem !important; } .hg-kid { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: grid; gap: 6px; } .hg-bar { display: grid; grid-template-columns: 28px 52px 1fr 28px; gap: 8px; align-items: center; font-weight: 800; } .hg-bar .track { position: relative; height: 14px; border-radius: 999px; background: var(--surface-2); overflow: hidden; } .hg-bar .track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; } .hg-bar .track::after { content: ''; position: absolute; left: calc(100% * 5 / 8); top: -2px; bottom: -2px; width: 2px; background: var(--text); } .hg-bar[data-five='true'] { color: var(--success); } .hg-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; } .hg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; } .hg-fruit-ic { width: 26px; height: 26px; display: inline-block; vertical-align: middle; } @keyframes hg-blink { 0%,100% { box-shadow: 0 0 0 0 rgba(253,224,71,.0); } 50% { box-shadow: 0 0 0 6px rgba(253,224,71,.55); } } @keyframes hg-flip { from { transform: rotateY(85deg) scale(.9); } to { transform: none; } } @keyframes hg-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 hg-ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(-14deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(5deg); } } @keyframes hg-fly { from { left: var(--fx); top: var(--fy); opacity: 1; } 85% { opacity: .9; } to { left: var(--tx); top: var(--ty); opacity: 0; transform: translate(-50%, -50%) scale(.6); } } @media (max-width: 719px) { .hg-table { height: 450px; border-width: 6px; border-radius: 22px; } .hg-seat { padding: 3px 3px 4px; gap: 2px; } .hg-seat .nm { font-size: .78rem; } .hg-meta { font-size: .76rem; gap: 4px; } .hg-bubble { font-size: .95rem; padding: 6px 12px; } } @media (prefers-reduced-motion: reduce) { .hg-seat[data-turn='true'] { animation: none; box-shadow: 0 0 0 4px rgba(253,224,71,.55); } .hg-pile .face, .hg-bell .art[data-ring='true'], .hg-bubble { animation: none; } .hg-ghost { display: none; } } `; // ---------------------------------------------------------------- fruit art /** One fruit in a 40×40 box. Distinct silhouettes so colour is never the only cue. */ function FruitShape({ fruit }: { fruit: Fruit }) { switch (fruit) { case 'strawberry': return ( {[ [14, 17], [20, 15], [26, 17], [12, 23], [18, 22], [24, 22], [28, 23], [16, 28], [22, 28], [19, 33], ].map(([x, y]) => ( ))} ); case 'banana': return ( ); case 'lime': return ( {Array.from({ length: 8 }, (_, i) => { const a = (i * Math.PI) / 4; return ; })} ); case 'plum': return ( ); } } const SPOTS: Record = { 1: [[50, 70]], 2: [ [50, 38], [50, 102], ], 3: [ [28, 34], [50, 70], [72, 106], ], 4: [ [29, 38], [71, 38], [29, 102], [71, 102], ], 5: [ [27, 32], [73, 32], [50, 70], [27, 108], [73, 108], ], }; export function FruitCardArt({ card }: { card: Pick | null }) { if (!card) { return ( ); } const size = card.count === 1 ? 54 : card.count === 5 ? 37 : 41; return ( {SPOTS[card.count].map(([x, y], i) => ( ))} ); } function FruitIcon({ fruit }: { fruit: Fruit }) { return ( ); } function BellArt() { return ( ); } // ---------------------------------------------------------------- geometry interface Pos { left: string; top: string; } const CENTER: Pos = { left: '50%', top: '50%' }; /** * Seat centres on an ellipse (me at the bottom, then clockwise: left → top → right). The radii are * "half the table minus half a seat", so seats always stay inside the felt whatever its size. * `tall` (narrow phone table): seats that are not level with the bell move further up/down so they clear it. */ function seatPositions(n: number, seat: { w: number; h: number }, maxRx: number, tall: boolean): Pos[] { const f = (x: number) => (Math.abs(x) < 1e-9 ? 0 : Number(x.toFixed(4))); return Array.from({ length: n }, (_, k) => { const t = (2 * Math.PI * k) / n; const c = f(Math.cos(t)); const y = tall && c !== 0 ? Math.sign(c) * Math.max(Math.abs(c), 0.75) : c; return { left: `calc(50% - ${f(Math.sin(t))} * min(50% - ${seat.w / 2 + 6}px, ${maxRx}px))`, top: `calc(50% + ${y} * (50% - ${seat.h / 2 + 6}px))`, }; }); } 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; } // ---------------------------------------------------------------- seat function Gauge({ start, end, now }: { start: number; end: number; now: number }) { const frac = Math.max(0, Math.min(1, (end - now) / Math.max(1, end - start))); const r = 12; const c = 2 * Math.PI * r; const color = frac < 0.34 ? '#ef4444' : frac < 0.67 ? '#f59e0b' : '#22c55e'; return ( ); } function seatLabel(s: SeatView, players: Record, me: string | null): string { const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`]; parts.push(s.top ? `펼친 카드 ${cardName(s.top)}` : '펼친 카드 없음'); parts.push(`덱 ${s.deck}장`); if (s.pile > 1) parts.push(`펼친 더미 ${s.pile}장`); if (s.status === 'out') parts.push('탈락'); if (s.status === 'left') parts.push('나감'); if (s.isTurn) parts.push('뒤집을 차례'); return parts.join(', '); } function Seat({ s, v, pos, players, me, now }: { s: SeatView; v: HalliGalliView; pos: Pos; players: Record; me: string | null; now: number }) { const u = userOf(players, s.id); const locked = s.lockedUntil > now && s.status === 'playing'; return (
  • {s.id === me ? '나' : u.nickname}
    {s.pile > 1 && } {s.top ? ( ) : ( )} {s.isTurn && v.phase === 'playing' && }
    {s.deck} {s.status === 'out' && ( 탈락 )} {s.status === 'left' && ( 나감 )} {locked && ( 잠금 )}
  • ); } // ---------------------------------------------------------------- bell feedback interface Bubble { key: string; kind: 'ok' | 'bad' | 'late'; text: string; } interface Ghost { key: string; from: Pos; to: Pos; card: Pick | null; delay: number; } function bellText(v: HalliGalliView, players: Record): Bubble | null { const b = v.lastBell; if (!b) return null; const who = b.player === v.me ? '내가' : `${nick(players, b.player)}님이`; if (b.correct) return { key: `b${b.seq}`, kind: 'ok', text: `땡! ${who} 먼저 쳤어요 · ${b.fruit ? FRUIT_KO[b.fruit] : ''} 5개 · ${b.cards}장 가져가요` }; const duel = v.phase === 'ended' && (v.options.duelWrongLoses || v.officialDuel); if (duel) return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요 · 승부가 끝났어요` }; const lost = b.cards === 0 ? '' : v.options.wrongToPot ? ` · 종 밑에 ${b.cards}장` : ` · ${b.cards}장 나눠 줘요`; return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요${lost}` }; } // ---------------------------------------------------------------- board function HalliGalliBoard({ view: v, me, players, act, pending }: BoardProps) { const now = useServerNow(100); const phone = usePhone(); const rtt = useRoom((s) => s.rttMs); const [bubble, setBubble] = useState(null); const [ghosts, setGhosts] = useState([]); const [hit, setHit] = useState(0); const [announce, setAnnounce] = useState(''); const sentFor = useRef(null); const prev = useRef(null); // Seats rotated so that I sit at the bottom (spectators: first seat at the bottom). const myIdx = v.seats.findIndex((s) => s.id === me); const rot = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx), ...v.seats.slice(0, myIdx)]; const many = rot.length >= 5; const cw = phone ? (many ? 46 : 54) : many ? 70 : 86; const sw = phone ? (many ? 74 : 86) : many ? 116 : 132; const pos = seatPositions(rot.length, { w: sw, h: cw * 1.4 + (phone ? 50 : 58) }, phone ? 400 : 380, phone); const posOf = (id: string) => pos[rot.findIndex((s) => s.id === id)] ?? CENTER; const mine = v.seats.find((s) => s.id === me) ?? null; const playing = v.phase === 'playing'; const lockedLeft = mine ? Math.max(0, mine.lockedUntil - now) : 0; const canRing = !!mine && mine.status === 'playing' && playing && lockedLeft === 0; const myTurn = !!mine && playing && v.turn === me; const canFlip = myTurn && !v.options.autoFlip && !v.pendingFlip && mine.deck > 0 && !pending; const ring = () => { if (!canRing || sentFor.current === v.tableVersion) return; sentFor.current = v.tableVersion; setHit((h) => h + 1); sounds.ding(); haptic(30); act({ type: 'ring', version: v.tableVersion, ...(v.options.latencyComp === 'light' && rtt !== null ? { rtt } : {}) }); }; const flip = () => { if (!canFlip) return; act({ type: 'flip' }); }; const ringRef = useRef(ring); const flipRef = useRef(flip); ringRef.current = ring; flipRef.current = flip; // Keyboard: Space = bell, Enter = flip (not while typing; Enter on a focused button keeps its own meaning). useEffect(() => { const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); const down = (e: KeyboardEvent) => { if (typing(e.target) || e.altKey || e.ctrlKey || e.metaKey) return; if (e.code === 'Space' || e.key === ' ') { e.preventDefault(); if (!e.repeat) ringRef.current(); } else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement)) { if (!e.repeat) flipRef.current(); } }; const up = (e: KeyboardEvent) => { if ((e.code === 'Space' || e.key === ' ') && !typing(e.target)) e.preventDefault(); }; window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); }; }, []); // "늦었어요" and other bell 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: flips, bells, card flights. useEffect(() => { const p = prev.current; prev.current = v; if (!p) return; const b = v.lastBell; if (b && b.seq !== p.lastBell?.seq) { const bub = bellText(v, players); if (bub) { setBubble(bub); setAnnounce(bub.text); } const to = posOf(b.player); const fly: Ghost[] = []; if (b.correct) { if (b.player !== me) sounds.ding(); else haptic([40, 30, 80]); p.seats.forEach((s, i) => s.top && fly.push({ key: `g${b.seq}-${s.id}`, from: posOf(s.id), to, card: s.top, delay: i * 40 })); if (p.pot > 0) fly.push({ key: `g${b.seq}-pot`, from: CENTER, to, card: null, delay: 0 }); } else { sounds.buzz(); if (b.player === me) haptic([90, 50, 90]); const from = posOf(b.player); const targets = v.options.wrongToPot ? [CENTER] : p.seats.filter((s) => s.id !== b.player && s.status === 'playing').map((s) => posOf(s.id)); targets.slice(0, Math.max(1, b.cards)).forEach((t, i) => fly.push({ key: `w${b.seq}-${i}`, from, to: t, card: null, delay: i * 60 })); } setGhosts(fly); return; } if (v.tableVersion !== p.tableVersion && v.lastFlipBy) { sounds.stone(); const s = v.seats.find((x) => x.id === v.lastFlipBy); if (s?.top) setAnnounce(`${s.id === me ? '내가' : `${nick(players, s.id)}님이`} ${cardName(s.top)}를 뒤집었어요`); } if (v.turn === me && p.turn !== me && playing) haptic(20); }, [v]); // eslint-disable-line react-hooks/exhaustive-deps // A new table (or a judged bell) lets me ring again. useEffect(() => { if (sentFor.current !== null && sentFor.current !== v.tableVersion) sentFor.current = null; }, [v.tableVersion]); useEffect(() => { if (!ghosts.length) return; const t = setTimeout(() => setGhosts([]), 1000); return () => clearTimeout(t); }, [ghosts]); useEffect(() => { if (!bubble) return; const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1900); return () => clearTimeout(t); }, [bubble]); const bellSize = phone ? (rot.length >= 5 ? 128 : 'min(40vw, 156px)') : 'clamp(140px, 20vh, 190px)'; const countdown = v.phase === 'countdown' ? Math.max(0, Math.ceil((v.countdownEndsAt - now) / 1000)) : 0; const totals = FRUITS.map((f) => ({ f, n: v.seats.reduce((a, s) => a + (s.top && s.top.fruit === f ? s.top.count : 0), 0) })); const timeLeft = v.endsAt !== null ? Math.max(0, v.endsAt - Math.max(now, v.countdownEndsAt)) : null; const ping = rtt; return (
    {timeLeft !== null && ( ⏱ {Math.floor(timeLeft / 60_000)}:{String(Math.floor((timeLeft % 60_000) / 1000)).padStart(2, '0')} )} {v.officialDuel && playing && 2명 남음 · 다음 종으로 끝나요}
    {!phone && 스페이스 바 = 종 · Enter = 뒤집기} {v.options.showRtt && ping !== null && ( = 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${ping}밀리초`}> {ping >= 200 ? `연결이 느려요 · ${ping}ms` : `핑 ${ping}ms`} )}
      {rot.map((s, i) => ( ))}
    {v.pot > 0 && ( 종 밑 {v.pot}장 )}
    {ghosts.map((g) => ( ))} {bubble && (
    {bubble.text}
    )} {countdown > 0 && (
    {countdown} 과일이 딱 5개가 되면 종을 쳐요!
    )}
    {mine && mine.status === 'playing' && v.phase !== 'ended' && (
    내 덱 {mine.deck}장
    )} {mine && mine.status !== 'playing' && v.phase !== 'ended' && (
    {mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}
    )} {v.options.kidMode && v.phase !== 'ended' && (
    5개가 되면 종을 쳐요 {totals.map(({ f, n }) => (
    {FRUIT_KO[f]} {n}
    ))}
    )}

    {announce}

    ); } // ---------------------------------------------------------------- side function logText(e: LogEntry, players: Record, me: string | null): string { const n = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`); switch (e.t) { case 'collect': return `${n(e.p)} 종을 쳐서 ${e.cards}장을 가져갔어요 (${FRUIT_KO[e.fruit]} 5개)`; case 'wrong': return e.toPot ? `${n(e.p)} 틀려서 ${e.gave}장을 종 밑에 넣었어요` : `${n(e.p)} 틀려서 ${e.gave}장을 나눠 줬어요`; case 'duelWrong': return `${n(e.p)} 틀려서 ${nick(players, e.by)}님이 ${e.cards}장을 가져갔어요`; case 'out': return e.why === 'left' ? `${n(e.p)} 나갔어요` : e.why === 'strikeOut' ? `${n(e.p)} 3번 틀려서 탈락했어요` : `${n(e.p)} 탈락했어요`; } } function rulesLine(o: HalliGalliOptions): string { const parts = [o.endMode === 'official' ? '2명 남으면 다음 종에서 끝' : '한 명 남을 때까지', o.timeLimitMin ? `${o.timeLimitMin}분 제한` : '시간 제한 없음']; parts.push(o.autoFlip ? `자동 뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초` : `뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초`); if (o.wrongRingLockoutMs) parts.push(`틀리면 종 ${o.wrongRingLockoutMs / 1000}초 잠금`); if (o.penaltyEscalation) parts.push('벌칙 점점 늘어남'); if (o.wrongToPot) parts.push('벌칙은 종 밑으로'); if (o.strikeOut) parts.push('3번 틀리면 탈락'); if (o.duelWrongLoses) parts.push('1대1 오답은 패배'); if (o.latencyComp === 'light') parts.push('지연 보정'); return parts.join(' · '); } function HalliGalliSide({ view: v, me, players }: BoardProps) { return ( <>
    할리갈리{v.phase === 'ended' ? ' · 게임 끝' : ''} {rulesLine(v.options)}
      {v.seats.map((s) => (
    1. {s.isTurn ? '▶ ' : ''} {nick(players, s.id)} {s.id === me ? ' (나)' : ''} {s.status === 'out' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : ''} ✓{s.correct} ✗{s.wrong} {s.cards}장
    2. ))}
    {(v.pot > 0 || v.removed > 0) && ( {v.pot > 0 ? `종 밑 ${v.pot}장` : ''} {v.pot > 0 && v.removed > 0 ? ' · ' : ''} {v.removed > 0 ? `게임에서 뺀 카드 ${v.removed}장` : ''} )}
    {v.result && (
    최종 순위 {v.result.ranking.map((r) => ( {r.rank}등 {nick(players, r.id)} {' '} · {r.cards}장 · 정답 {r.correct} · 오답 {r.wrong} ))} {v.result.summary}
    )} {v.log.length > 0 && (
    기록
      {[...v.log].reverse().map((e) => (
    1. {logText(e, players, me)}
    2. ))}
    )} ); } // ---------------------------------------------------------------- status function isMyTurn(v: HalliGalliView, me: string | null): boolean { return !!me && v.phase === 'playing' && v.turn === me && v.seats.some((s) => s.id === me && s.status === 'playing'); } function statusText(v: HalliGalliView, me: string | null, players: Record): string { if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요'; if (v.phase === 'countdown') return '곧 시작해요! 과일이 딱 5개가 되면 종을 쳐요'; const mine = v.seats.find((s) => s.id === me); if (mine && mine.status !== 'playing') return '탈락했어요 · 남은 게임을 지켜봐요'; if (isMyTurn(v, me)) return v.options.autoFlip ? '내 차례예요 · 곧 자동으로 뒤집혀요' : '내 차례예요 · 카드를 뒤집어 주세요'; return `${v.turn ? nick(players, v.turn) : ''}님 차례예요 · 5개가 되면 종!`; } // ---------------------------------------------------------------- options function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { return (
    {options.map(([k, l]) => ( ))}
    ); } function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { return (
    {label} {children} {help && {help}}
    ); } const onOff: [boolean, string][] = [ [true, '켜기'], [false, '끄기'], ]; function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); return (
    set({ endMode })} /> set({ timeLimitMin })} /> set({ flipSeconds })} /> set({ kidMode })} /> {more && ( <> set({ autoFlip })} /> set({ flipGuardMs })} /> set({ wrongRingLockoutMs })} /> set({ leftoverCards })} /> set({ latencyComp })} /> set({ showRtt })} /> set({ duelWrongLoses })} /> set({ strikeOut })} /> set({ penaltyEscalation })} /> set({ wrongToPot })} /> )}
    ); } function optionsSummary(o: HalliGalliOptions): string { return rulesLine(o) + (o.kidMode ? ' · 어린이 모드' : ''); } // ---------------------------------------------------------------- rules function Rules({ options }: { options?: HalliGalliOptions }) { const o = options; return (

    과일 카드 56장(딸기·바나나·라임·자두)을 똑같이 나눠 받고, 차례대로 내 덱 맨 위 카드를 뒤집어 내 앞에 펼쳐요. 펼쳐진 카드 맨 위에 보이는 과일 가운데 한 종류가 딱 5개가 되면 가운데 종을 쳐요.

    + + = 딸기 5개 → 땡!

    먼저 친 사람(서버에 먼저 도착한 사람)이 펼쳐진 카드를 모두 가져가 덱 맨 아래에 넣고, 다음에 뒤집어요. 6개나 4개일 때 치면 틀린 거예요. 틀리면 다른 사람에게 1장씩 줘요 {o?.wrongToPot ? '(이 방에서는 종 밑에 넣어요)' : ''}. 조금 늦게 친 종은 "늦었어요"로 무시되고 벌칙은 없어요.

    차례가 왔는데 덱이 비어 있으면 탈락해요. 덱이 비어도 차례가 오기 전에 종을 맞히면 다시 살아나요.{' '} {o?.endMode === 'official' ? '2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '마지막까지 남은 사람이 이겨요.'}{' '} {o?.timeLimitMin === null ? '' : `${o?.timeLimitMin ?? 10}분이 지나면 카드(덱 + 내 펼친 카드)가 많은 사람이 이겨요.`}

    PC에서는 스페이스 바로 종을, Enter로 뒤집기를 할 수 있어요. 휴대폰은 종 그림을 누르면 바로 울려요.

    ); } const ui: GameUI = { Board: HalliGalliBoard, Side: HalliGalliSide, statusText, isMyTurn, Options, optionsSummary, Rules, quickStart, helpNow, }; export default ui;