M4: 요트 다이스 규칙·화면·테스트

- 규칙 모듈(packages/games/src/yacht): 12칸(기본)·13칸 변형, 풀 하우스 요트 인정,
  추가 요트 보너스와 강제/자유 조커, 상단 보너스, 굴림 3회·고정 검증, 기권,
  시간 초과 시 (안 굴렸으면 굴린 뒤) 최고 점수 칸 자동 기록·희생 순서, 1~8명(혼자 가능)
- 점수 계산은 scoreFor 순수 함수로 분리, view에 빈 칸 미리보기·허용 칸 포함
- 테스트: 문서 10절 시나리오 전부, 시드 1,000개 무작위 대국(점수·합계 일관성,
  onTimeout 합법, 결정성), 기권·시간 초과 섞은 200개 대국
- 화면: SVG 주사위(눌러서 고정, 굴림 애니메이션·움직임 줄이기 존중), 남은 굴림 표시,
  전원 점수표(내 열 강조, 미리보기, 0점 확인), 상단 보너스 진행 막대, 기권
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(준비 중 목록에서 제거)
- 결과 화면: 1인 게임은 무승부/승리로 표시하지 않음
- E2E: PC 방장 + 휴대폰 친구가 12라운드 전체를 화면으로 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:17:15 +09:00
parent 5dd8e6ea07
commit 41ab2cf187
10 changed files with 1991 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
baduk: () => import('./baduk'),
seotda: () => import('./seotda'),
poker: () => import('./poker'),
yacht: () => import('./yacht'),
};
const cache = new Map<string, GameUI>();

View File

