- 보석 상인 → 스플렌더, 벽 쌓기 길찾기 → 쿼리도, 숫자 타일 → 루미큐브, 주사위 땅따먹기 → 부루마불 - 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>
1398 lines
66 KiB
TypeScript
1398 lines
66 KiB
TypeScript
/** 부루마불 화면 (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;
|