Files
joke-app/apps/web/src/games/las-vegas/index.tsx
EJClaw fa7ceea668 M7: 라스베가스 규칙·화면·테스트
- 규칙 모듈 packages/games/src/las-vegas: 지폐 54장 더미·카지노 채우기(합 5 이상), 굴리기/놓기(같은 눈 전부),
  중립(흰) 주사위 변형(auto/on/off, 3명은 라운드 시작 하우스 주사위 2개 자동 배치), 동점 무효 정산,
  하우스·남은 지폐 더미 아래로(카지노 1→6, 큰 금액부터), 라운드마다 선 이동, 장 수 동점 처리,
  시간 초과(굴리기 자동 / 개수→지폐→작은 수 순 자동 놓기)와 3회 연속 자리 비움, 기권,
  hideMoney 시 남의 금액은 장 수만 보이는 view, 놓기 미리 보기(1등/동점 주의) 계산
- 테스트: 문서 10절 시나리오 전부, 1,000시드 무작위 대국(타임아웃·기권 포함, 지폐·주사위 보존,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 100시드
- 화면 apps/web/src/games/las-vegas: 카지노 6곳(PC 한 줄, 폰 3열×2행), 색깔 주사위 쌓기, 놀이용 지폐,
  굴림 애니메이션, 숫자별 묶음 버튼 → 카지노 강조와 예상 순위 → 확정, 라운드 정산(동점 무효) 표시,
  최종 결과표, 폰에서는 굴리기/놓기 영역이 화면 아래에 고정
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘, 놀이용 점수 문구 검사 대상에 추가
- E2E: PC 방장 + 폰 친구, 3라운드로 바꿔 결과까지 실제 플레이(키보드 선택, 카지노 카드 선택, 동점 무효 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:53:47 +09:00

817 lines
39 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 라스베가스 화면 (docs/games/las-vegas.md §9). Bills are play points of this room only (docs/04 §6). */
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { LasVegasChoice, LasVegasOptions, LasVegasPayoutView, LasVegasView } from '@bg/games/las-vegas';
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 ROLL_MS = 650;
const HOUSE = 'house';
/** Seat colours (dice), plus white for the neutral "house" dice. */
const COLORS = ['#e5484d', '#2f7cf6', '#1f9d55', '#f2a10c', '#8e5cf0'];
const COLOR_NAMES = ['빨강', '파랑', '초록', '노랑', '보라'];
/** Play-bill colours per face value (deliberately toy-like). */
const BILL: Record<number, [string, string]> = {
1: ['#fde68a', '#78350f'],
2: ['#bbf7d0', '#14532d'],
3: ['#bae6fd', '#0c4a6e'],
4: ['#fbcfe8', '#831843'],
5: ['#ddd6fe', '#3b0764'],
6: ['#fed7aa', '#7c2d12'],
7: ['#a7f3d0', '#064e3b'],
8: ['#c7d2fe', '#1e1b4b'],
9: ['#fecaca', '#7f1d1d'],
};
const nick = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '하우스(흰 주사위)' : (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 seatColor = (v: LasVegasView, owner: string) => (owner === HOUSE ? '#ffffff' : (COLORS[v.seats.indexOf(owner)] ?? '#888'));
const initial = (players: Record<string, PublicUser>, id: string) => (id === HOUSE ? '흰' : ([...nick(players, id)][0] ?? '?'));
const CSS = `
.lv-wrap { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }
.lv-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; flex-wrap: wrap; padding: 10px 14px; }
.lv-help { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
.lv-help span { background: var(--warning-soft); color: var(--text); border: 2px solid var(--warning); border-radius: 999px; padding: 4px 14px; font-weight: 800; font-size: 1.02rem; }
.lv-casinos { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.lv-casino { position: relative; display: flex; flex-direction: column; gap: 6px; padding: 8px; min-height: 196px; border-radius: var(--radius); border: 3px solid var(--border);
background: var(--surface); box-shadow: var(--shadow); text-align: left; color: var(--text); font: inherit; transition: border-color .15s, transform .15s; min-width: 0; }
button.lv-casino { cursor: pointer; }
button.lv-casino:hover { border-color: var(--primary); }
.lv-casino:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lv-casino.can { border-style: dashed; border-color: var(--primary); }
.lv-casino.sel { border-style: solid; border-color: var(--primary); transform: translateY(-3px); box-shadow: 0 0 0 4px var(--primary-soft), var(--shadow); }
.lv-casino.first { border-color: var(--success); box-shadow: 0 0 0 4px var(--success-soft), var(--shadow); }
.lv-casino.tie { border-color: var(--warning); box-shadow: 0 0 0 4px var(--warning-soft), var(--shadow); }
.lv-ctop { display: flex; align-items: center; gap: 6px; }
.lv-cnum { font-size: 1.5rem; font-weight: 900; line-height: 1; }
.lv-cname { font-size: .8rem; color: var(--text-sub); font-weight: 700; white-space: nowrap; }
.lv-bills { display: flex; flex-wrap: wrap; gap: 4px; min-height: 30px; }
.lv-bill { position: relative; display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 28px; padding: 0 6px; border-radius: 6px;
font-weight: 900; font-size: 1.05rem; border: 2px dashed rgba(0,0,0,.35); box-shadow: inset 0 0 0 2px rgba(255,255,255,.55); font-variant-numeric: tabular-nums; }
.lv-bill::before { content: '★'; font-size: .55rem; position: absolute; left: 3px; top: 1px; opacity: .6; }
.lv-bill.sm { min-width: 26px; height: 22px; font-size: .85rem; padding: 0 4px; }
.lv-bill.hidden { background: repeating-linear-gradient(45deg, var(--surface-2), var(--surface-2) 4px, var(--border) 4px, var(--border) 8px); color: var(--text-sub); }
.lv-nobill { color: var(--text-sub); font-size: .8rem; }
.lv-stacks { display: flex; flex-direction: column; gap: 3px; margin-top: auto; }
.lv-stack { display: flex; align-items: center; gap: 4px; font-size: .82rem; font-weight: 800; min-width: 0; }
.lv-pips { display: flex; flex-wrap: wrap; gap: 2px; min-width: 0; }
.lv-dot { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid rgba(0,0,0,.45); flex: none; }
.lv-tag { position: absolute; top: -11px; right: 6px; font-size: .78rem; font-weight: 900; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
.lv-tag.first { background: var(--success); color: #fff; }
.lv-tag.tie { background: #facc15; color: #422006; }
.lv-tag.award { background: var(--primary); color: var(--primary-text); }
.lv-tag.none { background: var(--surface-2); color: var(--text-sub); border: 1px solid var(--border); }
@keyframes lv-hit { 0% { transform: scale(1); } 40% { transform: scale(1.05); box-shadow: 0 0 0 6px var(--primary-soft); } 100% { transform: scale(1); } }
.lv-casino.hit { animation: lv-hit .55s ease-out; }
.lv-lower { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 10px; align-items: start; }
.lv-tray { display: flex; flex-direction: column; gap: 10px; padding: 12px; }
.lv-groups { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.lv-group { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 92px; min-height: 64px; padding: 6px 10px; border-radius: 12px;
border: 3px solid var(--border); background: var(--surface); color: var(--text); font: inherit; cursor: pointer; }
.lv-group:disabled { cursor: default; opacity: 1; }
.lv-group[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
.lv-group:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.lv-group b { font-size: 1.15rem; }
.lv-group small { color: var(--text-sub); font-weight: 700; }
.lv-mdice { display: flex; gap: 3px; flex-wrap: wrap; justify-content: center; }
.lv-die { width: 28px; height: 28px; border-radius: 7px; border: 2px solid rgba(0,0,0,.4); flex: none; display: inline-block; }
.lv-die svg { display: block; width: 100%; height: 100%; }
.lv-die.lg { width: 40px; height: 40px; border-radius: 9px; }
@keyframes lv-tumble { 0% { transform: translateY(-16px) rotate(-40deg); } 50% { transform: translateY(3px) rotate(190deg); } 100% { transform: translateY(0) rotate(360deg); } }
.lv-die.rolling { animation: lv-tumble ${ROLL_MS}ms ease-out both; }
.lv-preview { border-radius: 10px; padding: 8px 12px; font-weight: 700; text-align: center; }
.lv-preview.first { background: var(--success-soft); color: var(--success); }
.lv-preview.tie { background: var(--warning-soft); color: var(--warning); }
.lv-preview.award, .lv-preview.none { background: var(--surface-2); }
.lv-roll { min-height: 60px; font-size: 1.15rem; }
.lv-settle { padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.lv-settle h3 { margin: 0; font-size: 1rem; }
.lv-prow { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 6px; align-items: center; font-size: .85rem; padding: 3px 0; border-bottom: 1px solid var(--border); }
.lv-prow:last-child { border-bottom: 0; }
.lv-pbody { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-width: 0; }
.lv-chip { display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; font-weight: 700; }
.lv-tieout { display: inline-flex; align-items: center; gap: 3px; color: var(--warning); font-weight: 800; }
.lv-tieout .lv-dot { opacity: .55; }
@keyframes lv-poof { 0% { transform: scale(1.4); opacity: 0; } 50% { transform: scale(1.15); opacity: 1; } 100% { transform: scale(1); } }
.lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: lv-poof .6s ease-out both; }
.lv-pname { max-width: 84px; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: bottom; }
.lv-final { padding: 12px; }
.lv-final table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.lv-final th, .lv-final td { padding: 4px 6px; border-bottom: 1px solid var(--border); text-align: center; white-space: nowrap; }
.lv-final td.notes { white-space: normal; text-align: left; }
@media (max-width: 1099px) {
.lv-lower { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 719px) {
.lv-casinos { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.lv-casino { min-height: 132px; padding: 6px; gap: 4px; }
.lv-wide { display: none; }
.lv-cnum { font-size: 1.25rem; }
.lv-cname { display: none; }
.lv-bill { min-width: 26px; height: 24px; font-size: .9rem; padding: 0 4px; }
.lv-dot { width: 11px; height: 11px; }
.lv-help { gap: 6px; flex-wrap: nowrap; }
.lv-help span { font-size: .8rem; padding: 2px 8px; white-space: nowrap; }
.lv-group { min-width: 76px; padding: 6px 8px; }
.lv-die { width: 22px; height: 22px; border-radius: 6px; }
.lv-die.lg { width: 32px; height: 32px; }
/* The tray sticks to the bottom of the screen so rolling / placing is always in reach. */
.lv-lower { display: contents; }
.lv-tray { position: sticky; bottom: 0; z-index: 4; box-shadow: 0 -4px 14px rgba(0,0,0,.12); }
}
@media (prefers-reduced-motion: reduce) {
.lv-casino, .lv-casino.sel { transition: none; transform: none; }
.lv-casino.hit, .lv-die.rolling, .lv-fresh .lv-tieout, .lv-fresh .lv-chip { animation: none; }
}
`;
// ---------------------------------------------------------------- small pieces
// prettier-ignore
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]],
};
/** Pip colour that reads on the die colour (white dice → dark pips). */
const pipOn = (bg: string) => (['#ffffff', '#fffdf8', '#f2a10c'].includes(bg) ? '#1c1e21' : '#ffffff');
function Die({ value, color, size = '', rolling = false }: { value: number; color: string; size?: '' | 'lg'; rolling?: boolean }) {
return (
<span className={`lv-die${size ? ` ${size}` : ''}${rolling ? ' rolling' : ''}`} style={{ background: color }} aria-hidden>
<svg viewBox="0 0 100 100">
{PIPS[value]!.map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={value === 1 ? 14 : 10} fill={pipOn(color)} />
))}
</svg>
</span>
);
}
function Bill({ note, small }: { note: number | null; small?: boolean }) {
if (note === null)
return (
<span className={`lv-bill hidden${small ? ' sm' : ''}`} aria-label="금액 비공개 지폐">
?
</span>
);
const [bg, fg] = BILL[note] ?? ['#eee', '#111'];
return (
<span className={`lv-bill${small ? ' sm' : ''}`} style={{ background: bg, color: fg }} aria-label={`지폐 ${note}점`}>
{note}
</span>
);
}
function Dot({ color }: { color: string }) {
return <span className="lv-dot" style={{ background: color }} aria-hidden />;
}
function OwnerChip({ v, players, owner, me }: { v: LasVegasView; players: Record<string, PublicUser>; owner: string; me: string | null }) {
return (
<span className="lv-chip">
<Dot color={seatColor(v, owner)} />
<span className="lv-pname">{owner === me ? '나' : owner === HOUSE ? '하우스' : nick(players, owner)}</span>
</span>
);
}
const STANDING_TAG: Record<LasVegasChoice['standing'], string> = { first: '1등!', tie: '동점 주의', award: '지폐 받음', none: '못 받아요' };
function previewText(c: LasVegasChoice): string {
const head = `${c.value}번 카지노에 ${c.own + c.neutral}개를 놓으면`;
switch (c.standing) {
case 'first':
return `${head} 지금은 1등이에요! (${c.note}점 지폐)`;
case 'award':
return `${head} 지금은 ${c.note}점 지폐를 받을 수 있어요.`;
case 'tie':
return `${head} 개수가 같아져서 지금은 둘 다 꽝이에요. 동점 주의!`;
default:
return c.own === 0 ? `${head} 흰 주사위만 놓여요. 내 주사위는 그대로예요.` : `${head} 지금은 받을 지폐가 없어요.`;
}
}
// ---------------------------------------------------------------- board
function LasVegasBoard({ view: v, me, players, act, pending }: BoardProps<LasVegasView>) {
const myTurn = v.phase !== 'finished' && !!me && v.current === me;
const [sel, setSel] = useState<number | null>(null);
const [rolling, setRolling] = useState(false);
const [flick, setFlick] = useState<number[] | null>(null);
const [hit, setHit] = useState<{ value: number; key: number } | null>(null);
const [announce, setAnnounce] = useState('');
const prevRoll = useRef(v.rollSeq);
const prevTurn = useRef(v.turnSeq);
const prevRounds = useRef(v.roundResults.length);
// New roll: tumble animation + screen-reader text.
useLayoutEffect(() => {
if (v.rollSeq <= prevRoll.current || !v.lastRoll) {
prevRoll.current = v.rollSeq;
return;
}
prevRoll.current = v.rollSeq;
setSel(null);
const who = v.current === me ? '내가' : `${nick(players, v.current ?? '')}님이`;
const all = [...v.lastRoll.own, ...v.lastRoll.neutral].sort((a, b) => a - b);
setAnnounce(`${who} 주사위 ${all.length}개를 굴렸어요: ${all.join(', ')}`);
if (reducedMotion()) {
sounds.stone();
return;
}
setRolling(true);
}, [v.rollSeq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!rolling || !v.lastRoll) return;
const n = v.lastRoll.own.length + v.lastRoll.neutral.length;
const t = setInterval(() => setFlick(Array.from({ length: n }, () => 1 + Math.floor(Math.random() * 6))), 70);
const end = setTimeout(() => {
setRolling(false);
setFlick(null);
sounds.stone();
}, ROLL_MS);
return () => {
clearInterval(t);
clearTimeout(end);
};
}, [rolling]); // eslint-disable-line react-hooks/exhaustive-deps
// Turn changes: placement flash, round settlement and my-turn cue.
useEffect(() => {
if (v.turnSeq === prevTurn.current) return;
prevTurn.current = v.turnSeq;
setSel(null);
const parts: string[] = [];
const lp = v.lastPlace;
if (lp) {
setHit({ value: lp.value, key: v.turnSeq });
const who = lp.player === me ? '내가' : `${nick(players, lp.player)}님이`;
parts.push(
`${who} ${lp.value}번 카지노에 주사위 ${lp.own + lp.neutral}개를 놓았어요${lp.neutral ? `(흰 ${lp.neutral}개 포함)` : ''}${lp.auto ? ' (시간 초과로 자동)' : ''}.`,
);
}
if (v.roundResults.length > prevRounds.current) {
prevRounds.current = v.roundResults.length;
const rr = v.roundResults[v.roundResults.length - 1]!;
const ties = rr.payouts.filter((p) => p.removedTies.length > 0).length;
parts.push(`${rr.round}라운드 정산이 끝났어요.${ties ? ` 동점 무효가 ${ties}곳에서 나왔어요.` : ''}`);
haptic([50, 30, 50]);
}
if (myTurn) {
haptic(40);
if (document.visibilityState === 'hidden') sounds.myTurn();
}
if (parts.length) setAnnounce(parts.join(' '));
}, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps
const choices = v.phase === 'place' && !rolling ? v.choices : [];
const selChoice = choices.find((c) => c.value === sel) ?? null;
const canPick = myTurn && v.phase === 'place' && !pending && !rolling;
const pick = (value: number) => {
if (!canPick || !choices.some((c) => c.value === value)) return;
setSel(sel === value ? null : value);
};
const place = () => {
if (!canPick || sel === null) return;
act({ type: 'PLACE', value: sel });
setSel(null);
};
const roll = () => {
if (myTurn && v.phase === 'roll' && !pending) act({ type: 'ROLL' });
};
const curName = v.current ? (v.current === me ? '내' : `${nick(players, v.current)}님`) : '';
const hand = me ? v.hand[me] : undefined;
const lastRR = v.roundResults[v.roundResults.length - 1];
return (
<div className="lv-wrap">
<style>{CSS}</style>
<div className="card lv-head">
<strong>{v.phase === 'finished' ? '게임 끝' : `${v.round} / ${v.rounds} 라운드`}</strong>
{v.current && (
<span className="row small" style={{ gap: 6, flexWrap: 'nowrap', minWidth: 0 }}>
<Dot color={seatColor(v, v.current)} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{curName} 차례</span>
</span>
)}
<span className="small muted">
{v.phase !== 'finished' && `선 ${v.startPlayer === me ? '나' : nick(players, v.startPlayer)} · `}
<span className="lv-wide">남은 지폐 </span>더미 {v.pileCount}장
</span>
</div>
{v.round === 1 && v.phase !== 'finished' && (
<div className="lv-help" aria-label="처음 하는 분을 위한 도움말">
<span>🎲 같은 숫자는 한꺼번에</span>
<span>⚠️ 개수가 같으면 둘 다 꽝</span>
</div>
)}
{v.result && lastRR && (
<div className="lv-lower">
<FinalTable v={v} me={me} players={players} />
<Settlement round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
</div>
)}
{!v.result && (
<section className="lv-casinos" aria-label="카지노 6곳">
{v.casinos.map((c) => {
const ch = choices.find((x) => x.value === c.value);
const isSel = sel === c.value;
const owners = Object.entries(c.dice)
.filter(([, n]) => (n ?? 0) > 0)
.sort((a, b) => b[1]! - a[1]! || v.seats.indexOf(a[0]) - v.seats.indexOf(b[0]));
const label = `${c.value}번 카지노. 지폐 ${c.notes.length ? c.notes.map((n) => `${n}점`).join(', ') : '없음'}. 주사위 ${
owners.length ? owners.map(([o, n]) => `${o === me ? '나' : nick(players, o)} ${n}개`).join(', ') : '없음'
}${ch && isSel ? `. ${STANDING_TAG[ch.standing]}` : ''}`;
const cls = `lv-casino${ch && canPick ? ' can' : ''}${isSel ? ` sel ${ch?.standing === 'first' ? 'first' : ch?.standing === 'tie' ? 'tie' : ''}` : ''}${hit?.value === c.value ? ' hit' : ''}`;
const body = (
<>
<div className="lv-ctop">
<Die value={c.value} color="#fffdf8" />
<span className="lv-cnum">{c.value}</span>
<span className="lv-cname">번 카지노</span>
</div>
<div className="lv-bills">
{c.notes.length ? c.notes.map((n, i) => <Bill key={i} note={n} />) : <span className="lv-nobill">지폐 없음</span>}
</div>
<div className="lv-stacks">
{owners.map(([o, n]) => (
<div key={o} className="lv-stack" title={nick(players, o)}>
<span className="lv-pips">
{Array.from({ length: Math.min(n!, 8) }, (_, i) => (
<Dot key={i} color={seatColor(v, o)} />
))}
</span>
<span>{n! > 8 ? `${n}` : ''}</span>
<span className="muted" style={{ fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{initial(players, o)}
</span>
</div>
))}
{ch && isSel && (
<div className="lv-stack" style={{ opacity: 0.85 }}>
<span className="lv-pips">
{Array.from({ length: ch.own }, (_, i) => (
<Dot key={`o${i}`} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: ch.neutral }, (_, i) => (
<Dot key={`n${i}`} color="#ffffff" />
))}
</span>
<span className="small">+{ch.own + ch.neutral}</span>
</div>
)}
</div>
{ch && isSel && <span className={`lv-tag ${ch.standing}`}>{STANDING_TAG[ch.standing]}</span>}
</>
);
return ch && canPick ? (
<button
key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`}
type="button"
className={cls}
aria-pressed={isSel}
aria-label={`${label}. 누르면 여기에 놓을 숫자로 골라요`}
onClick={() => pick(c.value)}
>
{body}
</button>
) : (
<div key={`${c.value}-${hit?.value === c.value ? hit.key : 's'}`} className={cls} role="group" aria-label={label}>
{body}
</div>
);
})}
</section>
)}
{!v.result && (
<div className="lv-lower">
<section className="card lv-tray" aria-label="주사위 굴리기와 놓기">
{v.phase === 'finished' ? (
<p className="muted" style={{ textAlign: 'center' }}>
게임이 끝났어요
</p>
) : rolling && v.lastRoll ? (
<div className="lv-mdice" aria-hidden>
{[...v.lastRoll.own.map(() => seatColor(v, v.current!)), ...v.lastRoll.neutral.map(() => '#ffffff')].map((col, i) => (
<Die key={`${v.rollSeq}-${i}`} value={flick?.[i] ?? 1} color={col} size="lg" rolling />
))}
</div>
) : v.phase === 'place' && v.current ? (
<>
<div className="lv-groups" role="group" aria-label={canPick ? '나온 숫자. 하나를 골라 주세요' : `${nick(players, v.current)}님이 굴린 주사위`}>
{choices.map((c) => (
<button
key={c.value}
type="button"
className="lv-group"
data-standing={c.standing}
aria-pressed={canPick ? sel === c.value : undefined}
disabled={!canPick}
aria-label={`${c.value} 주사위 ${c.own + c.neutral}개${c.neutral ? `(흰 ${c.neutral}개 포함)` : ''}. ${STANDING_TAG[c.standing]}`}
onClick={() => pick(c.value)}
>
<span className="lv-mdice">
{Array.from({ length: c.own }, (_, i) => (
<Die key={`o${i}`} value={c.value} color={seatColor(v, v.current!)} />
))}
{Array.from({ length: c.neutral }, (_, i) => (
<Die key={`n${i}`} value={c.value} color="#ffffff" />
))}
</span>
<b>
{c.value} × {c.own + c.neutral}개
</b>
{c.neutral > 0 && <small>흰 {c.neutral} 포함</small>}
</button>
))}
</div>
{canPick && selChoice && <div className={`lv-preview ${selChoice.standing}`}>{previewText(selChoice)}</div>}
{canPick ? (
<button className="btn btn-primary btn-block lv-roll" disabled={!selChoice} onClick={place}>
{selChoice ? `${selChoice.value}번 카지노에 ${selChoice.own + selChoice.neutral}개 놓기` : '놓을 숫자를 골라 주세요'}
</button>
) : (
<p className="muted small" style={{ textAlign: 'center' }}>
{myTurn ? '잠시만요…' : `${nick(players, v.current)}님이 놓을 숫자를 고르는 중이에요`}
</p>
)}
</>
) : myTurn ? (
<button className="btn btn-primary btn-block lv-roll" disabled={pending} onClick={roll}>
🎲 주사위 굴리기 ({(hand?.own ?? 0) + (hand?.neutral ?? 0)}개)
</button>
) : (
<p className="muted small" style={{ textAlign: 'center', minHeight: 24 }}>
{v.current ? `${nick(players, v.current)}님이 굴릴 차례예요` : ''}
</p>
)}
{hand && v.phase !== 'finished' && !v.conceded.includes(me!) && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
내 주사위 {hand.own}개{v.neutralPer > 0 ? ` · 흰 주사위 ${hand.neutral}개` : ''} 남음
</p>
)}
{v.houseRoll && v.houseRoll.round === v.round && v.phase !== 'finished' && (
<p className="small muted" style={{ textAlign: 'center', margin: 0 }}>
라운드 시작: 남는 흰 주사위 {v.houseRoll.values.length}개가 {v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요
</p>
)}
</section>
{lastRR ? (
<Settlement key={lastRR.round} round={lastRR.round} payouts={lastRR.payouts} v={v} me={me} players={players} />
) : (
<section className="card lv-settle" aria-label="정산 안내">
<h3>라운드가 끝나면</h3>
<p className="small muted" style={{ margin: 0 }}>
카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 무효예요.
</p>
</section>
)}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
function Settlement({
round,
payouts,
v,
me,
players,
}: {
round: number;
payouts: LasVegasPayoutView[];
v: LasVegasView;
me: string | null;
players: Record<string, PublicUser>;
}) {
const fresh = v.phase !== 'finished' && round === v.round - 1;
return (
<section className={`card lv-settle${fresh ? ' lv-fresh' : ''}`} aria-label={`${round}라운드 정산`}>
<h3>{round}라운드 정산</h3>
{payouts.map((p) => (
<div key={p.casino} className="lv-prow">
<strong>{p.casino}번</strong>
<div className="lv-pbody">
{p.removedTies.length > 0 && (
<span className="lv-tieout" aria-label={`동점 무효: ${p.removedTies.map((o) => (o === me ? '나' : nick(players, o))).join(', ')}`}>
💥 동점 무효
{p.removedTies.map((o) => (
<Dot key={o} color={seatColor(v, o)} />
))}
</span>
)}
{p.awards.map((a, i) => (
<span key={a.key ?? `${a.owner}-${i}`} className="lv-chip">
<OwnerChip v={v} players={players} owner={a.owner} me={me} />
<Bill note={a.note} small />
{a.note === null && <span className="muted">1장</span>}
</span>
))}
{p.returned.length > 0 && <span className="lv-chip muted">↩ 더미 아래로 {p.returned.map((n) => n).join('·')}</span>}
{p.awards.length === 0 && p.removedTies.length === 0 && p.returned.length === 0 && <span className="muted">아무도 없었어요</span>}
</div>
</div>
))}
</section>
);
}
function FinalTable({ v, me, players }: { v: LasVegasView; me: string | null; players: Record<string, PublicUser> }) {
const r = v.result!;
return (
<section className="card lv-final" aria-label="최종 결과">
<h3 style={{ margin: '0 0 6px' }}>최종 결과</h3>
<table>
<thead>
<tr>
<th scope="col">순위</th>
<th scope="col">이름</th>
{v.roundResults.map((rr) => (
<th key={rr.round} scope="col">
{rr.round}R
</th>
))}
<th scope="col">합계</th>
</tr>
</thead>
<tbody>
{r.ranking.map((row) => (
<tr key={row.player}>
<td>{row.rank}등</td>
<td style={{ textAlign: 'left' }}>
<OwnerChip v={v} players={players} owner={row.player} me={me} />
{row.conceded && <span className="muted small"> (기권)</span>}
</td>
{(r.roundTotals[row.player] ?? []).map((t, i) => (
<td key={i}>{t}</td>
))}
<td>
<strong>{row.total}점</strong>
<div className="small muted">{row.notes.length}장</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
// ---------------------------------------------------------------- side panel
function LasVegasSide({ view: v, me, players, act, deadline }: BoardProps<LasVegasView>) {
const [ask, setAsk] = useState(false);
const total = v.options.turnSeconds ? v.options.turnSeconds * 1000 : 30_000;
const mine = me ? v.money[me] : undefined;
const playing = v.phase !== 'finished';
return (
<>
<section className="card stack" style={{ padding: 12, gap: 8 }} aria-label="참가자">
{v.seats.map((p, i) => {
const turn = v.current === p;
const m = v.money[p]!;
const h = v.hand[p]!;
const out = v.conceded.includes(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 aria-label={`${COLOR_NAMES[i]} 주사위`} role="img">
<Die value={i + 1} color={COLORS[i]!} />
</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>}
{v.startPlayer === p && playing && (
<span className="badge" style={{ marginLeft: 4, padding: '0 6px' }}>
선
</span>
)}
</div>
<div className="small muted">
{out ? '기권' : v.away.includes(p) ? '자리 비움' : playing ? `주사위 ${h.own}${v.neutralPer ? ` · 흰 ${h.neutral}` : ''}` : '끝'}
</div>
</div>
<span
style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
aria-label={`${nick(players, p)} 지폐 ${m.count}장${m.total !== null ? `, ${m.total}점` : ''}`}
>
<strong style={{ fontSize: '1.2rem' }}>{m.total !== null ? `${m.total}점` : '?'}</strong>
<div className="small muted">{m.count}장</div>
</span>
{turn && playing && <TurnTimer deadline={deadline} totalMs={total} mine={p === me} size={40} />}
</div>
);
})}
</section>
{mine && (
<section className="card stack" style={{ padding: 12, gap: 6 }} aria-label="내 지폐">
<div className="row small" style={{ justifyContent: 'space-between' }}>
<strong>내 지폐</strong>
<span>
{mine.count}장 · {mine.total ?? 0}점
</span>
</div>
<div className="lv-bills">
{mine.notes && mine.notes.length ? mine.notes.map((n, i) => <Bill key={i} note={n} small />) : <span className="lv-nobill">아직 없어요</span>}
</div>
{v.options.hideMoney && playing && (
<p className="small muted" style={{ margin: 0 }}>
다른 사람에게는 장 수만 보여요.
</p>
)}
</section>
)}
{playing && me && v.money[me] && !v.conceded.includes(me) && (
<button className="btn btn-danger btn-sm" onClick={() => setAsk(true)}>
🏳 기권
</button>
)}
{ask && (
<ConfirmSheet
title="기권할까요?"
body="남은 주사위는 모두 버려지고 이후 차례에서 빠져요. 이미 받은 지폐는 결과에 남아요."
confirm="기권하기"
danger
onConfirm={() => act({ type: 'CONCEDE' })}
onClose={() => setAsk(false)}
/>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: LasVegasView, me: string | null): boolean {
return v.phase !== 'finished' && !!me && v.current === me;
}
function statusText(v: LasVegasView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (me && v.conceded.includes(me)) return '기권했어요. 구경하는 중이에요';
if (isMyTurn(v, me)) return v.phase === 'roll' ? '내 차례예요. 주사위를 굴려 주세요' : '내 차례예요. 놓을 숫자를 골라 주세요';
return v.current ? `${nick(players, v.current)}님 차례예요 · ${v.round}/${v.rounds}라운드` : '잠시만 기다려 주세요';
}
// ---------------------------------------------------------------- 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<LasVegasOptions>) {
const set = (patch: Partial<LasVegasOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="라운드 수">
<Seg
label="라운드 수"
value={o.rounds}
options={[
[3, '3라운드'],
[4, '4라운드'],
[5, '5라운드'],
]}
onChange={(rounds) => set({ rounds })}
/>
</Field>
<Field label="중립(흰) 주사위">
<Seg
label="중립(흰) 주사위"
value={o.neutralDice}
options={[
['auto', '자동(2명일 때만)'],
['on', '켜기(2~4명)'],
['off', '끄기'],
]}
onChange={(neutralDice) => set({ neutralDice })}
/>
</Field>
<Field label="다른 사람이 받은 지폐 금액">
<Seg
label="다른 사람이 받은 지폐 금액"
value={o.hideMoney ? 'hide' : 'show'}
options={[
['hide', '숨기기(장 수만)'],
['show', '보여 주기'],
]}
onChange={(k) => set({ hideMoney: k === 'hide' })}
/>
</Field>
<Field label="굴리기·놓기 제한 시간">
<Seg
label="굴리기·놓기 제한 시간"
value={o.turnSeconds === null ? 'none' : String(o.turnSeconds)}
options={[
['20', '20초'],
['30', '30초'],
['60', '60초'],
['none', '무제한'],
]}
onChange={(k) => set({ turnSeconds: k === 'none' ? null : (Number(k) as 20 | 30 | 60) })}
/>
</Field>
<p className="small muted" style={{ margin: 0 }}>
흰 주사위 &quot;켜기&quot;는 5명일 때 쓸 수 없어요(5색을 모두 쓰기 때문이에요).
</p>
</div>
);
}
function optionsSummary(o: LasVegasOptions): string {
return [
`${o.rounds}라운드`,
o.neutralDice === 'auto' ? '흰 주사위 자동' : o.neutralDice === 'on' ? '흰 주사위 켜기' : '흰 주사위 끄기',
o.hideMoney ? '금액 숨김' : '금액 공개',
o.turnSeconds === null ? '시간 제한 없음' : `${o.turnSeconds}초`,
].join(' · ');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: LasVegasOptions }) {
const rounds = options?.rounds ?? 4;
return (
<div className="stack">
<style>{CSS}</style>
<p>
1번부터 6번까지 카지노 6곳에 놀이용 지폐가 놓여 있어요(카지노마다 합이 5점 이상이 될 때까지). 각자 자기 색 주사위 <strong>8개</strong>를 가지고
시작해요.
</p>
<p>
내 차례에는 남은 주사위를 <strong>모두 굴린 뒤</strong>, 나온 숫자 하나를 골라 그 숫자가 나온 주사위를 <strong>한꺼번에</strong> 같은 번호 카지노에
놓아요. 일부만 놓을 수는 없어요. 주사위를 다 쓴 사람은 차례를 건너뛰어요.
</p>
<p>
모두 주사위를 다 놓으면 정산해요. 카지노마다 주사위가 가장 많은 사람이 가장 큰 지폐, 다음 사람이 그다음 지폐를 가져가요. 단,{' '}
<strong>개수가 같은 사람끼리는 모두 무효</strong>(꽝)예요.
</p>
<div className="row" style={{ gap: 6 }}>
<Bill note={8} />
<Bill note={3} />
<Bill note={1} />
<span className="small">가 놓인 카지노에 A 5개 · B 3개 · C 3개 · D 1개 → B와 C는 꽝, A가 8, D가 3을 받아요.</span>
</div>
<p>
<strong>흰 주사위</strong>(중립): 2명일 때 기본으로 각자 4개씩 더 받아요. 같은 숫자가 나오면 내 주사위와 함께 놓이고, 흰 주사위는 &quot;하우스&quot;라는
가상의 한 사람으로 쳐요. 하우스와 개수가 같아도 꽝이에요. 3명이 켜고 하면 남는 흰 주사위 2개를 라운드마다 선 플레이어가 굴려 바로 놓아요.
</p>
<p>{rounds}라운드가 끝나면 받은 지폐 합이 가장 큰 사람이 이겨요. 같으면 지폐 장 수가 많은 사람이 이겨요. 라운드마다 선은 다음 사람에게 넘어가요.</p>
<p className="muted">
시간 안에 굴리지 않으면 자동으로 굴리고, 시간 안에 고르지 않으면 개수가 가장 많은 숫자(같으면 지폐가 큰 카지노, 그래도 같으면 작은 숫자)에 자동으로
놓아요.
</p>
<p className="muted small">지폐는 이 방에서만 쓰는 놀이용 점수예요. 게임이 끝나면 사라지고, 실제 화폐나 상품과 바꿀 수 없어요.</p>
</div>
);
}
const ui: GameUI<LasVegasView, LasVegasOptions> = {
Board: LasVegasBoard,
Side: LasVegasSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;