@@ -0,0 +1,667 @@
/** 요트 다이스 화면 (docs/games/yacht.md §9). */
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { Category, ScoreEntry, YachtOptions, YachtSheetView, YachtView } from '@bg/games/yacht';
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';
// The client draws from the view only; names and hints live here so the rules module isn't bundled.
const NAME: Record<Category, string> = {
ones: '에이스',
twos: '듀스',
threes: '트레이',
fours: '포',
fives: '파이브',
sixes: '식스',
choice: '초이스',
threeKind: '쓰리 카인드',
fourKind: '포 카인드',
fullHouse: '풀 하우스',
smallStraight: '스몰 스트레이트',
largeStraight: '라지 스트레이트',
yacht: '요트',
};
const UPPER: Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes'];
const catName = (c: Category, scoring: YachtOptions['scoring']) => (c === 'choice' && scoring === 'yahtzee' ? '찬스' : NAME[c]);
const ROLL_MS = 600;
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 matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
const CSS = `
.yt-wrap { width: 100%; max-width: 1080px; display: flex; flex-direction: column; gap: 12px; }
.yt-tray { display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
.yt-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; }
.yt-dice { display: flex; gap: 8px; justify-content: center; padding: 6px 0 18px; }
.yt-die { --s: 64px; width: var(--s); height: var(--s); padding: 0; border-radius: 14px; border: 3px solid #c9c4b8; background: #fffdf8;
box-shadow: 0 3px 0 #b7b1a3, 0 4px 10px rgba(0,0,0,.15); cursor: pointer; position: relative; transition: transform .15s ease, border-color .15s; flex: none; }
.yt-die svg { display: block; width: 100%; height: 100%; }
.yt-die:disabled { cursor: default; }
.yt-die.held { transform: translateY(14px); border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft), 0 3px 8px rgba(0,0,0,.15); }
.yt-die.blank { background: var(--surface-2); border-style: dashed; box-shadow: none; }
.yt-die:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.yt-lock { position: absolute; left: 50%; bottom: -12px; transform: translateX(-50%); background: var(--primary); color: var(--primary-text);
font-size: .72rem; font-weight: 800; border-radius: 999px; padding: 1px 7px; white-space: nowrap; line-height: 1.4; }
@keyframes yt-tumble { 0% { transform: translateY(-18px) rotate(-30deg) scale(.9); } 40% { transform: translateY(4px) rotate(200deg); }
70% { transform: translateY(-6px) rotate(330deg); } 100% { transform: translateY(0) rotate(360deg); } }
.yt-die.rolling { animation: yt-tumble ${ROLL_MS}ms ease-out both; }
@keyframes yt-pop { 0% { transform: translate(-50%, -50%) scale(.4); opacity: 0; } 60% { transform: translate(-50%, -50%) scale(1.15); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } }
.yt-flash { position: fixed; left: 50%; top: 40%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; font-size: 4rem; font-weight: 900;
color: #fde047; text-shadow: 0 4px 12px rgba(0,0,0,.55), 0 0 2px #854d0e; animation: yt-pop .5s ease-out both; white-space: nowrap; }
.yt-roll { min-height: 64px; font-size: 1.2rem; }
.yt-sheet { padding: 0; overflow: auto; }
.yt-table { border-collapse: separate; border-spacing: 0; width: 100%; font-variant-numeric: tabular-nums; }
.yt-table th, .yt-table td { height: 38px; padding: 0 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
.yt-table thead th { position: sticky; top: 0; background: var(--surface); z-index: 2; height: 46px; font-size: .9rem; }
.yt-table th.yt-cat { position: sticky; left: 0; z-index: 1; background: var(--surface); text-align: left; font-weight: 700; padding-left: 12px; min-width: 112px; }
.yt-table thead th.yt-cat { z-index: 3; }
.yt-table td { min-width: 56px; }
.yt-table td.mine { background: var(--primary-soft); min-width: 72px; font-size: 1.08rem; }
.yt-table th.turn { box-shadow: inset 0 -4px 0 var(--focus); }
.yt-table tr.yt-sum th, .yt-table tr.yt-sum td { background: var(--surface-2); font-weight: 700; font-size: .92rem; }
.yt-table tr.yt-sum td.mine { background: var(--primary-soft); }
.yt-table tr.yt-total th, .yt-table tr.yt-total td { font-weight: 900; font-size: 1.1rem; border-bottom: 0; height: 44px; }
.yt-filled { font-weight: 800; }
.yt-ghost { color: var(--text-sub); opacity: .75; font-weight: 600; }
.yt-cell { width: 100%; height: 34px; border-radius: 8px; border: 2px dashed var(--border); background: var(--surface); color: var(--text-sub);
font: inherit; font-weight: 700; cursor: pointer; }
.yt-cell.good { border-style: solid; border-color: var(--success); color: var(--success); background: var(--success-soft); }
.yt-cell.zero { opacity: .6; }
.yt-cell:hover:not(:disabled) { border-color: var(--primary); color: var(--text); }
.yt-cell:disabled { cursor: not-allowed; opacity: .35; }
.yt-cell:focus-visible { outline: 3px solid var(--focus); outline-offset: 1px; }
@keyframes yt-new { from { background: #fde68a; } to { background: transparent; } }
.yt-new { animation: yt-new 1.6s ease-out; border-radius: 6px; }
.yt-bar { height: 8px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
.yt-bar > span { display: block; height: 100%; background: var(--success); transition: width .3s; }
.yt-pname { display: inline-block; max-width: 72px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
@media (min-width: 1100px) {
.yt-wrap { display: grid; grid-template-columns: 380px minmax(0, 1fr); align-items: start; max-height: 100%; }
.yt-die { --s: 64px; }
.yt-sheet { max-height: calc(100vh - 64px - 52px - 40px); }
.yt-table th, .yt-table td { height: 34px; }
.yt-cell { height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.yt-die, .yt-die.held { transition: none; }
.yt-die.rolling, .yt-flash, .yt-new { animation: none; }
}
`;
// ---------------------------------------------------------------- dice
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>
);
}
/** Small static die for the rules / last-score line. */
function MiniDie({ value, size = 26 }: { value: number; size?: number }) {
return (
<span style={{ display: 'inline-block', width: size, height: size, borderRadius: size / 4.5, border: '2px solid #c9c4b8', background: '#fffdf8', verticalAlign: 'middle' }}>
<DieFace value={value} />
</span>
);
}
function DiceRow({ dice, size }: { dice: number[]; size?: number }) {
return (
<span style={{ display: 'inline-flex', gap: 3 }} aria-label={`주사위 ${dice.join(', ')}`} role="img">
{dice.map((d, i) => (
<MiniDie key={i} value={d} size={size} />
))}
</span>
);
}
// ---------------------------------------------------------------- board
interface RollAnim {
key: number;
mask: boolean[];
}
function YachtBoard({ view: v, me, players, act, pending, tapConfirm }: BoardProps<YachtView>) {
const myTurn = v.phase === 'playing' && !!me && v.current === me;
const [held, setHeld] = useState<boolean[]>(v.held);
const [anim, setAnim] = useState<RollAnim | null>(null);
const [flick, setFlick] = useState<number[] | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [announce, setAnnounce] = useState('');
const [confirm, setConfirm] = useState<{ category: Category; points: number } | null>(null);
const prevRoll = useRef(v.rollSeq);
const prevTurn = useRef(v.turnSeq);
const pointer = useRef<string>('');
// Server holds win once our own pending action is answered (optimistic taps in between).
useEffect(() => {
if (!pending) setHeld(v.held);
}, [v.held, v.turnSeq, v.rollSeq, pending]);
// Roll animation, yacht flash and screen-reader text when a new roll arrives.
useLayoutEffect(() => {
if (v.rollSeq <= prevRoll.current || !v.dice) {
prevRoll.current = v.rollSeq;
return;
}
prevRoll.current = v.rollSeq;
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
setAnnounce(`${who} 굴렸어요: ${v.dice.join(', ')}`);
if (v.dice.every((d) => d === v.dice![0])) {
setFlash('요트!');
haptic([80, 40, 80]);
}
if (reducedMotion()) {
sounds.stone();
return;
}
const mask = v.rollsUsed === 1 ? [true, true, true, true, true] : v.held.map((h) => !h);
setAnim({ key: v.rollSeq, mask });
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!anim) return;
const t = setInterval(() => setFlick([0, 1, 2, 3, 4].map(() => 1 + Math.floor(Math.random() * 6))), 70);
const end = setTimeout(() => {
setAnim(null);
setFlick(null);
sounds.stone();
}, ROLL_MS);
return () => {
clearInterval(t);
clearTimeout(end);
};
}, [anim]);
useEffect(() => {
if (!flash) return;
const t = setTimeout(() => setFlash(null), 1500);
return () => clearTimeout(t);
}, [flash]);
// Score announcement + my-turn cue on turn changes.
useEffect(() => {
if (v.turnSeq === prevTurn.current) return;
prevTurn.current = v.turnSeq;
setConfirm(null);
const ls = v.lastScore;
if (ls) {
const who = ls.player === me ? '내가' : `${nick(players, ls.player)}님이`;
setAnnounce(`${who} ${catName(ls.category, v.options.scoring)}에 ${ls.points}점을 기록했어요${ls.bonus ? ` (요트 보너스 +${ls.bonus})` : ''}${ls.auto ? ' (시간 초과로 자동 기록)' : ''}.`);
}
if (v.phase === 'playing' && v.current === me) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const canRoll = myTurn && !pending && v.rollsUsed < 3 && !(v.rollsUsed > 0 && held.every(Boolean));
const canHold = myTurn && v.rollsUsed > 0 && v.rollsUsed < 3;
const toggle = (i: number) => {
if (!canHold) return;
const next = held.map((h, j) => (j === i ? !h : h));
setHeld(next);
act({ type: 'hold', held: next });
};
const roll = () => {
if (!canRoll) return;
act({ type: 'roll' });
};
const score = (category: Category, points: number, viaMouse: boolean) => {
if (!myTurn || pending) return;
if (points === 0 || (tapConfirm && !viaMouse)) setConfirm({ category, points });
else act({ type: 'score', category });
};
const shownDice = v.dice ?? null;
const rollLabel =
v.rollsUsed >= 3 ? '점수 칸을 골라 주세요' : v.rollsUsed > 0 && held.every(Boolean) ? '모두 고정했어요' : `🎲 굴리기 (남은 ${v.rollsLeft}번)`;
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
return (
<div className="yt-wrap">
<style>{CSS}</style>
<section className="card yt-tray" aria-label="주사위">
<div className="yt-head">
<strong>
{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.totalRounds} 라운드`}
</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Avatar user={userOf(players, v.current)} size={24} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
<span className="badge" aria-label={`굴린 횟수 ${v.rollsUsed}번`}>
{'●'.repeat(v.rollsUsed)}
{'○'.repeat(3 - v.rollsUsed)}
</span>
</span>
)}
</div>
<div className="yt-dice" role="group" aria-label={canHold ? '주사위. 누르면 고정하거나 풀어요' : '주사위'}>
{[0, 1, 2, 3, 4].map((i) => {
const value = shownDice ? (anim?.mask[i] && flick ? flick[i]! : shownDice[i]!) : null;
const isHeld = !!shownDice && v.rollsUsed > 0 && held[i]!;
const rolling = !!anim && anim.mask[i];
return (
<button
key={`${i}-${rolling ? anim!.key : 's'}`}
type="button"
className={`yt-die${isHeld ? ' held' : ''}${shownDice ? '' : ' blank'}${rolling ? ' rolling' : ''}`}
aria-pressed={canHold ? isHeld : undefined}
aria-label={shownDice ? `주사위 ${i + 1}: ${shownDice[i]}${isHeld ? ', 고정됨' : ''}` : `주사위 ${i + 1}: 아직 안 굴림`}
disabled={!canHold}
onClick={() => toggle(i)}
>
<DieFace value={value} />
{isHeld && <span className="yt-lock">🔒 고정</span>}
</button>
);
})}
</div>
{myTurn ? (
<button className="btn btn-primary btn-block yt-roll" disabled={!canRoll} onClick={roll}>
{rollLabel}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.phase === 'finished' ? '게임이 끝났어요' : v.current ? `${nick(players, v.current)}님이 굴리는 중이에요` : ''}
</p>
)}
{myTurn && v.rollsUsed > 0 && v.rollsUsed < 3 && <p className="muted small" style={{ textAlign: 'center' }}>남길 주사위를 누르면 고정돼요. 점수표의 칸을 누르면 기록해요.</p>}
{v.lastScore && <LastScore entry={v.lastScore} view={v} me={me} players={players} />}
</section>
<ScoreSheet view={v} me={me} players={players} myTurn={myTurn} pending={pending} onScore={score} pointer={pointer} />
<p className="sr-only" aria-live="polite">
{announce}
</p>
{flash && (
<div className="yt-flash" aria-hidden>
{flash}
</div>
)}
{confirm && (
<ConfirmSheet
title={`${catName(confirm.category, v.options.scoring)}에 ${confirm.points}점 기록할까요?`}
body={confirm.points === 0 ? '조건에 맞지 않아서 0점이 돼요. 한 번 기록한 칸은 바꿀 수 없어요.' : '한 번 기록한 칸은 바꿀 수 없어요.'}
confirm="기록하기"
onConfirm={() => act({ type: 'score', category: confirm.category })}
onClose={() => setConfirm(null)}
/>
)}
</div>
);
}
function LastScore({ entry: e, view: v, me, players }: { entry: ScoreEntry; view: YachtView; me: string | null; players: Record<string, PublicUser> }) {
return (
<p className="small row" style={{ justifyContent: 'center', gap: 6 }}>
<span className="muted">방금:</span>
<strong>{e.player === me ? '나' : nick(players, e.player)}</strong>
<DiceRow dice={e.dice} size={20} />
<span>
→ {catName(e.category, v.options.scoring)} {e.points}점{e.bonus ? ` +${e.bonus}` : ''}
{e.auto ? ' (자동)' : ''}
</span>
</p>
);
}
// ---------------------------------------------------------------- score sheet
function ScoreSheet({
view: v,
me,
players,
myTurn,
pending,
onScore,
pointer,
}: {
view: YachtView;
me: string | null;
players: Record<string, PublicUser>;
myTurn: boolean;
pending: boolean;
onScore: (c: Category, points: number, viaMouse: boolean) => void;
pointer: React.MutableRefObject<string>;
}) {
// My column first (bigger), then everyone else in turn order.
const cols = me && v.order.includes(me) ? [me, ...v.order.filter((p) => p !== me)] : v.order;
const upper = v.categories.filter((c) => UPPER.includes(c));
const lower = v.categories.filter((c) => !UPPER.includes(c));
const ls = v.lastScore;
const yz = v.options.scoring === 'yahtzee';
const cell = (p: string, c: Category) => {
const sh = v.sheets[p]!;
const val = sh.scores[c];
if (val !== undefined) {
const fresh = ls && ls.player === p && ls.category === c;
return (
<span key={fresh ? `n${v.turnSeq}` : 'f'} className={`yt-filled${fresh ? ' yt-new' : ''}`}>
{val}
</span>
);
}
const prev = v.current === p ? v.preview?.[c] : undefined;
if (p === me && myTurn && v.rollsUsed > 0 && prev !== undefined) {
const allowed = v.allowed.includes(c);
const show = v.options.showPreview;
return (
<button
type="button"
className={`yt-cell${show && prev > 0 ? ' good' : ''}${show && prev === 0 ? ' zero' : ''}`}
disabled={!allowed || pending}
aria-label={`${catName(c, v.options.scoring)}에 ${show ? `${prev}점 ` : ''}기록하기${allowed ? '' : ' (지금은 넣을 수 없어요)'}`}
onPointerDown={(e) => (pointer.current = e.pointerType)}
onClick={(e) => {
const viaMouse = e.detail === 0 || pointer.current === 'mouse';
pointer.current = '';
onScore(c, prev, viaMouse);
}}
>
{show ? prev : '기록'}
</button>
);
}
if (prev !== undefined && v.options.showPreview) return <span className="yt-ghost">{prev}</span>;
return null;
};
const header = (
<tr>
<th className="yt-cat" scope="col">
칸
</th>
{cols.map((p) => {
const sh = v.sheets[p]!;
return (
<th key={p} scope="col" className={v.current === p ? 'turn' : undefined} title={nick(players, p)}>
<span className="yt-pname">{p === me ? '나' : nick(players, p)}</span>
{sh.left && <div className="small muted">기권</div>}
{!sh.left && sh.away && <div className="small muted">자리 비움</div>}
</th>
);
})}
</tr>
);
const row = (c: Category) => (
<tr key={c}>
<th className="yt-cat" scope="row">
{catName(c, v.options.scoring)}
</th>
{cols.map((p) => (
<td key={p} className={p === me ? 'mine' : undefined}>
{cell(p, c)}
</td>
))}
</tr>
);
const sumRow = (label: string, f: (s: YachtSheetView) => React.ReactNode, cls = 'yt-sum') => (
<tr className={cls}>
<th className="yt-cat" scope="row">
{label}
</th>
{cols.map((p) => (
<td key={p} className={p === me ? 'mine' : undefined}>
{f(v.sheets[p]!)}
</td>
))}
</tr>
);
return (
<section className="card yt-sheet" aria-label="점수표">
<table className="yt-table">
<thead>{header}</thead>
<tbody>
{upper.map(row)}
{sumRow('소계', (s) => `${s.upper}/${v.upperBonusThreshold}`)}
{sumRow('보너스 +35', (s) => (s.upperBonus ? `+${s.upperBonus}` : <span className="muted small">{`${Math.max(0, v.upperBonusThreshold - s.upper)} 남음`}</span>))}
{lower.map(row)}
{yz && sumRow('요트 보너스', (s) => (s.yahtzeeBonus ? `+${s.yahtzeeBonus}` : '-'))}
{sumRow('총점', (s) => s.total, 'yt-total')}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function YachtSide({ view: v, me, players, act, deadline }: BoardProps<YachtView>) {
const [ask, setAsk] = useState(false);
const mine = me ? v.sheets[me] : undefined;
const total = v.options.turnSeconds > 0 ? v.options.turnSeconds * 1000 : deadline ? Math.max(1000, deadline - v.turnStartedAt) : 60_000;
return (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{v.order.map((p, i) => {
const sh = v.sheets[p]!;
const turn = v.current === p;
return (
<div key={p} className="row" style={{ gap: 10, flexWrap: 'nowrap', padding: '4px 6px', borderRadius: 10, border: `3px solid ${turn ? 'var(--focus)' : 'transparent'}` }}>
<span className="muted small" style={{ width: 16, textAlign: 'right' }}>
{i + 1}
</span>
<Avatar user={userOf(players, p)} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{nick(players, p)}
{p === me && <span className="muted small"> (나)</span>}
</div>
<div className="small muted">
{sh.left ? '기권' : sh.away ? '자리 비움' : turn ? '굴리는 중' : `${sh.filled}/${v.totalRounds}칸`}
</div>
</div>
<span style={{ fontWeight: 800, fontSize: '1.3rem', fontVariantNumeric: 'tabular-nums' }} aria-label={`${nick(players, p)} 총점 ${sh.total}점`}>
{sh.total}
</span>
{turn && v.phase === 'playing' && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && !mine.left && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="상단 보너스">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>상단 보너스</strong>
<span>{mine.upperBonus ? '35점 확보!' : `${v.upperBonusThreshold}까지 ${Math.max(0, v.upperBonusThreshold - mine.upper)}점 남음`}</span>
</div>
<div className="yt-bar" role="progressbar" aria-valuemin={0} aria-valuemax={v.upperBonusThreshold} aria-valuenow={Math.min(mine.upper, v.upperBonusThreshold)} aria-label="상단 합계">
<span style={{ width: `${Math.min(100, (mine.upper / v.upperBonusThreshold) * 100)}%` }} />
</div>
</section>
)}
{v.phase === 'playing' && mine && !mine.left && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 칸은 모두 0점이 되고, 이후 차례에서 빠져요."
confirm="기권하기"
danger
onConfirm={() => act({ type: 'concede' })}
onClose={() => setAsk(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: YachtView, me: string | null): boolean {
return v.phase === 'playing' && !!me && v.current === me;
}
function statusText(v: YachtView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (me && v.sheets[me]?.left) return '기권했어요. 구경하는 중이에요';
if (isMyTurn(v, me)) {
if (v.rollsUsed === 0) return '내 차례예요. 주사위를 굴려 주세요';
if (v.rollsUsed < 3) return `내 차례예요. 다시 굴리거나(${v.rollsLeft}번 남음) 칸을 골라 주세요`;
return '내 차례예요. 점수를 넣을 칸을 골라 주세요';
}
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.totalRounds}라운드` : '잠시만 기다려 주세요';
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: [T, string][]; onChange: (v: T) => void }) {
return (
<div className="segmented">
{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<YachtOptions>) {
const set = (patch: Partial<YachtOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="점수 규칙">
<Seg value={o.scoring} options={[['yacht51', '요트(12칸)'], ['yahtzee', '13칸 변형(미국식)']]} onChange={(scoring) => set({ scoring })} />
</Field>
{o.scoring === 'yacht51' ? (
<Field label="5개 모두 같은 눈을 풀 하우스로">
<Seg value={o.fullHouseAllowsYacht ? 'on' : 'off'} options={[['on', '인정'], ['off', '인정 안 함(전통 규칙)']]} onChange={(k) => set({ fullHouseAllowsYacht: k === 'on' })} />
</Field>
) : (
<Field label="요트가 또 나왔을 때">
<Seg value={o.yahtzeeJoker} options={[['forced', '정해진 순서대로(공식)'], ['free', '아무 칸이나']]} onChange={(yahtzeeJoker) => set({ yahtzeeJoker })} />
</Field>
)}
<Field label="한 차례 제한 시간">
<Seg value={o.turnSeconds} options={[[30, '30초'], [60, '60초'], [90, '90초'], [0, '제한 없음']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
<Field label="점수 미리 보기">
<Seg value={o.showPreview ? 'on' : 'off'} options={[['on', '빈 칸에 보여 주기'], ['off', '보여 주지 않기']]} onChange={(k) => set({ showPreview: k === 'on' })} />
</Field>
</div>
);
}
function optionsSummary(o: YachtOptions): string {
return [
o.scoring === 'yacht51' ? '요트 12칸' : '13칸 변형',
...(o.scoring === 'yacht51' && !o.fullHouseAllowsYacht ? ['풀 하우스에 요트 불인정'] : []),
...(o.scoring === 'yahtzee' ? [o.yahtzeeJoker === 'forced' ? '추가 요트는 정해진 순서' : '추가 요트는 아무 칸'] : []),
o.turnSeconds > 0 ? `한 차례 ${o.turnSeconds}초` : '시간 제한 없음',
...(o.showPreview ? [] : ['미리 보기 끔']),
].join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: YachtOptions }) {
const yz = options?.scoring === 'yahtzee';
const fhYacht = options?.fullHouseAllowsYacht ?? true;
const rows: [string, string, number[]][] = yz
? [
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
['쓰리 카인드', '같은 눈 3개 이상이면 5개 합', [4, 4, 4, 2, 5]],
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
['풀 하우스', '같은 눈 3개 + 다른 같은 눈 2개면 25점', [2, 2, 5, 5, 5]],
['스몰 스트레이트', '이어지는 눈 4개면 30점', [1, 2, 3, 4, 6]],
['라지 스트레이트', '이어지는 눈 5개면 40점', [2, 3, 4, 5, 6]],
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
['찬스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
]
: [
['에이스~식스', '그 눈이 나온 주사위의 합', [6, 6, 6, 2, 1]],
['초이스', '아무 조합이나 5개 합', [6, 5, 4, 4, 2]],
['포 카인드', '같은 눈 4개 이상이면 5개 합', [3, 3, 3, 3, 6]],
['풀 하우스', `같은 눈 3개 + 다른 같은 눈 2개면 5개 합${fhYacht ? '(5개 모두 같아도 인정)' : ''}`, [2, 2, 5, 5, 5]],
['스몰 스트레이트', '이어지는 눈 4개면 15점', [1, 2, 3, 4, 6]],
['라지 스트레이트', '이어지는 눈 5개면 30점', [2, 3, 4, 5, 6]],
['요트', '5개 모두 같은 눈이면 50점', [5, 5, 5, 5, 5]],
];
return (
<div className="stack">
<p>
주사위 5개를 한 차례에 <strong>최대 3번</strong> 굴려요. 마음에 드는 주사위는 눌러서 <strong>고정</strong>하고 나머지만 다시 굴릴 수 있어요. 고정은 굴릴 때마다 바꿀 수 있어요.
</p>
<p>
굴린 뒤에는 점수표의 <strong>빈 칸 하나</strong>를 골라 지금 주사위로 점수를 적어요. 조건에 맞지 않는 칸에 넣으면 0점이에요. 한 번 적은 칸은 바꿀 수 없고, 3번 굴린 뒤에는 꼭 적어야 해요.
</p>
<p>
{yz ? '13칸' : '12칸'}을 모두 채우면 끝나고, <strong>총점이 가장 높은 사람</strong>이 이겨요. 혼자서 최고 점수에 도전할 수도 있어요.
</p>
<div className="stack" style={{ gap: 6 }}>
{rows.map(([name, rule, dice]) => (
<div key={name} className="row" style={{ gap: 10, flexWrap: 'nowrap' }}>
<DiceRow dice={dice} size={22} />
<span className="small">
<strong>{name}</strong> · {rule}
</span>
</div>
))}
</div>
<p>
<strong>상단 보너스</strong>: 에이스~식스 6칸의 합이 63점 이상이면 35점을 더 받아요(눈마다 3개씩 모으면 딱 63점이에요).
</p>
{yz && (
<p>
<strong>추가 요트</strong>: 요트 칸에 50점을 적은 뒤 요트가 또 나오면 100점을 더 받아요.{' '}
{options?.yahtzeeJoker === 'free'
? '이때는 아무 빈 칸에나 적을 수 있어요.'
: '이때는 그 눈의 윗칸이 비어 있으면 거기에 먼저 적어야 하고, 윗칸이 차 있으면 아래쪽 칸에 적어요.'}{' '}
그 눈의 윗칸이 이미 차 있으면 풀 하우스 25점, 스트레이트 30·40점을 조건과 상관없이 인정해요.
</p>
)}
<p className="muted">
시간 안에 적지 않으면 지금 주사위로 가장 높은 점수가 나오는 칸에 자동으로 적혀요(아직 안 굴렸으면 먼저 굴려요). 기권하면 남은 칸은 모두 0점이 돼요.
</p>
</div>
);
}
const ui: GameUI<YachtView, YachtOptions> = {
Board: YachtBoard,
Side: YachtSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
mahjong: '🀇',
yut: '🪵',
othello: '◐',
yacht: '🎲',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -562,9 +562,11 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.id);
const voted = room.rematchVotes.includes(me.id);
const allFirst = result.ranking.length === 1;
const won = !allFirst && result.ranking[0]?.includes(me.id);
const lost = !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const solo = result.ranking.flat().length === 1;
const allFirst = !solo && result.ranking.length === 1;
const won = !solo && !allFirst && result.ranking[0]?.includes(me.id);
const lost = !solo && !allFirst && !won && result.ranking.some((g) => g.includes(me.id));
useEffect(() => {
if (played.current) return;
played.current = true;