/** 부루마불 화면 (docs/games/marble.md §9). Board, dice, tokens and buildings are drawn here (SVG/CSS only). */ import { useEffect, useMemo, useRef, useState } from 'react'; import type { MarbleOptions, MarbleOwnedView, MarblePlayerView, MarbleView } from '@bg/games/marble'; import { CARDS, SPACES, type CardId, type Level } from '@bg/games/marble/board'; import type { PublicUser } from '@bg/shared/lite'; import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; import { helpNow } from './help'; import { quickStart } from './quick'; type GameEvent = MarbleView['log'][number]; // ---------------------------------------------------------------- look & words const SEAT_COLORS = ['#dc2626', '#2563eb', '#16a34a', '#d97706']; const SEAT_NAMES = ['빨강', '파랑', '초록', '주황']; const SHAPES = ['circle', 'triangle', 'square', 'star'] as const; const GROUP_COLORS: Record = { A: '#8d5a2b', B: '#38bdf8', C: '#db2777', D: '#f97316', E: '#e11d48', F: '#eab308', G: '#16a34a', H: '#1e40af', }; const LEVEL_KO = ['땅', '별장', '빌딩', '호텔', '랜드마크']; const P = (n: number) => `${n}P`; const SALARY_WORD = '월급'; /** Short names for the phone board (2 lines of 2 letters at most). */ const SHORT: Record = { 0: '출발', 10: '무인도', 20: '기금받기', 21: '부에노스', 25: '우주센터', 30: '우주여행', 34: '샌프란', 38: '기금내기', }; const FULL_LINES: Record = { 20: ['사회복지기금', '받는 곳'], 21: ['부에노스', '아이레스'], 34: ['샌프란', '시스코'], 38: ['기금', '내는 곳'], }; const CORNER_ICON: Record = { 0: '🏁', 10: '🏝️', 20: '💝', 30: '🚀' }; const hasBatchim = (w: string) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; }; const eul = (w: string) => `${w}${hasBatchim(w) ? '을' : '를'}`; const ro = (w: string) => { const c = w.charCodeAt(w.length - 1); const jong = c >= 0xac00 && c <= 0xd7a3 ? (c - 0xac00) % 28 : 0; return `${w}${jong === 0 || jong === 8 ? '로' : '으로'}`; }; 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 reducedMotion = () => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; const spaceName = (pos: number) => SPACES[pos]!.name; function useCompact(): boolean { const q = '(max-width: 640px)'; const [c, setC] = useState(() => typeof window !== 'undefined' && !!window.matchMedia?.(q).matches); useEffect(() => { const m = window.matchMedia?.(q); if (!m) return; const on = () => setC(m.matches); m.addEventListener('change', on); return () => m.removeEventListener('change', on); }, []); return c; } const CSS = ` .mb-wrap { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 10px; } .mb-box { position: relative; width: 100%; max-width: min(100%, calc(100vh - 64px - 52px - 36px)); aspect-ratio: 1 / 1; } @media (max-width: 1099px) { .mb-box { max-width: min(100%, 760px); } } .mb-svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; border-radius: 16px; box-shadow: var(--shadow); background: var(--surface-2); touch-action: manipulation; user-select: none; } .mb-cell { cursor: pointer; outline: none; } .mb-cell .mb-focus { opacity: 0; } .mb-cell:focus-visible .mb-focus { opacity: 1; } .mb-cell.pick .mb-pick { animation: mb-pulse 1.1s ease-in-out infinite; } .mb-trail { animation: mb-flash .5s ease-out both; } @keyframes mb-pulse { 0%, 100% { opacity: .25; } 50% { opacity: .9; } } @keyframes mb-flash { from { opacity: .85; } to { opacity: 0; } } @keyframes mb-hop { 0% { transform: translateY(0); } 50% { transform: translateY(-9px); } 100% { transform: translateY(0); } } .mb-hop { transform-box: fill-box; transform-origin: center bottom; animation: mb-hop .14s ease-out; } .mb-center { position: absolute; left: 13.6%; top: 13.6%; width: 72.8%; height: 72.8%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 12px; text-align: center; overflow: auto; } .mb-title { font-weight: 900; font-size: 1.5rem; letter-spacing: -.02em; color: var(--text); opacity: .9; } .mb-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } .mb-pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 10px; border-radius: 999px; background: var(--surface); border: 1px solid var(--border); font-weight: 700; font-size: .9rem; white-space: nowrap; } .mb-dice { display: flex; gap: 10px; align-items: center; justify-content: center; min-height: 64px; } .mb-die { width: 60px; height: 60px; border-radius: 13px; background: #fffdf8; border: 3px solid #c9c4b8; box-shadow: 0 3px 0 #b7b1a3, 0 4px 10px rgba(0,0,0,.15); flex: none; } .mb-die svg { width: 100%; height: 100%; display: block; } .mb-die.blank { background: var(--surface); border-style: dashed; box-shadow: none; } .mb-die.rolling { animation: mb-tumble .6s ease-out both; } @keyframes mb-tumble { 0% { transform: translateY(-16px) rotate(-40deg) scale(.9); } 50% { transform: translateY(4px) rotate(200deg); } 100% { transform: translateY(0) rotate(360deg); } } .mb-double { font-weight: 900; color: var(--warning); } .mb-prompt { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 8px; align-items: stretch; background: var(--surface); border-radius: 14px; padding: 12px; box-shadow: var(--shadow); border: 2px solid var(--border); } .mb-prompt.mine { border-color: var(--primary); } .mb-prompt h3 { margin: 0; font-size: 1.12rem; line-height: 1.35; } .mb-prompt p { margin: 0; } .mb-row { display: flex; gap: 8px; } .mb-row > .btn { flex: 1; min-width: 0; } .mb-roll { min-height: 60px; font-size: 1.2rem; } .mb-warn { color: var(--warning); font-weight: 700; } .mb-bar { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; } .mb-bar > span { display: block; height: 100%; background: var(--danger); transition: width .3s; } .mb-card { width: min(100%, 320px); border-radius: 16px; padding: 12px 14px; text-align: center; font: inherit; background: linear-gradient(160deg, #fef3c7, #fde68a); color: #3b2a05; border: 3px solid #b45309; box-shadow: 0 10px 30px rgba(0,0,0,.35); cursor: pointer; animation: mb-card .35s ease-out both; } .mb-card b { display: block; font-size: 1.3rem; margin: 4px 0; } @keyframes mb-card { from { transform: rotateY(90deg) scale(.7); opacity: 0; } to { transform: rotateY(0) scale(1); opacity: 1; } } .mb-pop { position: absolute; right: 8px; top: -6px; font-weight: 900; font-size: .95rem; animation: mb-rise 1.6s ease-out both; pointer-events: none; } .mb-pop.plus { color: var(--success); } .mb-pop.minus { color: var(--danger); } @keyframes mb-rise { from { transform: translateY(6px); opacity: 0; } 15% { opacity: 1; } to { transform: translateY(-14px); opacity: 0; } } .mb-pcard { position: relative; padding: 10px 12px; border: 3px solid transparent; } .mb-pcard.turn { border-color: var(--primary); } .mb-pcard.out { opacity: .55; } .mb-pcard .mb-num { font-weight: 800; font-size: 1.2rem; font-variant-numeric: tabular-nums; } .mb-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .76rem; color: #fff; background: var(--text-sub); white-space: nowrap; } .mb-lands { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } .mb-lands li { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .mb-dot { width: 12px; height: 12px; border-radius: 3px; flex: none; display: inline-block; } .mb-log { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; max-height: 180px; overflow: auto; font-size: .9rem; } .mb-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } .mb-table th, .mb-table td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: center; } .mb-check { display: flex; align-items: center; gap: 8px; min-height: 40px; } .mb-check input { width: 22px; height: 22px; } @media (max-width: 640px) { .mb-center { gap: 6px; padding: 6px; } .mb-title { display: none; } .mb-card { padding: 6px 8px; } .mb-card b { font-size: 1rem; margin: 0; } .mb-pill { font-size: .78rem; padding: 1px 7px; } .mb-dice { min-height: 40px; gap: 6px; } .mb-die { width: 38px; height: 38px; border-radius: 9px; border-width: 2px; } .mb-prompt { padding: 8px; gap: 6px; border-radius: 10px; } .mb-prompt h3 { font-size: .95rem; } .mb-prompt p { font-size: .82rem; } .mb-prompt .btn { min-height: 44px; padding: 6px 8px; font-size: .95rem; } .mb-roll { min-height: 48px; font-size: 1rem; } } @media (prefers-reduced-motion: reduce) { .mb-cell.pick .mb-pick, .mb-trail, .mb-hop, .mb-die.rolling, .mb-card, .mb-pop { animation: none; } } `; // ---------------------------------------------------------------- geometry (viewBox 0..1000) const C = 136; const W = (1000 - 2 * C) / 9; type Side = 'bottom' | 'left' | 'top' | 'right' | 'corner'; interface Cell { x: number; y: number; w: number; h: number; side: Side; } const CELLS: Cell[] = Array.from({ length: 40 }, (_, pos): Cell => { if (pos === 0) return { x: 1000 - C, y: 1000 - C, w: C, h: C, side: 'corner' }; if (pos === 10) return { x: 0, y: 1000 - C, w: C, h: C, side: 'corner' }; if (pos === 20) return { x: 0, y: 0, w: C, h: C, side: 'corner' }; if (pos === 30) return { x: 1000 - C, y: 0, w: C, h: C, side: 'corner' }; if (pos < 10) return { x: 1000 - C - pos * W, y: 1000 - C, w: W, h: C, side: 'bottom' }; if (pos < 20) return { x: 0, y: 1000 - C - (pos - 10) * W, w: C, h: W, side: 'left' }; if (pos < 30) return { x: C + (pos - 21) * W, y: 0, w: W, h: C, side: 'top' }; return { x: 1000 - C, y: C + (pos - 31) * W, w: C, h: W, side: 'right' }; }); const center = (pos: number): [number, number] => { const c = CELLS[pos]!; return [c.x + c.w / 2, c.y + c.h / 2]; }; /** Areas inside a cell: the group band (inner edge), the text block and the token strip (outer edge). */ function areas(pos: number, band: number, compact = false) { const c = CELLS[pos]!; // Phone: tokens sit in the middle of the square, so the text may use the whole cell. const tok = compact && c.side !== 'corner' ? 0 : 40; switch (c.side) { case 'bottom': return { band: { x: c.x, y: c.y, w: c.w, h: band }, text: { x: c.x, y: c.y + band, w: c.w, h: c.h - band - tok }, tok: { x: c.x, y: c.y + c.h - tok, w: c.w, h: tok } }; case 'top': return { band: { x: c.x, y: c.y + c.h - band, w: c.w, h: band }, text: { x: c.x, y: c.y + tok, w: c.w, h: c.h - band - tok }, tok: { x: c.x, y: c.y, w: c.w, h: tok } }; case 'left': return { band: { x: c.x + c.w - band, y: c.y, w: band, h: c.h }, text: { x: c.x + tok, y: c.y, w: c.w - band - tok, h: c.h }, tok: { x: c.x, y: c.y, w: tok, h: c.h } }; case 'right': return { band: { x: c.x, y: c.y, w: band, h: c.h }, text: { x: c.x + band, y: c.y, w: c.w - band - tok, h: c.h }, tok: { x: c.x + c.w - tok, y: c.y, w: tok, h: c.h } }; default: return { band: null, text: { x: c.x, y: c.y, w: c.w, h: c.h - tok }, tok: { x: c.x, y: c.y + c.h - tok, w: c.w, h: tok } }; } } /** Token spot `i` (0..3) of `n` on a square. */ function tokenSpot(pos: number, i: number, n: number, compact: boolean): [number, number] { if (compact) { const [cx, cy] = center(pos); const d = n === 1 ? 0 : 17; const offs: [number, number][] = n <= 2 ? [[-d, 0], [d, 0]] : [[-d, -d], [d, -d], [-d, d], [d, d]]; const [ox, oy] = offs[i] ?? [0, 0]; return [cx + ox, cy + oy]; } const t = areas(pos, 22).tok; const horizontal = t.w >= t.h; const k = (i + 0.5) / Math.max(n, 1); return horizontal ? [t.x + t.w * (0.1 + 0.8 * k), t.y + t.h / 2] : [t.x + t.w / 2, t.y + t.h * (0.1 + 0.8 * k)]; } // ---------------------------------------------------------------- small drawings function Token({ seat, x, y, r }: { seat: number; x: number; y: number; r: number }) { const color = SEAT_COLORS[seat] ?? '#888'; const shape = SHAPES[seat % 4]!; const common = { fill: color, stroke: '#fff', strokeWidth: r * 0.2, strokeLinejoin: 'round' as const }; let body: React.ReactNode; if (shape === 'circle') body = ; else if (shape === 'square') body = ; else if (shape === 'triangle') body = ; else { const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 === 0 ? r * 1.15 : r * 0.52; return `${(Math.cos(a) * rr).toFixed(1)},${(Math.sin(a) * rr).toFixed(1)}`; }); body = ; } return ( {body} ); } function TokenIcon({ seat, size = 22 }: { seat: number; size?: number }) { return ( ); } /** Building stage icon (white on the group band). */ function BuildingIcon({ level, x, y, s }: { level: Level; x: number; y: number; s: number }) { const f = { fill: '#fff', stroke: '#1c1e21', strokeWidth: s * 0.08, strokeLinejoin: 'round' as const }; const h = s / 2; return ( {level === 1 && } {level === 2 && } {level === 3 && ( <> )} {level === 4 && } ); } const PIPS: Record = { 1: [[50, 50]], 2: [[28, 28], [72, 72]], 3: [[26, 26], [50, 50], [74, 74]], 4: [[28, 28], [72, 28], [28, 72], [72, 72]], 5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]], 6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]], }; function DieFace({ value }: { value: number | null }) { return ( {value !== null && PIPS[value]!.map(([x, y], i) => )} ); } // ---------------------------------------------------------------- board interface Anim { seq: number; player: string; path: number[]; i: number; } function cellLabel(pos: number, v: MarbleView, players: Record, me: string | null): string { const sp = SPACES[pos]!; const parts = [`${pos}번 칸 ${sp.name}`]; const o = v.owned.find((x) => x.pos === pos); if (sp.kind === 'city' || sp.kind === 'tour') { if (o) parts.push(`${o.owner === me ? '내' : `${nick(players, o.owner)}님`} 땅${sp.kind === 'city' ? `, ${LEVEL_KO[o.level]}` : ''}, 통행료 ${P(o.toll)}`); else parts.push(`주인 없음, 땅값 ${P(sp.price!)}`); } const here = v.players.filter((p) => p.alive && p.pos === pos).map((p) => (p.id === me ? '나' : nick(players, p.id))); if (here.length) parts.push(`말: ${here.join(', ')}`); return parts.join('. '); } function MarbleBoard({ view: v, me, players, act, pending }: BoardProps) { const compact = useCompact(); const my = v.my; const [focus, setFocus] = useState(0); const [info, setInfo] = useState(null); const [anim, setAnim] = useState(null); const [rolling, setRolling] = useState(false); const [card, setCard] = useState<{ id: CardId; player: string; seq: number } | null>(null); const [announce, setAnnounce] = useState(''); const seen = useRef({ roll: v.lastRoll?.seq ?? 0, move: v.lastMove?.seq ?? 0, card: v.lastCard?.seq ?? 0, current: v.current }); const cellRefs = useRef<(SVGGElement | null)[]>([]); const spaceMode = !!my?.space; const who = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`); // Dice roll: tumble + screen-reader text. useEffect(() => { const r = v.lastRoll; if (!r || r.seq === seen.current.roll) return; seen.current.roll = r.seq; setCard(null); // a new roll replaces an old card on the center setAnnounce(`${who(r.player)} ${r.dice[0]}, ${r.dice[1]}을 굴렸어요${r.double ? '. 더블!' : ''}`); sounds.stone(); if (reducedMotion()) return; setRolling(true); const t = setTimeout(() => setRolling(false), 600); return () => clearTimeout(t); }, [v.lastRoll?.seq]); // eslint-disable-line react-hooks/exhaustive-deps // Token walks along the path (forward / back); jumps for warps and space travel. useEffect(() => { const m = v.lastMove; if (!m || m.seq === seen.current.move) return; seen.current.move = m.seq; if (reducedMotion() || m.kind === 'warp' || m.kind === 'space') return; const path: number[] = []; if (m.kind === 'back') for (let p = m.from; p !== m.to; ) path.push((p = (p + 39) % 40)); else for (let p = m.from; p !== m.to; ) path.push((p = (p + 1) % 40)); if (path.length) setAnim({ seq: m.seq, player: m.player, path: [m.from, ...path], i: 0 }); }, [v.lastMove?.seq]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!anim) return; if (anim.i >= anim.path.length - 1) { const t = setTimeout(() => setAnim(null), 100); return () => clearTimeout(t); } const step = Math.max(45, Math.min(130, 1100 / anim.path.length)); const t = setTimeout(() => setAnim((a) => (a && a.seq === anim.seq ? { ...a, i: a.i + 1 } : a)), step); return () => clearTimeout(t); }, [anim]); // Card reveal. useEffect(() => { const c = v.lastCard; if (!c || c.seq === seen.current.card) return; seen.current.card = c.seq; setCard({ id: c.id, player: c.player, seq: c.seq }); setAnnounce(`${who(c.player)} 행운카드를 뽑았어요: ${CARDS[c.id].name}. ${CARDS[c.id].text}`); const t = setTimeout(() => setCard((x) => (x && x.seq === c.seq ? null : x)), 3200); return () => clearTimeout(t); }, [v.lastCard?.seq]); // eslint-disable-line react-hooks/exhaustive-deps // My-turn cue. useEffect(() => { if (v.current !== seen.current.current && v.current === me && v.phase !== 'finished') { haptic(40); sounds.myTurn(); } seen.current.current = v.current; }, [v.current]); // eslint-disable-line react-hooks/exhaustive-deps const moveCell = (to: number) => { const n = (to + 40) % 40; setFocus(n); cellRefs.current[n]?.focus(); }; const onCellKey = (pos: number) => (e: React.KeyboardEvent) => { if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); moveCell(pos + 1); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); moveCell(pos - 1); } else if (e.key === 'Home') { e.preventDefault(); moveCell(0); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); // keep the chat's global Enter shortcut away setInfo(pos); } }; const ownedAt = useMemo(() => { const m = new Map(); for (const o of v.owned) m.set(o.pos, o); return m; }, [v.owned]); const seatOf = (id: string) => v.players.find((p) => p.id === id)?.seat ?? 0; const trail = anim ? new Set(anim.path.slice(1, anim.i + 1)) : null; const band = compact ? 28 : 22; // Tokens: the moving one is drawn at its animation spot. const tokens: { p: MarblePlayerView; pos: number; idx: number; n: number }[] = []; const alive = v.players.filter((p) => p.alive); for (const p of alive) { const pos = anim && anim.player === p.id ? anim.path[anim.i]! : p.pos; tokens.push({ p, pos, idx: 0, n: 0 }); } for (const t of tokens) { const same = tokens.filter((x) => x.pos === t.pos); t.n = same.length; t.idx = same.indexOf(t); } const decisionPos = v.phase === 'decide' ? v.decision?.pos : undefined; return (
{CELLS.map((c, pos) => { const sp = SPACES[pos]!; const o = ownedAt.get(pos); const a = areas(pos, band, compact); const ownerColor = o ? SEAT_COLORS[seatOf(o.owner)]! : null; const pick = spaceMode && pos !== 30; const lines = compact ? splitShort(pos) : FULL_LINES[pos] ?? (sp.name.length > 5 ? [sp.name.slice(0, 4), sp.name.slice(4)] : [sp.name]); const fs = compact ? (c.side === 'corner' ? 30 : c.side === 'left' || c.side === 'right' ? 31 : 28) : c.side === 'corner' ? 19 : 17; const tx = a.text.x + a.text.w / 2; const showPrice = !compact && (sp.kind === 'city' || sp.kind === 'tour'); const icon = CORNER_ICON[pos] ?? (sp.kind === 'card' ? '🍀' : sp.kind === 'tour' ? '🗺️' : null); const iconSize = compact ? 30 : c.side === 'corner' ? 34 : 20; const blockH = (icon ? iconSize + 4 : 0) + lines.length * fs * 1.12 + (showPrice ? 18 : 0); let ty = a.text.y + (a.text.h - blockH) / 2; return ( { cellRefs.current[pos] = el; }} className={`mb-cell${pick ? ' pick' : ''}`} role="button" tabIndex={pos === focus ? 0 : -1} aria-label={cellLabel(pos, v, players, me) + (pick ? '. 우주여행으로 갈 수 있어요' : '')} onClick={() => { setFocus(pos); setInfo(pos); }} onKeyDown={onCellKey(pos)} onFocus={() => setFocus(pos)} > {a.band && sp.group && } {a.band && sp.kind === 'tour' && } {ownerColor && } {o && o.level > 0 && a.band && ( {Array.from({ length: o.level }, (_, k) => { const horiz = a.band!.w >= a.band!.h; const step = (horiz ? a.band!.w : a.band!.h) / (o.level + 1); const x = horiz ? a.band!.x + step * (k + 1) : a.band!.x + a.band!.w / 2; const y = horiz ? a.band!.y + a.band!.h / 2 : a.band!.y + step * (k + 1); return ; })} )} {icon && ( {icon} )} {lines.map((l, i) => { ty += fs * 1.12; const maxW = a.text.w - 8; const est = l.length * fs * 0.98; return ( maxW ? { textLength: maxW, lengthAdjust: 'spacingAndGlyphs' } : {})}> {l} ); })} {showPrice && ( {o ? `통행료 ${P(o.toll)}` : P(sp.price!)} )} {trail?.has(pos) && } {(pick || pos === decisionPos) && ( )} ); })} {tokens.map(({ p, pos, idx, n }) => { const [x, y] = tokenSpot(pos, idx, n, compact); const moving = anim && anim.player === p.id; return ( ); })}
setCard(null)} />

