Files
joke-app/apps/web/src/games/marble/index.tsx
EJClaw 6390e3e64c 게임 4개 원래 이름으로, .5 + NPMplus 배포 구성
- 보석 상인 → 스플렌더, 벽 쌓기 길찾기 → 쿼리도, 숫자 타일 → 루미큐브, 주사위 땅따먹기 → 부루마불
- deploy/docker-compose.proxy.yml: Caddy 없이 앱만(.5:2970), HTTPS·헤더는 NPMplus
- deploy/npmplus-advanced.conf: Caddyfile과 같은 보안 헤더
- 문서: 2026-10-06 결정, 배포 7절 (e2e 구십구 0 카드 방향 버튼 반영 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:16:32 +09:00

1398 lines
66 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 부루마불 화면 (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<string, string> = {
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<number, string> = {
0: '출발',
10: '무인도',
20: '기금받기',
21: '부에노스',
25: '우주센터',
30: '우주여행',
34: '샌프란',
38: '기금내기',
};
const FULL_LINES: Record<number, string[]> = {
20: ['사회복지기금', '받는 곳'],
21: ['부에노스', '아이레스'],
34: ['샌프란', '시스코'],
38: ['기금', '내는 곳'],
};
const CORNER_ICON: Record<number, string> = { 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<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 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 = <circle r={r} {...common} />;
else if (shape === 'square') body = <rect x={-r * 0.88} y={-r * 0.88} width={r * 1.76} height={r * 1.76} rx={r * 0.22} {...common} />;
else if (shape === 'triangle') body = <polygon points={`0,${-r * 1.08} ${r * 1.05},${r * 0.78} ${-r * 1.05},${r * 0.78}`} {...common} />;
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 = <polygon points={pts.join(' ')} {...common} />;
}
return (
<g transform={`translate(${x} ${y})`}>
<circle cx={r * 0.12} cy={r * 0.18} r={r * 0.95} fill="#000" opacity={0.28} />
{body}
</g>
);
}
function TokenIcon({ seat, size = 22 }: { seat: number; size?: number }) {
return (
<svg width={size} height={size} viewBox="-12 -12 24 24" aria-hidden style={{ flex: 'none' }}>
<Token seat={seat} x={0} y={0} r={8.5} />
</svg>
);
}
/** 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 (
<g transform={`translate(${x} ${y})`}>
{level === 1 && <polygon points={`${-h * 0.8},${h * 0.8} ${-h * 0.8},${-h * 0.1} 0,${-h * 0.85} ${h * 0.8},${-h * 0.1} ${h * 0.8},${h * 0.8}`} {...f} />}
{level === 2 && <rect x={-h * 0.55} y={-h * 0.9} width={h * 1.1} height={h * 1.8} rx={h * 0.1} {...f} />}
{level === 3 && (
<>
<rect x={-h * 0.85} y={-h * 0.55} width={h * 1.7} height={h * 1.4} {...f} />
<polygon points={`${-h * 0.95},${-h * 0.5} 0,${-h * 0.95} ${h * 0.95},${-h * 0.5}`} {...f} />
</>
)}
{level === 4 && <polygon points={`0,${-h} ${h * 0.35},${h * 0.85} ${-h * 0.35},${h * 0.85}`} {...f} />}
</g>
);
}
const PIPS: Record<number, [number, number][]> = {
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 (
<svg viewBox="0 0 100 100" aria-hidden>
{value !== null && PIPS[value]!.map(([x, y], i) => <circle key={i} cx={x} cy={y} r={value === 1 ? 13 : 10} fill={value === 1 ? '#c81e1e' : '#1c1e21'} />)}
</svg>
);
}
// ---------------------------------------------------------------- board
interface Anim {
seq: number;
player: string;
path: number[];
i: number;
}
function cellLabel(pos: number, v: MarbleView, players: Record<string, PublicUser>, 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<MarbleView>) {
const compact = useCompact();
const my = v.my;
const [focus, setFocus] = useState(0);
const [info, setInfo] = useState<number | null>(null);
const [anim, setAnim] = useState<Anim | null>(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<number, MarbleOwnedView>();
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 (
<div className="mb-wrap">
<style>{CSS}</style>
<div className="mb-box">
<svg className="mb-svg" viewBox="0 0 1000 1000" role="group" aria-label={`부루마불 판. 출발은 오른쪽 아래이고, 말은 시계 방향으로 돌아요. 방향키로 칸을 옮기고 Enter로 칸 정보를 봐요.${spaceMode ? ' 우주여행으로 갈 칸을 골라 주세요.' : ''}`}>
{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 (
<g
key={pos}
ref={(el) => {
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)}
>
<rect x={c.x} y={c.y} width={c.w} height={c.h} style={{ fill: ownerColor ? `color-mix(in srgb, ${ownerColor} 20%, var(--surface))` : 'var(--surface)' }} stroke="var(--border)" strokeWidth={2} />
{a.band && sp.group && <rect x={a.band.x} y={a.band.y} width={a.band.w} height={a.band.h} fill={GROUP_COLORS[sp.group]} />}
{a.band && sp.kind === 'tour' && <rect x={a.band.x} y={a.band.y} width={a.band.w} height={a.band.h} fill="#64748b" />}
{ownerColor && <rect x={c.x + 4} y={c.y + 4} width={c.w - 8} height={c.h - 8} fill="none" stroke={ownerColor} strokeWidth={6} rx={4} />}
{o && o.level > 0 && a.band && (
<g>
{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 <BuildingIcon key={k} level={(k + 1) as Level} x={x} y={y} s={band * 0.85} />;
})}
</g>
)}
{icon && (
<text x={tx} y={(ty += iconSize) - 4} textAnchor="middle" fontSize={iconSize} aria-hidden>
{icon}
</text>
)}
{lines.map((l, i) => {
ty += fs * 1.12;
const maxW = a.text.w - 8;
const est = l.length * fs * 0.98;
return (
<text key={i} x={tx} y={ty - fs * 0.22} textAnchor="middle" fontSize={fs} fontWeight={800} fill="var(--text)" {...(est > maxW ? { textLength: maxW, lengthAdjust: 'spacingAndGlyphs' } : {})}>
{l}
</text>
);
})}
{showPrice && (
<text x={tx} y={ty + 15} textAnchor="middle" fontSize={14} fontWeight={700} fill="var(--text-sub)">
{o ? `통행료 ${P(o.toll)}` : P(sp.price!)}
</text>
)}
{trail?.has(pos) && <rect className="mb-trail" x={c.x} y={c.y} width={c.w} height={c.h} fill="var(--focus)" opacity={0} key={`t${anim!.seq}-${pos}`} />}
{(pick || pos === decisionPos) && (
<rect className={pick ? 'mb-pick' : undefined} x={c.x + 3} y={c.y + 3} width={c.w - 6} height={c.h - 6} fill="none" stroke="var(--focus)" strokeWidth={6} rx={6} />
)}
<rect className="mb-focus" x={c.x + 2} y={c.y + 2} width={c.w - 4} height={c.h - 4} fill="none" stroke="var(--primary)" strokeWidth={7} rx={6} />
</g>
);
})}
{tokens.map(({ p, pos, idx, n }) => {
const [x, y] = tokenSpot(pos, idx, n, compact);
const moving = anim && anim.player === p.id;
return (
<g key={p.id + (moving ? `-${anim!.i}` : '')} className={moving ? 'mb-hop' : undefined} aria-hidden>
<Token seat={p.seat} x={x} y={y} r={compact ? 15 : 12} />
</g>
);
})}
</svg>
<div className="mb-center">
<Center view={v} me={me} players={players} act={act} pending={pending} rolling={rolling} card={card} onCardClose={() => setCard(null)} />
</div>
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
{info !== null && (
<SpaceSheet
pos={info}
view={v}
me={me}
players={players}
onClose={() => setInfo(null)}
onTravel={
spaceMode && info !== 30 && !pending
? () => {
act({ type: 'SPACE_TRAVEL', dest: info });
setInfo(null);
}
: null
}
/>
)}
</div>
);
}
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<string, PublicUser>;
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 (
<>
<div className="mb-title" aria-hidden>
부루마불
</div>
<div className="mb-meta">
<span className="mb-pill">{v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`}</span>
<span className="mb-pill" aria-label={`사회복지기금 ${v.fund}P`}>
💝 기금 {P(v.fund)}
</span>
<span className="mb-pill" aria-label={`행운카드 ${v.deckCount}장 남음`}>
🍀 {v.deckCount}장
</span>
</div>
{card ? (
<button type="button" key={card.seq} className="mb-card" onClick={onCardClose} aria-label={`행운카드 ${CARDS[card.id].name}: ${CARDS[card.id].text}. 누르면 닫혀요`}>
<div className="small">🍀 행운카드 · {card.player === me ? '나' : `${nick(players, card.player)}님`}</div>
<b>{CARDS[card.id].name}</b>
<div className="small">{CARDS[card.id].text}</div>
</button>
) : (
<div className="mb-dice" role="img" aria-label={r ? `마지막 주사위 ${r.dice[0]}, ${r.dice[1]}${r.double ? ', 더블' : ''}` : '아직 아무도 굴리지 않았어요'}>
{[0, 1].map((i) => (
<span key={i} className={`mb-die${dice ? '' : ' blank'}${rolling ? ' rolling' : ''}`}>
<DieFace value={dice ? dice[i]! : null} />
</span>
))}
{r && !rolling && (
<span style={{ fontWeight: 800, minWidth: '3ch' }} aria-hidden>
= {r.dice[0] + r.dice[1]}
{r.double && <div className="mb-double small">더블!</div>}
</span>
)}
</div>
)}
{v.phase !== 'finished' && <Prompt view={v} me={me} players={players} act={act} pending={pending} />}
{v.phase === 'finished' && v.result && (
<div className="mb-prompt">
<h3>{v.result.summary}</h3>
</div>
)}
{meP && !meP.alive && v.phase !== 'finished' && <p className="small muted">{meP.conceded ? '기권했어요.' : '파산했어요.'} 끝까지 구경할 수 있어요.</p>}
</>
);
}
function Prompt({ view: v, me, players, act, pending }: { view: MarbleView; me: string | null; players: Record<string, PublicUser>; 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 className={after < 50 ? 'mb-warn' : 'muted'}>사고 나면 남는 포인트 {P(after)}</p>;
};
if (my?.respond && v.trade) {
const t = v.trade;
return (
<div className="mb-prompt mine" role="region" aria-label="교환 제안">
<h3>{nick(players, t.from)}님의 교환 제안</h3>
<TradeSummary trade={t} me={me} players={players} />
<div className="mb-row">
<button className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'TRADE_RESPOND', accept: true })}>
받기
</button>
<button className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'TRADE_RESPOND', accept: false })}>
거절
</button>
</div>
</div>
);
}
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 (
<div className="mb-prompt mine" role="region" aria-label="모자란 포인트 채우기">
<h3>{P(d.short)}가 모자라요</h3>
<p>
{debtWhat(d.reason, d.to, players, me)} {P(d.amount)}를 내야 해요. 땅이나 건물을 팔아 채우면 자동으로 내요.
</p>
<div className="mb-bar" role="progressbar" aria-valuemin={0} aria-valuemax={d.amount} aria-valuenow={cash} aria-label="모은 포인트">
<span style={{ width: `${pct}%` }} />
</div>
{auto && (
<button className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'SELL', pos: auto.pos, what: auto.what })}>
가장 싼 것부터 팔기 · {spaceName(auto.pos)} {auto.what === 'top' ? LEVEL_KO[auto.level] : '땅'} +{P(auto.amount)}
</button>
)}
<p className="small muted">‘내 땅’ 목록에서 직접 골라 팔 수도 있어요.</p>
</div>
);
}
if (my?.decide) {
const d = my.decide;
const name = spaceName(d.pos);
if (d.buy !== null) {
return (
<div className="mb-prompt mine" role="region" aria-label="땅 사기">
<h3>{eul(name)} {P(d.buy)}에 살까요?</h3>
{left(d.buy)}
<div className="mb-row">
<button className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'BUY' })}>
사기
</button>
<button className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'PASS' })}>
안 사기
</button>
</div>
</div>
);
}
if (d.build) {
return (
<div className="mb-prompt mine" role="region" aria-label="건물 짓기">
<h3>
{name}에 {eul(LEVEL_KO[d.build.level]!)} {P(d.build.cost)}에 지을까요?
</h3>
<p className="muted">지으면 통행료가 {P(SPACES[d.pos]!.tolls![d.build.level]!)}가 돼요.</p>
{left(d.build.cost)}
<div className="mb-row">
<button className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'BUILD', level: d.build!.level })}>
짓기
</button>
<button className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'PASS' })}>
안 짓기
</button>
</div>
</div>
);
}
if (d.takeover !== null) {
return (
<div className="mb-prompt mine" role="region" aria-label="인수">
<h3>
{eul(name)} {P(d.takeover)}에 인수할까요?
</h3>
<p className="muted">건물째로 내 땅이 돼요. 주인은 거절할 수 없어요.</p>
{left(d.takeover)}
<div className="mb-row">
<button className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'TAKEOVER' })}>
인수하기
</button>
<button className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'PASS' })}>
안 하기
</button>
</div>
</div>
);
}
}
if (my?.space) {
return (
<div className="mb-prompt mine" role="region" aria-label="우주여행">
<h3>🚀 우주여행! 갈 칸을 판에서 골라 주세요</h3>
<p className="muted">반짝이는 칸을 누르면 미리 보기가 나와요. 출발을 지나가면 {SALARY_WORD}을 받아요.</p>
<button className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'SPACE_TRAVEL', dest: 0 })}>
출발로 가기 (+{P(v.options.salary)})
</button>
</div>
);
}
if (my?.island) {
const isl = my.island;
return (
<div className="mb-prompt mine" role="region" aria-label="무인도">
<h3>🏝️ 무인도예요 ({isl.left}번 남음)</h3>
<p className="muted">더블이 나오면 바로 탈출해서 그만큼 가요.</p>
<button className="btn btn-primary mb-roll" disabled={pending} onClick={() => act({ type: 'ROLL' })}>
🎲 탈출 굴리기
</button>
{(isl.hasCard || isl.fee !== null) && (
<div className="mb-row">
{isl.hasCard && (
<button className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'USE_ESCAPE_CARD' })}>
탈출권 쓰기
</button>
)}
{isl.fee !== null && (
<button className="btn btn-secondary" disabled={pending || !isl.canPayFee} onClick={() => act({ type: 'PAY_ISLAND_FEE' })}>
탈출비 {P(isl.fee)} 내기
</button>
)}
</div>
)}
</div>
);
}
if (my?.canRoll) {
return (
<div className="mb-prompt mine" role="region" aria-label="주사위 굴리기">
{v.extraRoll && <p className="mb-double">더블! 한 번 더 굴려요{v.doublesThisTurn === 2 ? ' (또 더블이면 무인도)' : ''}</p>}
<button className="btn btn-primary mb-roll" disabled={pending} onClick={() => act({ type: 'ROLL' })}>
{v.extraRoll ? '🎲 한 번 더 굴리기' : '🎲 주사위 굴리기'}
</button>
</div>
);
}
// 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 (
<div className="mb-prompt" role="status">
<p>{text}</p>
</div>
);
}
function debtWhat(reason: string, to: { kind: string; id?: string }, players: Record<string, PublicUser>, 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<MarbleView['trade']>; me: string | null; players: Record<string, PublicUser> }) {
const side = (lands: number[], pts: number) => [...lands.map(spaceName), ...(pts ? [P(pts)] : [])].join(', ') || '없음';
return (
<p className="small">
{t.from === me ? '내가' : `${nick(players, t.from)}님이`} 주는 것: <strong>{side(t.give.lands, t.give.cash)}</strong>
<br />
{t.to === me ? '내가' : `${nick(players, t.to)}님이`} 주는 것: <strong>{side(t.take.lands, t.take.cash)}</strong>
</p>
);
}
// ---------------------------------------------------------------- square info / space travel preview
function SpaceSheet({
pos,
view: v,
me,
players,
onClose,
onTravel,
}: {
pos: number;
view: MarbleView;
me: string | null;
players: Record<string, PublicUser>;
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<string, string> = {
start: `지나가거나 도착하면 ${SALARY_WORD} ${P(v.options.salary)}를 받아요.`,
card: '행운카드를 한 장 뽑아 바로 따라요.',
island: '도착하면 무인도에 갇혀요. 더블이 나오거나 정해진 차례가 지나면 나가요.',
fundIn: '모인 사회복지기금을 모두 받아요.',
fundOut: '사회복지기금에 15P를 내요.',
space: '다음 차례에 판의 원하는 칸으로 날아가요.',
tour: '관광지예요. 건물은 못 짓고, 2곳 이상 가지면 통행료가 2배예요.',
city: '도시예요. 별장 → 빌딩 → 호텔 순서로 한 방문에 한 단계씩 지을 수 있어요.',
};
return (
<Sheet onClose={onClose} label={`${sp.name} 정보`}>
<h2 className="row" style={{ gap: 8 }}>
{sp.group && <span className="mb-dot" style={{ background: GROUP_COLORS[sp.group] }} aria-hidden />}
{sp.name} <span className="muted small">({pos}번 칸)</span>
</h2>
<p className="muted">{KIND[sp.kind]}</p>
{(sp.kind === 'city' || sp.kind === 'tour') && (
<p>
{o ? (
<>
주인: <strong>{o.owner === me ? '나' : nick(players, o.owner)}</strong>
{sp.kind === 'city' && ` · ${LEVEL_KO[o.level]}`} · 지금 통행료 <strong>{P(o.toll)}</strong>
{o.takeoverPrice !== null && ` · 인수가 ${P(o.takeoverPrice)}`}
</>
) : (
<>
주인 없음 · 땅값 <strong>{P(sp.price!)}</strong>
</>
)}
</p>
)}
{sp.kind === 'city' && (
<table className="mb-table">
<thead>
<tr>
<th scope="col">단계</th>
<th scope="col">짓는 값</th>
<th scope="col">통행료</th>
</tr>
</thead>
<tbody>
{LEVEL_KO.slice(0, v.options.landmark ? 5 : 4).map((name, l) => (
<tr key={l} style={o && o.level === l ? { fontWeight: 800, background: 'var(--primary-soft)' } : undefined}>
<th scope="row">{name}</th>
<td>{P(l === 0 ? sp.price! : sp.build![l - 1]!)}</td>
<td>{P(sp.tolls![l]!)}</td>
</tr>
))}
</tbody>
</table>
)}
{sp.kind === 'tour' && <p className="small">통행료 {P(sp.tolls![0]!)} (관광지 2곳 이상이면 2배)</p>}
{pre && onTravel && (
<div className="card stack" style={{ padding: 12, gap: 4, background: 'var(--surface-2)' }}>
<strong>여기로 가면</strong>
{pre.fare > 0 && <span>우주센터 주인에게 탑승료 {P(pre.fare)}</span>}
<span>{pre.salary ? `출발을 지나서 ${SALARY_WORD} +${P(v.options.salary)}` : `${SALARY_WORD} 없음`}</span>
{pre.toll > 0 && <span className="mb-warn">통행료 {P(pre.toll)}</span>}
{pre.owner === me && <span>내 땅이라 건물을 지을 수 있어요</span>}
{pre.owner === null && (sp.kind === 'city' || sp.kind === 'tour') && <span>주인 없는 땅이라 살 수 있어요</span>}
</div>
)}
<div className="stack">
{onTravel && (
<button className="btn btn-primary btn-block" onClick={onTravel}>
🚀 여기로 가기
</button>
)}
<button className="btn btn-secondary btn-block" onClick={onClose}>
닫기
</button>
</div>
</Sheet>
);
}
// ---------------------------------------------------------------- side panel
function PlayerCard({ p, v, me, players, deadline }: { p: MarblePlayerView; v: MarbleView; me: string | null; players: Record<string, PublicUser>; 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 (
<div
className={`card mb-pcard${v.current === p.id && v.phase !== 'finished' ? ' turn' : ''}${p.alive ? '' : ' out'}`}
aria-label={`${SEAT_NAMES[p.seat]} ${nick(players, p.id)}${p.id === me ? '(나)' : ''}: ${P(p.cash)}, 땅 ${p.lands.length}곳, 총자산 ${P(p.totalAssets)}${status.length ? `, ${status.join(', ')}` : ''}`}
>
<div className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
<TokenIcon seat={p.seat} size={26} />
<Avatar user={userOf(players, p.id)} size={28} />
<strong style={{ flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{nick(players, p.id)}
{p.id === me && <span className="muted small"> (나)</span>}
</strong>
<span className="mb-num" aria-hidden>
{P(p.cash)}
</span>
{active && <TurnTimer deadline={deadline} totalMs={Math.max(1000, totalMs)} mine={p.id === me} size={34} />}
</div>
<div className="row small" style={{ gap: 6, marginTop: 4, flexWrap: 'wrap' }} aria-hidden>
<span className="muted">
땅 {p.lands.length}곳 · 총자산 {P(p.totalAssets)}
</span>
{status.map((x) => (
<span key={x} className="mb-badge" style={x === '자리 비움' ? { background: 'var(--warning)' } : undefined}>
{x}
</span>
))}
{p.heldCards.map((c) => (
<span key={c} className="mb-badge" style={{ background: '#b45309' }}>
🍀 {CARDS[c].name}
</span>
))}
</div>
{delta && (
<span key={delta.k} className={`mb-pop ${delta.n > 0 ? 'plus' : 'minus'}`} aria-hidden>
{delta.n > 0 ? `+${delta.n}` : `−${-delta.n}`}
</span>
)}
</div>
);
}
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 (
<details className="card wide-open" open={!!sell || undefined} style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>
내 땅 ({mine.length}곳){sell ? ' · 팔 것을 골라 주세요' : ''}
</summary>
{mine.length === 0 ? (
<p className="small muted" style={{ marginTop: 8 }}>
아직 가진 땅이 없어요.
</p>
) : (
<ul className="mb-lands" style={{ marginTop: 8 }}>
{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 (
<li key={o.pos}>
<span className="mb-dot" style={{ background: sp.group ? GROUP_COLORS[sp.group] : '#64748b' }} aria-hidden />
<span style={{ flex: 1, minWidth: 0 }}>
<strong>{sp.name}</strong> <span className="small muted">{sp.kind === 'city' ? LEVEL_KO[o.level] : '관광지'} · 통행료 {P(o.toll)}</span>
</span>
{top && (
<button className="btn btn-secondary btn-sm" disabled={pending} onClick={() => act({ type: 'SELL', pos: o.pos, what: 'top' })} aria-label={`${sp.name} ${LEVEL_KO[o.level]} 팔기, ${P(top.amount)} 받음`}>
{LEVEL_KO[o.level]} 팔기 +{P(top.amount)}
</button>
)}
{all && (
<button className="btn btn-secondary btn-sm" disabled={pending} onClick={() => act({ type: 'SELL', pos: o.pos, what: 'all' })} aria-label={`${sp.name} 땅째 팔기, ${P(all.amount)} 받음`}>
땅째 팔기 +{P(all.amount)}
</button>
)}
</li>
);
})}
</ul>
)}
</details>
);
}
function TradeSheet({ v, me, players, act, onClose }: { v: MarbleView; me: string; players: Record<string, PublicUser>; 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<number[]>([]);
const [take, setTake] = useState<number[]>([]);
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 ? (
<span className="small muted">땅이 없어요</span>
) : (
lands.map((pos) => (
<label key={pos} className="mb-check">
<input type="checkbox" checked={sel.includes(pos)} onChange={() => toggle(sel, set, pos)} />
{spaceName(pos)} <span className="small muted">({LEVEL_KO[v.owned.find((o) => o.pos === pos)?.level ?? 0]})</span>
</label>
))
);
return (
<Sheet onClose={onClose} label="교환 제안하기">
<h2>교환 제안하기</h2>
<div className="segmented" role="group" aria-label="누구에게">
{others.map((p) => (
<button
key={p.id}
type="button"
aria-pressed={to === p.id}
onClick={() => {
setTo(p.id);
setTake([]);
setTakeCash(0);
}}
>
{nick(players, p.id)}
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<fieldset className="stack" style={{ gap: 4, border: 0, padding: 0, margin: 0 }}>
<legend style={{ fontWeight: 700 }}>내가 줄 것</legend>
{landList(mine.lands, give, setGive)}
<label className="small">
포인트{' '}
<input type="number" min={0} max={mine.cash} value={giveCash} onChange={(e) => setGiveCash(Math.max(0, Math.min(mine.cash, Math.floor(Number(e.target.value) || 0))))} style={{ width: 80 }} />
</label>
</fieldset>
<fieldset className="stack" style={{ gap: 4, border: 0, padding: 0, margin: 0 }}>
<legend style={{ fontWeight: 700 }}>받을 것</legend>
{them && landList(them.lands, take, setTake)}
<label className="small">
포인트{' '}
<input type="number" min={0} max={them?.cash ?? 0} value={takeCash} onChange={(e) => setTakeCash(Math.max(0, Math.min(them?.cash ?? 0, Math.floor(Number(e.target.value) || 0))))} style={{ width: 80 }} />
</label>
</fieldset>
</div>
<p className="small muted">상대가 20초 안에 답하지 않으면 거절돼요. 건물이 있는 땅은 건물째로 넘어가요.</p>
<div className="stack">
<button
className="btn btn-primary btn-block"
disabled={!to || empty}
onClick={() => {
act({ type: 'PROPOSE_TRADE', to, give: { lands: give, cash: giveCash }, take: { lands: take, cash: takeCash } });
onClose();
}}
>
제안하기
</button>
<button className="btn btn-secondary btn-block" onClick={onClose}>
취소
</button>
</div>
</Sheet>
);
}
function logLine(e: GameEvent, players: Record<string, PublicUser>): 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<MarbleView>) {
const [confirm, setConfirm] = useState(false);
const [trade, setTrade] = useState(false);
const ref = useRef<HTMLOListElement>(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 (
<>
<section className="stack" style={{ gap: 8 }} aria-label="참가자">
{v.players.map((p) => (
<PlayerCard key={p.id} p={p} v={v} me={me} players={players} deadline={deadline} />
))}
</section>
{me && meP?.alive && v.phase !== 'finished' && <MyLands v={v} me={me} act={act} pending={pending} />}
{v.my?.canPropose && (
<button className="btn btn-secondary" onClick={() => setTrade(true)}>
🤝 교환 제안하기 ({v.tradesLeft}번 남음)
</button>
)}
{lines.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>진행 기록</summary>
<ol ref={ref} className="mb-log" style={{ marginTop: 8 }}>
{lines.slice(-40).map((l, i) => (
<li key={`${v.turnSeq}-${i}`}>{l}</li>
))}
</ol>
</details>
)}
{meP?.alive && v.phase !== 'finished' && (
<button className="btn btn-danger btn-sm" onClick={() => setConfirm(true)}>
🏳 기권
</button>
)}
{trade && me && <TradeSheet v={v} me={me} players={players} act={act} onClose={() => setTrade(false)} />}
{confirm && (
<ConfirmSheet
title="기권할까요?"
body="내 땅과 건물은 모두 은행으로 돌아가고, 게임에서 빠져요."
confirm="기권하기"
danger
onConfirm={() => 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, PublicUser>): 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<T extends string | number>({ 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 }: { label: string; children: React.ReactNode }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
</div>
);
}
function Options({ value: o, onChange }: OptionsProps<MarbleOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<MarbleOptions>) => onChange({ ...o, ...patch });
const onOff = (label: string, key: 'buildOnPurchase' | 'takeover' | 'landmark' | 'monopolyWin' | 'trade', on: string, off: string) => (
<Field label={label}>
<Seg label={label} value={o[key] ? 'on' : 'off'} options={[['on', on], ['off', off]]} onChange={(k) => set({ [key]: k === 'on' } as Partial<MarbleOptions>)} />
</Field>
);
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="라운드 제한">
<Seg label="라운드 제한" value={o.maxRounds} options={[[15, '15라운드'], [20, '20라운드'], [30, '30라운드'], [0, '무제한']]} onChange={(maxRounds) => set({ maxRounds })} />
</Field>
<Field label="시작 포인트">
<Seg label="시작 포인트" value={o.startMoney} options={[[200, '200P'], [300, '300P'], [500, '500P']]} onChange={(startMoney) => set({ startMoney })} />
</Field>
<Field label="결정 1번 제한 시간">
<Seg
label="결정 1번 제한 시간"
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
options={[['15', '15초'], ['30', '30초'], ['60', '60초'], ['none', '무제한']]}
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 15 | 30 | 60) })}
/>
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 우리 집 규칙 닫기' : '▼ 우리 집 규칙(자세한 설정)'}
</button>
{more && (
<>
<Field label={SALARY_WORD}>
<Seg label={SALARY_WORD} value={o.salary} options={[[10, '10P'], [20, '20P'], [30, '30P']]} onChange={(salary) => set({ salary })} />
</Field>
<Field label="무인도에서 쉬는 차례">
<Seg label="무인도에서 쉬는 차례" value={o.islandTurns} options={[[1, '1번'], [2, '2번'], [3, '3번']]} onChange={(islandTurns) => set({ islandTurns })} />
</Field>
<Field label="무인도 탈출비">
<Seg label="무인도 탈출비" value={o.islandFee} options={[[0, '없음'], [10, '10P'], [20, '20P']]} onChange={(islandFee) => set({ islandFee })} />
</Field>
{onOff('산 자리에서 바로 별장 짓기', 'buildOnPurchase', '가능', '다음 방문부터')}
{onOff('인수(2배 값에 사들이기)', 'takeover', '켜기', '끄기')}
{onOff('랜드마크(호텔 다음 단계)', 'landmark', '켜기', '끄기')}
{onOff('독점 즉시 승리', 'monopolyWin', '켜기', '끄기')}
{onOff('땅·포인트 교환', 'trade', '켜기', '끄기')}
</>
)}
</div>
);
}
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 (
<div className="stack">
<p>
주사위 2개를 굴려 세계 도시 40칸 판을 시계 방향으로 돌아요. 주인 없는 땅에 서면 <strong>살 수 있고</strong>, 내 땅에 다시 오면 <strong>별장 → 빌딩 → 호텔</strong>
{o?.landmark ? ' → 랜드마크' : ''} 순서로 한 단계씩 지어요. 다른 사람이 내 땅에 서면 <strong>통행료</strong>를 받아요.
</p>
<p>
<strong>포인트(P)</strong>는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 저장하거나 바꿀 수 없어요.
</p>
<p>
<strong>출발</strong>을 지나거나 도착하면 {SALARY_WORD} {P(o?.salary ?? 20)}를 받아요. <strong>더블</strong>(두 주사위가 같음)이면 한 번 더 굴리고, 한 차례에 더블이 3번 나오면 바로
무인도로 가요.
</p>
<p>
<strong>무인도</strong>: 최대 {o?.islandTurns ?? 3}번 쉬어요. 내 차례에 더블이 나오면 바로 나가요{o?.islandFee ? `. 탈출비 ${P(o.islandFee)}를 내고 나갈 수도 있어요` : ''}. 무인도에
있어도 통행료는 받아요.
</p>
<p>
<strong>우주여행</strong>: 도착하면 다음 차례에 판의 원하는 칸으로 날아가요. 우주센터 주인에게 탑승료 {P(20)}를 내요(내 것이거나 주인이 없으면 무료).
</p>
<p>
<strong>사회복지기금</strong>: ‘내는 곳’에서 {P(15)}를 내고, 행운카드 벌금도 여기에 모여요. ‘받는 곳’에 도착하면 모인 기금을 모두 받아요.
</p>
<p>
<strong>행운카드</strong>: 20장 중 맨 위 카드를 뽑아 바로 따라요. 무인도 탈출권과 통행료 면제권은 가지고 있다가 써요.
</p>
<p>
<strong>관광지</strong>(제주도·경주·우주센터·부산)에는 건물을 못 짓지만, 2곳 이상 가지면 통행료가 2배예요.
</p>
<p>
내야 할 포인트가 모자라면 땅이나 건물을 은행에 팔아(낸 값의 절반) 채워요. 다 팔아도 모자라면 <strong>파산</strong>해서 빠져요.
</p>
{o?.takeover && <p>인수: 남의 도시에서 통행료를 낸 뒤, 땅값과 건물값 합의 2배를 주고 사들일 수 있어요(랜드마크·관광지 제외).</p>}
{o?.monopolyWin && <p>독점 승리: 도시 그룹 3개를 모두 갖거나(트리플), 한 줄의 도시 6곳을 모두 갖거나(라인), 관광지 4곳을 모두 가지면 바로 이겨요.</p>}
{o?.trade && <p>교환: 주사위를 굴리기 전에 다른 사람에게 땅·포인트 교환을 제안할 수 있어요(한 차례 2번까지).</p>}
<p>
{o?.maxRounds === 0 ? '한 사람만 남을 때까지 해요.' : `${o?.maxRounds ?? 20}라운드가 끝나면`} <strong>총자산</strong>(포인트 + 땅값 + 건물값)이 가장 많은 사람이 이겨요. 그 전에 다른 사람이 모두
파산해도 이겨요.
</p>
<p className="muted small">시간 안에 정하지 않으면 자동으로 굴리고, 사거나 짓지 않고 넘어가요. 모자란 포인트는 가장 싼 것부터 팔아 채워요.</p>
</div>
);
}
const ui: GameUI<MarbleView, MarbleOptions> = {
Board: MarbleBoard,
Side: MarbleSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;