M4: 숫자 타일 규칙·화면·테스트
- 규칙 모듈(packages/games/src/rummikub): 106타일, 선 정하기·분배, 그룹/런 판정과 조커 해석, 서버 초안(setDraft/undoDraft/resetDraft) + 보존 검사(C1~C3), 확정 검증(받침대 1장 이상, 첫 등록 점수, 재배치), 시간 초과(그대로면 1장, 올바르면 확정/되돌림, 틀리면 벌칙 장수), 풀 소진 교착 종료, 점수 계산, 여러 판 매치, 나가기·자리 비움, 숨김 정보 view, secretsOf - 테스트: 문서 10절 시나리오 전부 + 엔진 randomPlayout 25회 + 시드 1,000개 무작위 대국 (타일 106장 보존, 매 단계 정보 유출·onTimeout 합법성 검사, 결정성) - 화면(apps/web/src/games/rummikub): 탭으로 고르고 놓을 곳 누르기(◀ 앞 / ▶ 뒤 / 새 묶음), 받침대 색깔순·숫자순 정렬, 되돌리기·처음으로, 실시간 묶음 검사(초록/빨강 + 이유), 첫 등록 진행 막대, 도와줘 힌트, 한 장 가져오기·확정, 색+모양 표시 타일(조커 자체 디자인) - 등록: GAMES, CATALOG, 화면 레지스트리, 아이콘(곧 나올 게임 목록에서 제거) - E2E: PC 방장 + 휴대폰 친구가 화면으로 10차례(힌트로 내기, 되돌리기, 가져오기) 진행 - @bg/games 패키지에 './rummikub/rules' 내보내기 추가(화면 청크에 zod가 안 들어가게) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
baduk: () => import('./baduk'),
|
||||
seotda: () => import('./seotda'),
|
||||
poker: () => import('./poker'),
|
||||
rummikub: () => import('./rummikub'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
823
apps/web/src/games/rummikub/index.tsx
Normal file
823
apps/web/src/games/rummikub/index.tsx
Normal file
@@ -0,0 +1,823 @@
|
||||
/** 숫자 타일 화면 (docs/games/rummikub.md §9). Tap a tile to pick it up, then tap where it goes. */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
// Pure tile/set helpers only (no zod): keeps this lazy chunk small.
|
||||
import { findMeld, normalizeSet, pickDisjoint, setProblem, sortTiles, tileLabel, tileOf, type DraftSet, type JokerAs, type TileColor } from '@bg/games/rummikub/rules';
|
||||
import type { LogEntry, RummikubOptions, RummikubView } from '@bg/games/rummikub';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar, ConfirmSheet } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
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' };
|
||||
|
||||
/** Colour + shape mark so colours never have to be told apart by hue alone (§9). */
|
||||
const MARK: Record<TileColor, string> = { K: '●', R: '♥', B: '◆', O: '★' };
|
||||
|
||||
const CSS = `
|
||||
.rk-root { width: 100%; align-self: stretch; flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rk-players { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rk-player { display: flex; align-items: center; gap: 6px; background: var(--surface); color: var(--text); border: 3px solid transparent;
|
||||
border-radius: 12px; padding: 4px 10px 4px 6px; box-shadow: var(--shadow); font-size: .9rem; min-height: 44px; }
|
||||
.rk-player.turn { border-color: var(--focus); }
|
||||
.rk-player.gone { opacity: .55; }
|
||||
.rk-player .rk-count { font-weight: 800; font-variant-numeric: tabular-nums; }
|
||||
.rk-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .75rem; line-height: 1.6; }
|
||||
.rk-table { flex: 1; min-height: 200px; overflow: auto; background: var(--felt); border-radius: var(--radius-lg); padding: 10px;
|
||||
display: flex; flex-wrap: wrap; align-content: flex-start; align-items: flex-start; gap: 10px 12px; box-shadow: inset 0 2px 10px rgba(0,0,0,.25); }
|
||||
.rk-table-head { flex-basis: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .92rem; text-shadow: 0 1px 2px rgba(0,0,0,.4); }
|
||||
.rk-empty { color: rgba(255,255,255,.85); font-size: .95rem; padding: 6px 2px; }
|
||||
.rk-set { display: flex; flex-wrap: wrap; align-items: center; gap: 3px; padding: 4px; border-radius: 10px; border: 3px solid transparent; background: rgba(0,0,0,.18); max-width: 100%; }
|
||||
.rk-set.ok { border-color: #86efac; }
|
||||
.rk-set.bad { border-color: #fca5a5; border-style: dashed; background: rgba(127,29,29,.35); }
|
||||
.rk-set.changed { animation: rk-flash 1s ease-out 2; }
|
||||
@keyframes rk-flash { 0% { box-shadow: 0 0 0 5px #fde047; } 100% { box-shadow: 0 0 0 0 rgba(253,224,71,0); } }
|
||||
.rk-problem { flex-basis: 100%; font-size: .8rem; font-weight: 700; color: #fff; background: #b91c1c; border-radius: 6px; padding: 1px 6px; }
|
||||
.rk-setinfo { flex-basis: 100%; display: flex; gap: 6px; align-items: center; justify-content: space-between; font-size: .78rem; color: rgba(255,255,255,.9); }
|
||||
.rk-setinfo button { background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.5); border-radius: 6px; min-height: 28px; padding: 0 6px; cursor: pointer; font-size: .78rem; }
|
||||
.rk-tile { --w: 44px; --h: 60px; --tc: #111827; position: relative; width: var(--w); height: var(--h); flex: none; padding: 0; border-radius: 8px;
|
||||
background: linear-gradient(#fffdf7, #f1e7cf); border: 1px solid #c9b98f; box-shadow: 0 2px 0 #b5a172, 0 3px 6px rgba(0,0,0,.25);
|
||||
display: inline-flex; flex-direction: column; align-items: center; justify-content: center; color: var(--tc); font-weight: 900; line-height: 1;
|
||||
font-family: inherit; transition: transform .2s ease, box-shadow .2s ease; user-select: none; }
|
||||
button.rk-tile { cursor: pointer; }
|
||||
button.rk-tile:disabled { cursor: default; }
|
||||
.rk-tile[data-c=K] { --tc: #111827; }
|
||||
.rk-tile[data-c=R] { --tc: #c62828; }
|
||||
.rk-tile[data-c=B] { --tc: #1d4ed8; }
|
||||
.rk-tile[data-c=O] { --tc: #a14a00; }
|
||||
.rk-n { font-size: calc(var(--h) * .44); font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
|
||||
.rk-m { font-size: calc(var(--h) * .2); margin-top: 3px; }
|
||||
.rk-jk { display: block; width: calc(var(--w) * .62); height: calc(var(--w) * .62); }
|
||||
.rk-jkas { position: absolute; right: 2px; top: 1px; font-size: .62rem; font-weight: 800; color: #374151; }
|
||||
.rk-tile[aria-pressed=true] { transform: translateY(-7px); box-shadow: 0 0 0 3px var(--focus), 0 6px 10px rgba(0,0,0,.3); }
|
||||
.rk-tile.hint { box-shadow: 0 0 0 3px #a855f7, 0 3px 6px rgba(0,0,0,.25); }
|
||||
.rk-tile.fresh::after { content: '새'; position: absolute; top: -7px; left: -5px; background: var(--primary); color: var(--primary-text); font-size: .62rem; font-weight: 800; border-radius: 999px; padding: 1px 4px; }
|
||||
.rk-tile.sm { --w: 38px; --h: 52px; }
|
||||
.rk-tile.xs { --w: 28px; --h: 38px; box-shadow: 0 1px 0 #b5a172; }
|
||||
.rk-tile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.rk-slot { min-width: 30px; height: 52px; border: 2px dashed rgba(255,255,255,.85); border-radius: 8px; background: rgba(255,255,255,.14); color: #fff;
|
||||
font-weight: 900; font-size: 1rem; cursor: pointer; padding: 0 4px; }
|
||||
.rk-slot:hover, .rk-newset:hover { background: rgba(255,255,255,.28); }
|
||||
.rk-newset { border: 2px dashed rgba(255,255,255,.9); color: #fff; background: rgba(255,255,255,.12); border-radius: 10px; min-height: 60px; padding: 0 16px; font-weight: 800; font-size: 1rem; cursor: pointer; }
|
||||
.rk-meld { flex-basis: 100%; display: flex; align-items: center; gap: 8px; color: #fff; font-weight: 700; font-size: .9rem; }
|
||||
.rk-meld progress { flex: 1; height: 12px; accent-color: #facc15; }
|
||||
.rk-bar { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
|
||||
.rk-bar .btn { min-height: 52px; padding: 0 6px; font-size: .98rem; }
|
||||
.rk-msg { margin: 0; min-height: 1.4em; text-align: center; font-size: .92rem; }
|
||||
.rk-rackwrap { display: flex; flex-direction: column; gap: 6px; }
|
||||
.rk-rackhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
|
||||
.rk-rackhead .segmented button { min-height: 40px; }
|
||||
.rk-rack { background: var(--wood); border-radius: var(--radius-lg); padding: 10px 8px 14px; display: flex; flex-wrap: wrap; gap: 5px; min-height: 84px;
|
||||
box-shadow: inset 0 -7px 0 var(--wood-line); align-content: flex-start; }
|
||||
.rk-rack-empty { color: #3b2a0a; font-weight: 700; align-self: center; }
|
||||
.rk-result { display: grid; gap: 6px; }
|
||||
.rk-result-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.rk-mini { display: inline-flex; gap: 2px; flex-wrap: wrap; }
|
||||
@media (max-width: 1099px) {
|
||||
.rk-table { overflow: visible; min-height: 160px; }
|
||||
}
|
||||
@media (max-width: 719px) {
|
||||
.rk-bar { position: sticky; bottom: 0; z-index: 5; background: var(--bg); padding: 6px 0; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.rk-bar .btn { min-height: 48px; font-size: .95rem; }
|
||||
.rk-tile.sm { --w: 34px; --h: 47px; }
|
||||
.rk-slot { height: 47px; min-width: 28px; }
|
||||
.rk-table { padding: 8px; gap: 8px; }
|
||||
.rk-player { font-size: .85rem; padding: 3px 8px 3px 4px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rk-tile { transition: none; }
|
||||
.rk-set.changed { animation: none; box-shadow: 0 0 0 3px #fde047; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- tile
|
||||
|
||||
function JokerFace() {
|
||||
// Four coloured quarters + a ring: not a face, not a copy of any commercial joker design.
|
||||
return (
|
||||
<svg className="rk-jk" viewBox="0 0 40 40" aria-hidden>
|
||||
<path d="M20 20 L20 3 A17 17 0 0 1 37 20 Z" fill="#c62828" />
|
||||
<path d="M20 20 L37 20 A17 17 0 0 1 20 37 Z" fill="#1d4ed8" />
|
||||
<path d="M20 20 L20 37 A17 17 0 0 1 3 20 Z" fill="#a14a00" />
|
||||
<path d="M20 20 L3 20 A17 17 0 0 1 20 3 Z" fill="#111827" />
|
||||
<circle cx="20" cy="20" r="8" fill="#fffdf7" />
|
||||
<text x="20" y="24" textAnchor="middle" fontSize="11" fontWeight="900" fill="#111827">J</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface TileProps {
|
||||
id: string;
|
||||
size?: 'md' | 'sm' | 'xs';
|
||||
as?: JokerAs;
|
||||
selected?: boolean;
|
||||
hint?: boolean;
|
||||
fresh?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function Tile({ id, size = 'md', as, selected, hint, fresh, onClick }: TileProps) {
|
||||
const t = tileOf(id);
|
||||
const cls = `rk-tile${size !== 'md' ? ` ${size}` : ''}${hint ? ' hint' : ''}${fresh ? ' fresh' : ''}`;
|
||||
const body =
|
||||
t.kind === 'joker' ? (
|
||||
<>
|
||||
<JokerFace />
|
||||
{as && <span className="rk-jkas">{as.n}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="rk-n">{t.n}</span>
|
||||
<span className="rk-m" aria-hidden>
|
||||
{MARK[t.color]}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const label = `${tileLabel(id, as)}${fresh ? ', 새로 가져온 타일' : ''}`;
|
||||
const dataC = t.kind === 'num' ? t.color : undefined;
|
||||
if (!onClick) {
|
||||
return (
|
||||
<span className={cls} data-c={dataC} role="img" aria-label={label}>
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cls} data-c={dataC} aria-pressed={!!selected} aria-label={label} onClick={onClick}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- draft editing
|
||||
|
||||
/** Keeps a set in a readable order: the server's interpretation when valid, otherwise as placed. */
|
||||
function arrange(d: DraftSet): DraftSet {
|
||||
const n = normalizeSet(d);
|
||||
const tileIds = n ? n.tileIds : d.tileIds;
|
||||
return d.as ? { tileIds, as: d.as } : { tileIds };
|
||||
}
|
||||
|
||||
type Target = { set: number; at: 'start' | 'end' } | 'new' | 'rack';
|
||||
|
||||
/** Moves `sel` to the target; tiles that were on the table at turn start never go back to the rack. */
|
||||
function moveTiles(sets: DraftSet[], sel: string[], target: Target, onTable0: Set<string>): DraftSet[] | null {
|
||||
const moving = target === 'rack' ? sel.filter((id) => !onTable0.has(id)) : sel;
|
||||
if (!moving.length) return null;
|
||||
const ordered = sortTiles(moving, 'number');
|
||||
const next: DraftSet[] = sets.map((d) => ({ ...d, tileIds: d.tileIds.filter((id) => !moving.includes(id)) }));
|
||||
let touched: DraftSet | null = null;
|
||||
if (target === 'new') {
|
||||
touched = { tileIds: ordered };
|
||||
next.push(touched);
|
||||
} else if (target !== 'rack') {
|
||||
touched = next[target.set]!;
|
||||
touched.tileIds = target.at === 'start' ? [...ordered, ...touched.tileIds] : [...touched.tileIds, ...ordered];
|
||||
}
|
||||
return next.filter((d) => d.tileIds.length > 0).map((d) => (d === touched ? arrange(d) : d));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
function RummikubBoard({ view: v, me, players, act, pending, deadline }: BoardProps<RummikubView>) {
|
||||
const my = v.my;
|
||||
const mine = v.phase === 'playing' && !!me && v.current === me;
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [hintIds, setHintIds] = useState<string[]>([]);
|
||||
const [local, setLocal] = useState<DraftSet[] | null>(null);
|
||||
const [sortBy, setSortBy] = useState<'color' | 'number'>(() => (localStorage.getItem('rk-sort') === 'number' ? 'number' : 'color'));
|
||||
const [msg, setMsg] = useState('');
|
||||
const [confirm, setConfirm] = useState<null | 'draw' | 'commit'>(null);
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const wasMine = useRef(false);
|
||||
const lastLog = useRef<string>('');
|
||||
|
||||
const serverSets: DraftSet[] = useMemo(
|
||||
() => (v.draft ? v.draft.map((d) => (d.as ? { tileIds: d.tileIds, as: d.as } : { tileIds: d.tileIds })) : v.table.map((t) => ({ tileIds: t.tileIds, as: t.kind }))),
|
||||
[v.draft, v.table],
|
||||
);
|
||||
// Optimistic draft until the server answers (cleared when nothing is pending any more).
|
||||
useEffect(() => {
|
||||
if (!pending) setLocal(null);
|
||||
}, [pending, v.turnNo, v.draftEdits]);
|
||||
const sets = mine && local ? local : serverSets;
|
||||
const onTable0 = useMemo(() => new Set(v.table.flatMap((t) => t.tileIds)), [v.table]);
|
||||
const inSets = useMemo(() => new Set(sets.flatMap((d) => d.tileIds)), [sets]);
|
||||
const rack = useMemo(() => sortTiles((my?.rack ?? []).filter((id) => !inSets.has(id)), sortBy), [my?.rack, inSets, sortBy]);
|
||||
const fresh = new Set(my?.lastDrawn ?? []);
|
||||
|
||||
// Turn change: clear selection and hints, announce.
|
||||
useEffect(() => {
|
||||
setSel([]);
|
||||
setHintIds([]);
|
||||
setMsg('');
|
||||
if (mine && !wasMine.current) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
}
|
||||
wasMine.current = mine;
|
||||
}, [v.turnNo, mine]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const e = v.log[v.log.length - 1];
|
||||
if (!e) return;
|
||||
const key = `${v.match.gameNo}-${e.turn}-${e.kind}`;
|
||||
if (key === lastLog.current) return;
|
||||
lastLog.current = key;
|
||||
if (e.by !== me) setAnnounce(logText(e, players, me));
|
||||
}, [v.log]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Drop selected tiles that disappeared (e.g. after undo on the server).
|
||||
useEffect(() => {
|
||||
setSel((s) => (s.every((id) => inSets.has(id) || rack.includes(id)) ? s : s.filter((id) => inSets.has(id) || rack.includes(id))));
|
||||
}, [inSets, rack]);
|
||||
|
||||
const send = (next: DraftSet[]) => {
|
||||
setLocal(next);
|
||||
act({ type: 'setDraft', sets: next, turn: v.turnNo });
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (!mine) return;
|
||||
setHintIds([]);
|
||||
setMsg('');
|
||||
setSel((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
};
|
||||
|
||||
const move = (target: Target) => {
|
||||
if (!mine || !sel.length) return;
|
||||
if (!my?.melded && !v.options.initialMeldAllowExtend && sel.some((id) => onTable0.has(id))) {
|
||||
setMsg('첫 등록 전에는 바닥의 타일을 옮길 수 없어요. 내 받침대 타일만 골라 주세요.');
|
||||
return;
|
||||
}
|
||||
if (target === 'rack' && sel.some((id) => onTable0.has(id))) setMsg('바닥에 있던 타일은 받침대로 가져올 수 없어요.');
|
||||
else setMsg('');
|
||||
const next = moveTiles(sets, sel, target, onTable0);
|
||||
if (!next) return;
|
||||
setSel([]);
|
||||
send(next);
|
||||
};
|
||||
|
||||
const toggleKind = (i: number) => {
|
||||
const d = sets[i]!;
|
||||
const cur = v.draft?.[i]?.kind ?? 'run';
|
||||
send(sets.map((x, k) => (k === i ? { tileIds: d.tileIds, as: cur === 'run' ? 'group' : 'run' } : x)));
|
||||
};
|
||||
|
||||
const hint = () => {
|
||||
const free = rack;
|
||||
let found: string[][] = [];
|
||||
if (!my?.melded) found = (findMeld(free, v.options.initialMeldPoints) ?? []).map((m) => m.tileIds);
|
||||
else found = pickDisjoint(free).sets.slice(0, 1).map((m) => m.tileIds);
|
||||
if (!found.length) {
|
||||
setHintIds([]);
|
||||
setMsg(my?.melded ? '받침대 타일만으로 바로 만들 수 있는 묶음이 없어요. 바닥 묶음에 붙여 보거나 한 장 가져오세요.' : `받침대 타일만으로 ${v.options.initialMeldPoints}점을 만들기 어려워요. 한 장 가져오세요.`);
|
||||
return;
|
||||
}
|
||||
setHintIds(found.flat());
|
||||
setSel(found[0]!);
|
||||
setMsg(found.length > 1 ? `보라색 타일로 묶음 ${found.length}개를 만들 수 있어요. 첫 묶음을 골라 두었어요. "새 묶음"을 눌러 보세요.` : '보라색 타일로 묶음을 만들 수 있어요. 골라 두었으니 "새 묶음"을 눌러 보세요.');
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
if (my?.check && !my.check.ok && v.options.invalidCommitPenalty === 'penalty3' && my.changed && my.check.fromRack > 0) {
|
||||
setConfirm('commit');
|
||||
return;
|
||||
}
|
||||
act({ type: 'commit', turn: v.turnNo });
|
||||
};
|
||||
const draw = () => {
|
||||
if (my?.changed) setConfirm('draw');
|
||||
else act({ type: v.poolCount ? 'draw' : 'pass' });
|
||||
};
|
||||
|
||||
const check = my?.check ?? null;
|
||||
const viewDraft = v.draft;
|
||||
const showingDraft = !!viewDraft;
|
||||
const thinking = v.current && v.current !== me && v.draftEdits > 0;
|
||||
const curName = v.current ? nick(players, v.current) : '';
|
||||
const selFromSets = sel.some((id) => inSets.has(id) && !onTable0.has(id));
|
||||
|
||||
return (
|
||||
<div className="rk-root">
|
||||
<style>{CSS}</style>
|
||||
<Players v={v} me={me} players={players} deadline={deadline} />
|
||||
|
||||
<section className="rk-table" aria-label="바닥">
|
||||
<div className="rk-table-head">
|
||||
<span>
|
||||
{showingDraft && !mine ? `${curName}님이 고민 중인 배치` : mine && my?.changed ? '바닥(확정 전)' : '바닥'}
|
||||
{thinking && !showingDraft ? ` · ${curName}님이 생각 중…` : ''}
|
||||
</span>
|
||||
<span aria-label={`남은 타일 ${v.poolCount}장`}>남은 타일 {v.poolCount}장</span>
|
||||
</div>
|
||||
{mine && my && !my.melded && (
|
||||
<div className="rk-meld">
|
||||
<span>
|
||||
첫 등록 {check?.meldPoints ?? 0} / {v.options.initialMeldPoints}점
|
||||
</span>
|
||||
<progress max={v.options.initialMeldPoints || 1} value={Math.min(check?.meldPoints ?? 0, v.options.initialMeldPoints)} aria-label="첫 등록 점수" />
|
||||
</div>
|
||||
)}
|
||||
{sets.length === 0 && !(mine && sel.length) && <p className="rk-empty">아직 바닥에 놓인 타일이 없어요.</p>}
|
||||
{sets.map((d, i) => {
|
||||
const info = mine || showingDraft ? (local ? null : viewDraft?.[i]) : null;
|
||||
const n = info ? null : normalizeSet(d);
|
||||
const ok = info ? info.ok : !!n;
|
||||
const problem = info ? info.problem : n ? null : setProblem(d);
|
||||
const jokerAs = info ? info.jokerAs : (n?.jokerAs ?? {});
|
||||
const tableSet = !mine && !showingDraft ? v.table[i] : undefined;
|
||||
const changed = !!tableSet && v.lastChanged.includes(tableSet.id);
|
||||
const label = `묶음 ${i + 1}: ${d.tileIds.map((id) => tileLabel(id, jokerAs[id])).join(', ')}${mine || showingDraft ? (ok ? ' (올바름)' : ` (고쳐야 함: ${problem ?? ''})`) : ''}`;
|
||||
return (
|
||||
<div
|
||||
key={tableSet ? `${tableSet.id}-${changed ? v.turnNo : 0}` : `d${i}`}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={`rk-set${mine || showingDraft ? (ok ? ' ok' : ' bad') : ''}${changed ? ' changed' : ''}`}
|
||||
>
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 앞에 놓기`} onClick={() => move({ set: i, at: 'start' })}>
|
||||
◀
|
||||
</button>
|
||||
)}
|
||||
{d.tileIds.map((id) => (
|
||||
<Tile key={id} id={id} size="sm" as={jokerAs[id]} selected={sel.includes(id)} onClick={mine ? () => toggle(id) : undefined} />
|
||||
))}
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-slot" aria-label={`묶음 ${i + 1} 뒤에 놓기`} onClick={() => move({ set: i, at: 'end' })}>
|
||||
▶
|
||||
</button>
|
||||
)}
|
||||
{(mine || showingDraft) && !ok && problem && <span className="rk-problem">{problem}</span>}
|
||||
{mine && info?.ambiguous && (
|
||||
<span className="rk-setinfo">
|
||||
<span>{info.kind === 'group' ? '같은 숫자 묶음으로 봐요' : '이어진 숫자로 봐요'}</span>
|
||||
<button type="button" onClick={() => toggleKind(i)}>
|
||||
{info.kind === 'group' ? '이어진 숫자로' : '같은 숫자로'}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mine && sel.length > 0 && (
|
||||
<button type="button" className="rk-newset" onClick={() => move('new')}>
|
||||
+ 새 묶음
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{mine && (
|
||||
<>
|
||||
<p className="rk-msg" aria-live="polite" style={{ color: msg || (check && !check.ok && my?.changed) ? 'var(--danger)' : 'var(--text-sub)' }}>
|
||||
{msg ||
|
||||
(sel.length
|
||||
? `${sel.length}장 골랐어요. 놓을 곳(◀ ▶ 또는 새 묶음)을 누르세요.`
|
||||
: my?.changed
|
||||
? check?.ok
|
||||
? '좋아요! 확정을 누르면 차례가 끝나요.'
|
||||
: (check?.reason ?? '')
|
||||
: '타일을 눌러 고른 뒤 놓을 곳을 누르세요.')}
|
||||
</p>
|
||||
<div className="rk-bar" role="group" aria-label="내 차례 행동">
|
||||
<button type="button" className="btn btn-secondary" disabled={!my?.canUndo || pending} onClick={() => act({ type: 'undoDraft' })}>
|
||||
되돌리기
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={!my?.changed || pending} onClick={() => act({ type: 'resetDraft' })}>
|
||||
처음으로
|
||||
</button>
|
||||
{selFromSets ? (
|
||||
<button type="button" className="btn btn-soft" onClick={() => move('rack')}>
|
||||
받침대로
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn btn-soft" disabled={!v.options.hints} onClick={hint} title={v.options.hints ? '받침대 타일로 바로 낼 수 있는 묶음을 찾아 줘요' : '이 방은 힌트를 쓰지 않아요'}>
|
||||
도와줘
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-soft" disabled={!sel.length} onClick={() => move('new')}>
|
||||
새 묶음
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={pending} onClick={draw}>
|
||||
{v.poolCount ? '한 장 가져오기' : '넘기기'}
|
||||
</button>
|
||||
<button type="button" className={`btn ${check?.ok ? 'btn-primary' : 'btn-secondary'}`} disabled={!my?.changed || pending} onClick={commit} aria-describedby="rk-commit-help">
|
||||
확정
|
||||
</button>
|
||||
</div>
|
||||
<span id="rk-commit-help" className="sr-only">
|
||||
{check?.ok ? '지금 확정할 수 있어요' : (check?.reason ?? '')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.phase !== 'playing' && v.round && <RoundPanel v={v} me={me} players={players} act={act} pending={pending} deadline={deadline} />}
|
||||
|
||||
{my && v.phase === 'playing' && (
|
||||
<div className="rk-rackwrap">
|
||||
<div className="rk-rackhead">
|
||||
<strong>
|
||||
내 받침대 <span className="muted small">({my.rack.length}장{rack.length !== my.rack.length ? `, ${my.rack.length - rack.length}장은 바닥에` : ''})</span>
|
||||
</strong>
|
||||
<div className="segmented" role="group" aria-label="받침대 정렬">
|
||||
{(
|
||||
[
|
||||
['color', '색깔순'],
|
||||
['number', '숫자순'],
|
||||
] as const
|
||||
).map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
aria-pressed={sortBy === k}
|
||||
onClick={() => {
|
||||
setSortBy(k);
|
||||
localStorage.setItem('rk-sort', k);
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rk-rack" role="group" aria-label="내 받침대">
|
||||
{rack.length === 0 && <span className="rk-rack-empty">{my.rack.length ? '모두 바닥에 놓았어요.' : '타일이 없어요.'}</span>}
|
||||
{rack.map((id) => (
|
||||
<Tile key={id} id={id} selected={sel.includes(id)} hint={hintIds.includes(id)} fresh={fresh.has(id)} onClick={mine ? () => toggle(id) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
{confirm === 'draw' && (
|
||||
<ConfirmSheet
|
||||
title="놓은 타일을 되돌리고 한 장 가져올까요?"
|
||||
body="이번 차례에 바꾼 배치는 모두 처음으로 돌아가요."
|
||||
confirm={v.poolCount ? '되돌리고 한 장 가져오기' : '되돌리고 넘기기'}
|
||||
onConfirm={() => act({ type: v.poolCount ? 'draw' : 'pass' })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
{confirm === 'commit' && (
|
||||
<ConfirmSheet
|
||||
title="틀린 묶음이 있어요. 그래도 확정할까요?"
|
||||
body={`${check?.reason ?? ''} 지금 확정하면 놓은 타일이 돌아오고 3장을 가져와요.`}
|
||||
confirm="그래도 확정"
|
||||
danger
|
||||
onConfirm={() => act({ type: 'commit', turn: v.turnNo })}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Players({ v, me, players, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
|
||||
return (
|
||||
<div className="rk-players" role="list" aria-label="참가자">
|
||||
{v.seats.map((s) => {
|
||||
const turn = v.current === s.id;
|
||||
const u = userOf(players, s.id);
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
role="listitem"
|
||||
className={`rk-player${turn ? ' turn' : ''}${s.left || !s.inRound ? ' gone' : ''}`}
|
||||
aria-label={`${u.nickname}${s.id === me ? '(나)' : ''}, 타일 ${s.tiles}장, ${s.melded ? '첫 등록 함' : '첫 등록 전'}${turn ? ', 지금 차례' : ''}${s.left ? ', 나감' : s.away ? ', 자리 비움' : ''}`}
|
||||
>
|
||||
<Avatar user={u} size={26} />
|
||||
<span style={{ maxWidth: 110, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{u.nickname}
|
||||
{s.id === me && <span className="muted"> (나)</span>}
|
||||
</span>
|
||||
<span className="rk-count" title="받침대 타일 수">
|
||||
{s.tiles}장
|
||||
</span>
|
||||
{!s.melded && s.inRound && (
|
||||
<span className="rk-badge" style={{ background: 'var(--surface-2)', color: 'var(--text-sub)' }}>
|
||||
첫 등록 전
|
||||
</span>
|
||||
)}
|
||||
{s.left && (
|
||||
<span className="rk-badge" style={{ background: 'var(--danger)', color: '#fff' }}>
|
||||
나감
|
||||
</span>
|
||||
)}
|
||||
{!s.left && s.away && (
|
||||
<span className="rk-badge" style={{ background: 'var(--warning)', color: '#fff' }}>
|
||||
자리 비움
|
||||
</span>
|
||||
)}
|
||||
{v.match.total > 1 && <span className="small muted">{s.wins}승</span>}
|
||||
{turn && <TurnTimer deadline={deadline} totalMs={v.turnMs} mine={s.id === me} size={30} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundPanel({ v, me, players, act, pending, deadline }: { v: RummikubView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; deadline: number | null }) {
|
||||
const r = v.round!;
|
||||
const title =
|
||||
r.endReason === 'rackEmpty'
|
||||
? `${r.winners.map((w) => nick(players, w)).join(', ')}님이 타일을 모두 내려놓았어요! 다 냈다!`
|
||||
: r.endReason === 'stalemate'
|
||||
? '더 낼 수 있는 사람이 없어 끝났어요. 남은 타일 점수가 적은 사람이 이겨요.'
|
||||
: '판이 끝났어요.';
|
||||
const order = r.ranking.flat();
|
||||
return (
|
||||
<div className="card stack rk-result" style={{ padding: 12 }} aria-live="polite">
|
||||
<strong>
|
||||
{v.match.total > 1 ? `${r.gameNo}판 결과 · ` : ''}
|
||||
{title}
|
||||
</strong>
|
||||
{order.map((p) => (
|
||||
<div key={p} className="rk-result-row">
|
||||
<strong style={{ minWidth: 70 }}>
|
||||
{nick(players, p)}
|
||||
{r.winners.includes(p) ? ' 🏆' : ''}
|
||||
</strong>
|
||||
<span style={{ fontWeight: 800, color: r.scores[p]! >= 0 ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{r.scores[p]! > 0 ? '+' : ''}
|
||||
{r.scores[p]}점
|
||||
</span>
|
||||
<span className="small muted">남은 타일 {r.rackValues[p]}점</span>
|
||||
<span className="rk-mini" aria-label={`${nick(players, p)}님의 남은 타일`}>
|
||||
{(r.revealed[p] ?? []).map((id) => (
|
||||
<Tile key={id} id={id} size="xs" />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="small muted">점수는 이 방에서 순위를 정하는 데만 써요.</span>
|
||||
{v.phase === 'roundEnd' && (
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
{v.my?.needReady ? (
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'ready' })}>
|
||||
다음 판 시작
|
||||
</button>
|
||||
) : (
|
||||
<span className="small muted">{me && v.ready.includes(me) ? '다른 사람을 기다리는 중이에요.' : '다음 판을 기다리는 중이에요.'}</span>
|
||||
)}
|
||||
<TurnTimer deadline={deadline} totalMs={30_000} mine={!!v.my?.needReady} size={36} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
|
||||
const who = e.by === me ? '내가' : `${nick(players, e.by)}님이`;
|
||||
switch (e.kind) {
|
||||
case 'commit':
|
||||
return `${who} ${e.count}장을 냈어요.`;
|
||||
case 'timeoutCommit':
|
||||
return `시간이 다 되어 ${who} 놓아 둔 ${e.count}장이 확정됐어요.`;
|
||||
case 'draw':
|
||||
return `${who} 1장을 가져왔어요.`;
|
||||
case 'timeoutDraw':
|
||||
return `시간이 다 되어 ${who} ${e.count}장을 가져왔어요.`;
|
||||
case 'penalty':
|
||||
return `${who} 묶음을 다 맞추지 못해 ${e.count}장을 가져왔어요.`;
|
||||
case 'pass':
|
||||
return `${who} 넘겼어요.`;
|
||||
case 'left':
|
||||
return `${nick(players, e.by)}님이 나갔어요.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function RummikubSide({ view: v, me, players }: BoardProps<RummikubView>) {
|
||||
const order = [...v.seats].sort((a, b) => b.wins - a.wins || b.total - a.total);
|
||||
const lastRound = v.opening.length ? Math.max(...v.opening.map((o) => o.round)) : 0;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 6, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'}</strong>
|
||||
<span className="small muted">남은 타일 {v.poolCount}장</span>
|
||||
</div>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
|
||||
{order.map((s) => (
|
||||
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.left ? 0.55 : 1 }}>
|
||||
<Avatar user={userOf(players, s.id)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
|
||||
{nick(players, s.id)}
|
||||
{s.left ? ' · 나감' : ''}
|
||||
</span>
|
||||
<span className="small">{s.tiles}장</span>
|
||||
{v.match.total > 1 && (
|
||||
<span className="small muted">
|
||||
{s.wins}승 · {s.total > 0 ? '+' : ''}
|
||||
{s.total}점
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{lastRound > 0 && v.match.gameNo >= 1 && (
|
||||
<span className="small muted">
|
||||
선 정하기:{' '}
|
||||
{v.opening
|
||||
.filter((o) => o.round === lastRound)
|
||||
.map((o) => `${nick(players, o.player)} ${o.n === null ? '조커(다시)' : o.n}`)
|
||||
.join(' · ')}
|
||||
{lastRound > 1 ? ` (${lastRound}번째 뽑기)` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.log.length > 0 && (
|
||||
<details className="card" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
|
||||
<ol style={{ listStyle: 'none', margin: '8px 0 0', padding: 0, display: 'grid', gap: 2, maxHeight: 180, overflow: 'auto' }}>
|
||||
{[...v.log].reverse().map((e, i) => (
|
||||
<li key={`${e.turn}-${i}`} className="small">
|
||||
{logText(e, players, me)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
<details className="card wide-open" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>규칙 요약</summary>
|
||||
<Rules options={v.options} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: RummikubView, me: string | null): boolean {
|
||||
if (!me) return false;
|
||||
if (v.phase === 'roundEnd') return !!v.my?.needReady;
|
||||
return v.phase === 'playing' && v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: RummikubView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'roundEnd') return v.my?.needReady ? '판이 끝났어요. 다음 판을 시작해 주세요' : '다음 판을 기다리는 중이에요';
|
||||
if (v.current === me && v.my) {
|
||||
if (!v.my.melded) return `내 차례예요 · 첫 등록 ${v.my.check?.meldPoints ?? 0}/${v.options.initialMeldPoints}점`;
|
||||
return '내 차례예요. 타일을 내거나 한 장 가져오세요';
|
||||
}
|
||||
if (!v.current) return '잠시만 기다려 주세요';
|
||||
return `${nick(players, v.current)}님 차례예요${v.draftEdits > 0 ? ' · 생각 중' : ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- options
|
||||
|
||||
function Seg<T extends string | number | boolean>({ 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, help, children }: { label: string; help?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
{help && <span className="small muted">{help}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<RummikubOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<RummikubOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="첫 등록 최소 점수" help="처음 낼 때는 받침대 타일만으로 만든 묶음의 숫자 합이 이 점수 이상이어야 해요.">
|
||||
<Seg label="첫 등록 최소 점수" value={o.initialMeldPoints} options={[[0, '없음'], [20, '20점'], [30, '30점(공식)'], [50, '50점']]} onChange={(initialMeldPoints) => set({ initialMeldPoints })} />
|
||||
</Field>
|
||||
<Field label="한 차례 제한 시간" help="어르신이나 아이와 할 때는 넉넉하게 골라 주세요.">
|
||||
<Seg label="한 차례 제한 시간" value={o.turnSeconds} options={[[60, '1분'], [90, '1분 30초'], [120, '2분'], [180, '3분']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<Field label="판 수">
|
||||
<Seg label="판 수" value={o.gamesPerMatch} options={[['1', '1판'], ['players', '인원수만큼'], ['2', '2판'], ['3', '3판'], ['4', '4판']]} onChange={(gamesPerMatch) => set({ gamesPerMatch })} />
|
||||
</Field>
|
||||
<Field label='"도와줘" 힌트 버튼'>
|
||||
<Seg label="힌트 버튼" value={o.hints} options={[[true, '켜기'], [false, '끄기']]} onChange={(hints) => set({ hints })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="시간이 다 됐을 때 올바르게 놓아 둔 타일">
|
||||
<Seg label="시간 초과 때 올바른 배치" value={o.timeoutValidDraft} options={[['commit', '자동으로 확정'], ['revert', '되돌리고 한 장 가져오기(공식)']]} onChange={(timeoutValidDraft) => set({ timeoutValidDraft })} />
|
||||
</Field>
|
||||
<Field label="시간이 다 됐는데 덜 맞췄을 때 가져올 장수">
|
||||
<Seg label="시간 초과 벌칙" value={o.invalidTimeoutPenalty} options={[[1, '1장'], [3, '3장(공식)']]} onChange={(invalidTimeoutPenalty) => set({ invalidTimeoutPenalty })} />
|
||||
</Field>
|
||||
<Field label="틀린 채로 확정을 누르면">
|
||||
<Seg label="틀린 확정" value={o.invalidCommitPenalty} options={[['reject', '알려 주고 계속 고치기'], ['penalty3', '되돌리고 3장 가져오기']]} onChange={(invalidCommitPenalty) => set({ invalidCommitPenalty })} />
|
||||
</Field>
|
||||
<Field label="받침대에 남은 조커 감점">
|
||||
<Seg label="조커 감점" value={o.jokerPenalty} options={[[30, '30점(공식)'], [50, '50점(대회)']]} onChange={(jokerPenalty) => set({ jokerPenalty })} />
|
||||
</Field>
|
||||
<Field label="첫 등록하는 차례에 바닥 묶음에도 붙이기" help="켜면 첫 등록 점수를 새 묶음으로 채운 뒤, 같은 차례에 바닥 묶음을 고칠 수 있어요.">
|
||||
<Seg label="첫 등록 차례 재배치" value={o.initialMeldAllowExtend} options={[[false, '안 됨(공식)'], [true, '허용']]} onChange={(initialMeldAllowExtend) => set({ initialMeldAllowExtend })} />
|
||||
</Field>
|
||||
<Field label="고민 중인 배치를 다른 사람에게 보이기" help="켜면 바닥에 올려 본 타일이 바로 보여요. 다시 되돌려도 이미 본 타일은 기억에 남아요.">
|
||||
<Seg label="고민 중인 배치 공개" value={o.showDraftToOthers} options={[[false, '안 보이기'], [true, '보이기']]} onChange={(showDraftToOthers) => set({ showDraftToOthers })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: RummikubOptions): string {
|
||||
const games = o.gamesPerMatch === 'players' ? '인원수만큼' : `${o.gamesPerMatch}판`;
|
||||
const sec = o.turnSeconds % 60 ? `${Math.floor(o.turnSeconds / 60)}분 ${o.turnSeconds % 60}초` : `${o.turnSeconds / 60}분`;
|
||||
return [o.initialMeldPoints ? `첫 등록 ${o.initialMeldPoints}점` : '첫 등록 점수 없음', `한 차례 ${sec}`, games, o.jokerPenalty === 50 ? '조커 50점' : '', o.showDraftToOthers ? '배치 공개' : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- rules
|
||||
|
||||
function Example({ ids }: { ids: string[] }) {
|
||||
const n = normalizeSet({ tileIds: ids });
|
||||
return (
|
||||
<span className="rk-mini" style={{ verticalAlign: 'middle', margin: '2px 0' }}>
|
||||
{ids.map((id) => (
|
||||
<Tile key={id} id={id} size="xs" as={n?.jokerAs[id]} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: RummikubOptions }) {
|
||||
const o = options;
|
||||
const meld = o?.initialMeldPoints ?? 30;
|
||||
return (
|
||||
<div className="stack">
|
||||
<style>{CSS}</style>
|
||||
<p>
|
||||
1부터 13까지 숫자가 적힌 4가지 색 타일(색마다 모양 표시 ● ♥ ◆ ★)이 두 벌씩 104장, 조커 2장이 있어요. 각자 14장을 받고, 내 받침대의 타일을 가장 먼저 모두 내려놓으면 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>묶음 두 가지</strong>
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>
|
||||
같은 숫자, 다른 색 3~4장 <Example ids={['K7a', 'R7a', 'B7a']} />
|
||||
</li>
|
||||
<li>
|
||||
같은 색, 이어지는 숫자 3장 이상 <Example ids={['B3a', 'B4a', 'B5a', 'B6a']} /> (13 다음에 1은 올 수 없어요)
|
||||
</li>
|
||||
<li>
|
||||
조커는 아무 타일이나 대신해요 <Example ids={['O9a', 'O10a', 'JK1']} />
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>내 차례</strong>: 타일을 1장 이상 내고 "확정"을 누르거나, "한 장 가져오기"를 눌러요. 타일을 누른 뒤 놓을 곳(◀ 앞, ▶ 뒤, 새 묶음)을 누르면 옮겨져요. 확정할 때 바닥의 모든 묶음이 올바라야 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>첫 등록</strong>:{' '}
|
||||
{meld ? `처음 낼 때는 받침대 타일만으로 만든 새 묶음의 숫자 합이 ${meld}점 이상이어야 해요. 조커는 대신하는 숫자로 세요.` : '이 방은 첫 등록 점수가 없어요.'}{' '}
|
||||
{o?.initialMeldAllowExtend ? '첫 등록 차례에도 점수를 채운 뒤에는 바닥 묶음을 고칠 수 있어요.' : '첫 등록 전에는 바닥 묶음을 건드릴 수 없어요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>바닥 고치기</strong>: 첫 등록 뒤에는 바닥의 묶음을 쪼개고 합치고 옮겨서 내 타일을 끼워 넣을 수 있어요. 예를 들어 빨강 4-5-6-7-8 사이에 내 빨강 6을 넣어 4-5-6과 6-7-8로 나눌 수 있어요. 바닥 타일은 받침대로 가져올 수 없고, 조커를 빼 왔다면 같은 차례에 다시 바닥에 써야 해요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>시간</strong>: 한 차례는 {o ? `${o.turnSeconds}초` : '1분'}예요. 시간이 다 되면{' '}
|
||||
{o?.timeoutValidDraft === 'revert' ? '놓아 둔 타일을 되돌리고 1장을 가져와요' : '올바르게 놓아 둔 타일은 자동으로 확정돼요'}. 묶음이 틀린 채로 끝나면 처음으로 되돌리고 {o?.invalidTimeoutPenalty ?? 3}장을 가져와요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>점수</strong>: 누가 다 내려놓으면 나머지는 받침대 숫자 합만큼 빼고(조커 {o?.jokerPenalty ?? 30}점), 이긴 사람이 그만큼 더해요. 남은 타일이 없는데 아무도 못 내면 받침대 합이 가장 적은 사람이 이겨요. 점수는 이 방의 순위에만 써요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<RummikubView, RummikubOptions> = {
|
||||
Board: RummikubBoard,
|
||||
Side: RummikubSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
|
||||
mahjong: '🀇',
|
||||
yut: '🪵',
|
||||
othello: '◐',
|
||||
rummikub: '🔢',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
export const UPCOMING = ['윷놀이', '원카드', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
|
||||
|
||||
Reference in New Issue
Block a user