{announce}

{info !== null && ( setInfo(null)} onTravel={ spaceMode && info !== 30 && !pending ? () => { act({ type: 'SPACE_TRAVEL', dest: info }); setInfo(null); } : null } /> )}
); } function splitShort(pos: number): string[] { const n = SHORT[pos] ?? SPACES[pos]!.name; if (n.length <= 3) return [n]; return [n.slice(0, 2), n.slice(2, 4)]; } // ---------------------------------------------------------------- center (dice + prompt) function Center({ view: v, me, players, act, pending, rolling, card, onCardClose, }: { view: MarbleView; me: string | null; players: Record; act: (a: unknown) => void; pending: boolean; rolling: boolean; card: { id: CardId; player: string; seq: number } | null; onCardClose: () => void; }) { const [flick, setFlick] = useState<[number, number] | null>(null); useEffect(() => { if (!rolling) { setFlick(null); return; } const t = setInterval(() => setFlick([1 + Math.floor(Math.random() * 6), 1 + Math.floor(Math.random() * 6)]), 70); return () => clearInterval(t); }, [rolling]); const r = v.lastRoll; const dice = flick ?? r?.dice ?? null; const meP = me ? v.players.find((p) => p.id === me) : undefined; return ( <>
부루마불
{v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`} 💝 기금 {P(v.fund)} 🍀 {v.deckCount}장
{card ? ( ) : (
{[0, 1].map((i) => ( ))} {r && !rolling && ( = {r.dice[0] + r.dice[1]} {r.double &&
더블!
}
)}
)} {v.phase !== 'finished' && } {v.phase === 'finished' && v.result && (

{v.result.summary}

)} {meP && !meP.alive && v.phase !== 'finished' &&

{meP.conceded ? '기권했어요.' : '파산했어요.'} 끝까지 구경할 수 있어요.

} ); } function Prompt({ view: v, me, players, act, pending }: { view: MarbleView; me: string | null; players: Record; act: (a: unknown) => void; pending: boolean }) { const my = v.my; const meP = me ? v.players.find((p) => p.id === me) : undefined; const cur = v.current ?? ''; const cash = meP?.cash ?? 0; const left = (cost: number) => { const after = cash - cost; return

사고 나면 남는 포인트 {P(after)}

; }; if (my?.respond && v.trade) { const t = v.trade; return (

{nick(players, t.from)}님의 교환 제안

); } if (my?.sell && v.debt) { const d = v.debt; const pct = Math.min(100, (cash / d.amount) * 100); const auto = my.autoSell ? my.sell.find((o) => o.pos === my.autoSell!.pos && o.what === my.autoSell!.what) : null; return (

{P(d.short)}가 모자라요

{debtWhat(d.reason, d.to, players, me)} {P(d.amount)}를 내야 해요. 땅이나 건물을 팔아 채우면 자동으로 내요.

{auto && ( )}

‘내 땅’ 목록에서 직접 골라 팔 수도 있어요.

); } if (my?.decide) { const d = my.decide; const name = spaceName(d.pos); if (d.buy !== null) { return (

{eul(name)} {P(d.buy)}에 살까요?

{left(d.buy)}
); } if (d.build) { return (

{name}에 {eul(LEVEL_KO[d.build.level]!)} {P(d.build.cost)}에 지을까요?

지으면 통행료가 {P(SPACES[d.pos]!.tolls![d.build.level]!)}가 돼요.

{left(d.build.cost)}
); } if (d.takeover !== null) { return (

{eul(name)} {P(d.takeover)}에 인수할까요?

건물째로 내 땅이 돼요. 주인은 거절할 수 없어요.

{left(d.takeover)}
); } } if (my?.space) { return (

🚀 우주여행! 갈 칸을 판에서 골라 주세요

반짝이는 칸을 누르면 미리 보기가 나와요. 출발을 지나가면 {SALARY_WORD}을 받아요.

); } if (my?.island) { const isl = my.island; return (

🏝️ 무인도예요 ({isl.left}번 남음)

더블이 나오면 바로 탈출해서 그만큼 가요.

{(isl.hasCard || isl.fee !== null) && (
{isl.hasCard && ( )} {isl.fee !== null && ( )}
)}
); } if (my?.canRoll) { return (
{v.extraRoll &&

더블! 한 번 더 굴려요{v.doublesThisTurn === 2 ? ' (또 더블이면 무인도)' : ''}

}
); } // Somebody else is acting. let text = `${nick(players, cur)}님 차례예요`; if (v.phase === 'decide' && v.decision) text = `${nick(players, cur)}님이 ${spaceName(v.decision.pos)}에서 고민 중이에요`; if (v.phase === 'debt' && v.debt) text = `${nick(players, v.debt.debtor)}님이 모자란 ${P(v.debt.short)}를 채우는 중이에요`; if (v.phase === 'tradeResponse' && v.trade) text = `${nick(players, v.trade.to)}님이 교환 제안을 보는 중이에요`; return (

{text}

); } function debtWhat(reason: string, to: { kind: string; id?: string }, players: Record, me: string | null): string { const target = to.kind === 'player' ? (to.id === me ? '나' : `${nick(players, to.id!)}님`) : to.kind === 'fund' ? '사회복지기금' : '은행'; if (reason === 'toll') return `${target}에게 통행료`; if (reason === 'fare') return `${target}에게 우주여행 탑승료`; if (reason === 'fund') return '사회복지기금에'; return `${target}에게 행운카드로`; } function TradeSummary({ trade: t, me, players }: { trade: NonNullable; me: string | null; players: Record }) { const side = (lands: number[], pts: number) => [...lands.map(spaceName), ...(pts ? [P(pts)] : [])].join(', ') || '없음'; return (

{t.from === me ? '내가' : `${nick(players, t.from)}님이`} 주는 것: {side(t.give.lands, t.give.cash)}
{t.to === me ? '내가' : `${nick(players, t.to)}님이`} 주는 것: {side(t.take.lands, t.take.cash)}

); } // ---------------------------------------------------------------- square info / space travel preview function SpaceSheet({ pos, view: v, me, players, onClose, onTravel, }: { pos: number; view: MarbleView; me: string | null; players: Record; onClose: () => void; onTravel: (() => void) | null; }) { const sp = SPACES[pos]!; const o = v.owned.find((x) => x.pos === pos); const pre = v.spacePreview?.find((p) => p.dest === pos); const KIND: Record = { start: `지나가거나 도착하면 ${SALARY_WORD} ${P(v.options.salary)}를 받아요.`, card: '행운카드를 한 장 뽑아 바로 따라요.', island: '도착하면 무인도에 갇혀요. 더블이 나오거나 정해진 차례가 지나면 나가요.', fundIn: '모인 사회복지기금을 모두 받아요.', fundOut: '사회복지기금에 15P를 내요.', space: '다음 차례에 판의 원하는 칸으로 날아가요.', tour: '관광지예요. 건물은 못 짓고, 2곳 이상 가지면 통행료가 2배예요.', city: '도시예요. 별장 → 빌딩 → 호텔 순서로 한 방문에 한 단계씩 지을 수 있어요.', }; return (

{sp.group && } {sp.name} ({pos}번 칸)

{KIND[sp.kind]}

{(sp.kind === 'city' || sp.kind === 'tour') && (

{o ? ( <> 주인: {o.owner === me ? '나' : nick(players, o.owner)} {sp.kind === 'city' && ` · ${LEVEL_KO[o.level]}`} · 지금 통행료 {P(o.toll)} {o.takeoverPrice !== null && ` · 인수가 ${P(o.takeoverPrice)}`} ) : ( <> 주인 없음 · 땅값 {P(sp.price!)} )}

)} {sp.kind === 'city' && ( {LEVEL_KO.slice(0, v.options.landmark ? 5 : 4).map((name, l) => ( ))}
단계 짓는 값 통행료
{name} {P(l === 0 ? sp.price! : sp.build![l - 1]!)} {P(sp.tolls![l]!)}
)} {sp.kind === 'tour' &&

통행료 {P(sp.tolls![0]!)} (관광지 2곳 이상이면 2배)

} {pre && onTravel && (
여기로 가면 {pre.fare > 0 && 우주센터 주인에게 탑승료 {P(pre.fare)}} {pre.salary ? `출발을 지나서 ${SALARY_WORD} +${P(v.options.salary)}` : `${SALARY_WORD} 없음`} {pre.toll > 0 && 통행료 {P(pre.toll)}} {pre.owner === me && 내 땅이라 건물을 지을 수 있어요} {pre.owner === null && (sp.kind === 'city' || sp.kind === 'tour') && 주인 없는 땅이라 살 수 있어요}
)}
{onTravel && ( )}
); } // ---------------------------------------------------------------- side panel function PlayerCard({ p, v, me, players, deadline }: { p: MarblePlayerView; v: MarbleView; me: string | null; players: Record; deadline: number | null }) { const active = v.phase !== 'finished' && ((v.phase === 'debt' && v.debt?.debtor === p.id) || (v.phase === 'tradeResponse' && v.trade?.to === p.id) || (v.phase !== 'debt' && v.phase !== 'tradeResponse' && v.current === p.id)); const prev = useRef(p.cash); const [delta, setDelta] = useState<{ n: number; k: number } | null>(null); useEffect(() => { const d = p.cash - prev.current; prev.current = p.cash; if (d === 0) return; setDelta({ n: d, k: Date.now() }); const t = setTimeout(() => setDelta(null), 1600); return () => clearTimeout(t); }, [p.cash]); const status: string[] = []; if (!p.alive) status.push(p.conceded ? '기권' : '파산'); if (p.islandLeft > 0) status.push(`무인도 ${p.islandLeft}`); if (p.spacePending) status.push('우주여행 대기'); if (p.away) status.push('자리 비움'); const totalMs = v.phase === 'tradeResponse' ? 20_000 : p.away ? 5000 : (v.options.turnSeconds ?? 0) * 1000; return (
{nick(players, p.id)} {p.id === me && (나)} {P(p.cash)} {active && }
땅 {p.lands.length}곳 · 총자산 {P(p.totalAssets)} {status.map((x) => ( {x} ))} {p.heldCards.map((c) => ( 🍀 {CARDS[c].name} ))}
{delta && ( 0 ? 'plus' : 'minus'}`} aria-hidden> {delta.n > 0 ? `+${delta.n}` : `−${-delta.n}`} )}
); } function MyLands({ v, me, act, pending }: { v: MarbleView; me: string; act: (a: unknown) => void; pending: boolean }) { const mine = v.owned.filter((o) => o.owner === me); const sell = v.my?.sell ?? null; return (
내 땅 ({mine.length}곳){sell ? ' · 팔 것을 골라 주세요' : ''} {mine.length === 0 ? (

아직 가진 땅이 없어요.

) : (
    {mine.map((o) => { const sp = SPACES[o.pos]!; const top = sell?.find((x) => x.pos === o.pos && x.what === 'top'); const all = sell?.find((x) => x.pos === o.pos && x.what === 'all'); return (
  • {sp.name} {sp.kind === 'city' ? LEVEL_KO[o.level] : '관광지'} · 통행료 {P(o.toll)} {top && ( )} {all && ( )}
  • ); })}
)}
); } function TradeSheet({ v, me, players, act, onClose }: { v: MarbleView; me: string; players: Record; act: (a: unknown) => void; onClose: () => void }) { const others = v.players.filter((p) => p.alive && p.id !== me); const [to, setTo] = useState(others[0]?.id ?? ''); const [give, setGive] = useState([]); const [take, setTake] = useState([]); const [giveCash, setGiveCash] = useState(0); const [takeCash, setTakeCash] = useState(0); const mine = v.players.find((p) => p.id === me)!; const them = v.players.find((p) => p.id === to); const toggle = (list: number[], set: (x: number[]) => void, pos: number) => set(list.includes(pos) ? list.filter((x) => x !== pos) : [...list, pos]); const empty = give.length + take.length + giveCash + takeCash === 0; const landList = (lands: number[], sel: number[], set: (x: number[]) => void) => lands.length === 0 ? ( 땅이 없어요 ) : ( lands.map((pos) => ( )) ); return (

교환 제안하기

{others.map((p) => ( ))}
내가 줄 것 {landList(mine.lands, give, setGive)}
받을 것 {them && landList(them.lands, take, setTake)}

상대가 20초 안에 답하지 않으면 거절돼요. 건물이 있는 땅은 건물째로 넘어가요.

); } function logLine(e: GameEvent, players: Record): string | null { const who = (k = 'player') => (typeof e[k] === 'string' ? nick(players, e[k] as string) : ''); const at = (k = 'pos') => spaceName(e[k] as number); switch (e.type) { case 'turn': return `— ${who()}님 차례 (${e.round}라운드)`; case 'rolled': { const d = e.dice as [number, number]; return `${who()}: 🎲 ${d[0]}+${d[1]}${e.double ? ' 더블!' : ''}${e.island ? ' (무인도)' : ''}`; } case 'moved': return e.kind === 'roll' ? null : `${who()}: ${ro(at('to'))} 이동`; case 'salary': return `${who()}: ${SALARY_WORD} +${P(e.amount as number)}`; case 'bought': return `${who()}: ${eul(at())} ${P(e.price as number)}에 샀어요`; case 'built': return `${who()}: ${at()}에 ${eul(LEVEL_KO[e.level as number]!)} 지었어요`; case 'tollPaid': return e.waived ? `${who('from')}: 통행료 면제권을 썼어요 (${at()})` : `${who('from')} → ${who('to')}: 통행료 ${P(e.amount as number)}`; case 'takeover': return `${who()}: ${eul(at())} ${P(e.price as number)}에 인수했어요`; case 'cardDrawn': return `${who()}: 🍀 ${CARDS[e.cardId as CardId].name}`; case 'cardGain': return `${who()}: +${P(e.amount as number)}`; case 'paid': { const to = e.to as { kind: string; id?: string }; return `${who('from')} → ${to.kind === 'player' ? nick(players, to.id!) : to.kind === 'fund' ? '기금' : '은행'}: ${P(e.amount as number)}`; } case 'fundPaid': return `${who()}: 기금에 ${P(e.amount as number)}`; case 'fundReceived': return (e.amount as number) > 0 ? `${who()}: 기금 ${P(e.amount as number)}를 받았어요` : `${who()}: 모인 기금이 없어요`; case 'toIsland': return `${who()}: 무인도로! ${e.why === 'doubles' ? '(더블 3번)' : e.why === 'card' ? '(폭풍 경보)' : ''}`; case 'islandStay': return `${who()}: 무인도에 머물러요 (${e.left}번 남음)`; case 'escaped': return `${who()}: 무인도 탈출 ${e.how === 'double' ? '(더블)' : e.how === 'card' ? '(탈출권)' : `(탈출비 ${P(e.amount as number)})`}`; case 'spaceWait': return `${who()}: 다음 차례에 우주여행`; case 'spaceTravel': return `${who()}: 🚀 ${ro(at('dest'))} 우주여행`; case 'debt': return `${who()}: ${P(e.short as number)}가 모자라요`; case 'sold': return `${who()}: ${at()} ${e.what === 'top' ? '건물' : '땅'} 팔기 +${P(e.amount as number)}`; case 'bankrupt': return `${who()}님 파산`; case 'conceded': return `${who()}님 기권`; case 'cannotAfford': return `${who()}: 포인트가 모자라 ${e.what === 'buy' ? '못 샀어요' : '못 지었어요'} (${at()})`; case 'tradeProposed': return `${who('from')} → ${who('to')}: 교환 제안`; case 'tradeResolved': return `교환 ${e.accepted ? '성사!' : e.failed ? '실패(조건이 바뀜)' : e.cancelled ? '취소' : '거절'}`; case 'roundEnd': return `${e.round}라운드 끝`; case 'monopoly': return `${who()}: 독점 달성!`; case 'timeout': return `${who()}님 시간 초과 · 자동으로 했어요`; case 'away': return `${who()}님 자리 비움`; case 'gameOver': return String(e.summary ?? '게임이 끝났어요'); default: return null; } } function MarbleSide({ view: v, me, players, act, pending, deadline }: BoardProps) { const [confirm, setConfirm] = useState(false); const [trade, setTrade] = useState(false); const ref = useRef(null); const lines = v.log.map((e) => logLine(e, players)).filter((x): x is string => !!x); useEffect(() => { ref.current?.scrollTo({ top: ref.current.scrollHeight }); }, [v.turnSeq]); const meP = me ? v.players.find((p) => p.id === me) : undefined; return ( <>
{v.players.map((p) => ( ))}
{me && meP?.alive && v.phase !== 'finished' && } {v.my?.canPropose && ( )} {lines.length > 0 && (
진행 기록
    {lines.slice(-40).map((l, i) => (
  1. {l}
  2. ))}
)} {meP?.alive && v.phase !== 'finished' && ( )} {trade && me && setTrade(false)} />} {confirm && ( act({ type: 'CONCEDE' })} onClose={() => setConfirm(false)} /> )} ); } // ---------------------------------------------------------------- status function isMyTurn(v: MarbleView, me: string | null): boolean { const m = v.my; return !!m && (m.canRoll || !!m.island || m.space || !!m.decide || !!m.sell || m.respond); } function statusText(v: MarbleView, me: string | null, players: Record): string { if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; const meP = me ? v.players.find((p) => p.id === me) : undefined; if (meP && !meP.alive) return meP.conceded ? '기권했어요. 구경하는 중이에요' : '파산했어요. 구경하는 중이에요'; const m = v.my; if (m) { if (m.respond) return '교환 제안이 왔어요. 받을지 정해 주세요'; if (m.sell && v.debt) return `포인트가 ${P(v.debt.short)} 모자라요. 땅이나 건물을 팔아 주세요`; if (m.decide) { if (m.decide.buy !== null) return `내 차례예요. ${eul(spaceName(m.decide.pos))} 살지 정해 주세요`; if (m.decide.build) return `내 차례예요. ${eul(LEVEL_KO[m.decide.build.level]!)} 지을지 정해 주세요`; return '내 차례예요. 인수할지 정해 주세요'; } if (m.space) return '내 차례예요. 우주여행으로 갈 칸을 골라 주세요'; if (m.island) return '내 차례예요. 무인도에서 주사위를 굴려 주세요(더블이면 탈출)'; if (m.canRoll) return v.extraRoll ? '내 차례예요. 더블! 한 번 더 굴려 주세요' : '내 차례예요. 주사위를 굴려 주세요'; } const round = v.maxRounds > 0 ? ` · ${v.round}/${v.maxRounds}라운드` : ` · ${v.round}라운드`; if (v.phase === 'debt' && v.debt) return `${nick(players, v.debt.debtor)}님이 포인트를 채우는 중이에요${round}`; if (v.phase === 'tradeResponse' && v.trade) return `${nick(players, v.trade.to)}님이 교환 제안을 보는 중이에요`; return v.current ? `${nick(players, v.current)}님 차례예요${round}` : '잠시만 기다려 주세요'; } // ---------------------------------------------------------------- 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 }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } function Options({ value: o, onChange }: OptionsProps) { const [more, setMore] = useState(false); const set = (patch: Partial) => onChange({ ...o, ...patch }); const onOff = (label: string, key: 'buildOnPurchase' | 'takeover' | 'landmark' | 'monopolyWin' | 'trade', on: string, off: string) => ( set({ [key]: k === 'on' } as Partial)} /> ); return (
set({ maxRounds })} /> set({ startMoney })} /> set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })} /> {more && ( <> set({ salary })} /> set({ islandTurns })} /> set({ islandFee })} /> {onOff('산 자리에서 바로 별장 짓기', 'buildOnPurchase', '가능', '다음 방문부터')} {onOff('인수(2배 값에 사들이기)', 'takeover', '켜기', '끄기')} {onOff('랜드마크(호텔 다음 단계)', 'landmark', '켜기', '끄기')} {onOff('독점 즉시 승리', 'monopolyWin', '켜기', '끄기')} {onOff('땅·포인트 교환', 'trade', '켜기', '끄기')} )}
); } function optionsSummary(o: MarbleOptions): string { return [ o.maxRounds > 0 ? `${o.maxRounds}라운드` : '라운드 무제한', `시작 ${P(o.startMoney)}`, `${SALARY_WORD} ${P(o.salary)}`, ...(o.islandTurns !== 3 ? [`무인도 ${o.islandTurns}번`] : []), ...(o.islandFee ? [`탈출비 ${P(o.islandFee)}`] : []), ...(o.buildOnPurchase ? ['산 자리에서 별장'] : []), ...(o.takeover ? ['인수'] : []), ...(o.landmark ? ['랜드마크'] : []), ...(o.monopolyWin ? ['독점 승리'] : []), ...(o.trade ? ['교환'] : []), o.turnSeconds === null ? '시간 무제한' : `${o.turnSeconds}초`, ].join(' · '); } // ---------------------------------------------------------------- rules function Rules({ options }: { options?: MarbleOptions }) { const o = options; return (

주사위 2개를 굴려 세계 도시 40칸 판을 시계 방향으로 돌아요. 주인 없는 땅에 서면 살 수 있고, 내 땅에 다시 오면 별장 → 빌딩 → 호텔 {o?.landmark ? ' → 랜드마크' : ''} 순서로 한 단계씩 지어요. 다른 사람이 내 땅에 서면 통행료를 받아요.

포인트(P)는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 저장하거나 바꿀 수 없어요.

출발을 지나거나 도착하면 {SALARY_WORD} {P(o?.salary ?? 20)}를 받아요. 더블(두 주사위가 같음)이면 한 번 더 굴리고, 한 차례에 더블이 3번 나오면 바로 무인도로 가요.

무인도: 최대 {o?.islandTurns ?? 3}번 쉬어요. 내 차례에 더블이 나오면 바로 나가요{o?.islandFee ? `. 탈출비 ${P(o.islandFee)}를 내고 나갈 수도 있어요` : ''}. 무인도에 있어도 통행료는 받아요.

우주여행: 도착하면 다음 차례에 판의 원하는 칸으로 날아가요. 우주센터 주인에게 탑승료 {P(20)}를 내요(내 것이거나 주인이 없으면 무료).

사회복지기금: ‘내는 곳’에서 {P(15)}를 내고, 행운카드 벌금도 여기에 모여요. ‘받는 곳’에 도착하면 모인 기금을 모두 받아요.

행운카드: 20장 중 맨 위 카드를 뽑아 바로 따라요. 무인도 탈출권과 통행료 면제권은 가지고 있다가 써요.

관광지(제주도·경주·우주센터·부산)에는 건물을 못 짓지만, 2곳 이상 가지면 통행료가 2배예요.

내야 할 포인트가 모자라면 땅이나 건물을 은행에 팔아(낸 값의 절반) 채워요. 다 팔아도 모자라면 파산해서 빠져요.

{o?.takeover &&

인수: 남의 도시에서 통행료를 낸 뒤, 땅값과 건물값 합의 2배를 주고 사들일 수 있어요(랜드마크·관광지 제외).

} {o?.monopolyWin &&

독점 승리: 도시 그룹 3개를 모두 갖거나(트리플), 한 줄의 도시 6곳을 모두 갖거나(라인), 관광지 4곳을 모두 가지면 바로 이겨요.

} {o?.trade &&

교환: 주사위를 굴리기 전에 다른 사람에게 땅·포인트 교환을 제안할 수 있어요(한 차례 2번까지).

}

{o?.maxRounds === 0 ? '한 사람만 남을 때까지 해요.' : `${o?.maxRounds ?? 20}라운드가 끝나면`} 총자산(포인트 + 땅값 + 건물값)이 가장 많은 사람이 이겨요. 그 전에 다른 사람이 모두 파산해도 이겨요.

시간 안에 정하지 않으면 자동으로 굴리고, 사거나 짓지 않고 넘어가요. 모자란 포인트는 가장 싼 것부터 팔아 채워요.

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