Merge branch 'm7/ninety-nine' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:13 +09:00
10 changed files with 2379 additions and 0 deletions

View File

@@ -0,0 +1,855 @@
/** 구십구 화면 (docs/games/ninety-nine.md §9). */
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
import type { CardFace, CardKind, ChoiceView, HandCardView, LogEntry, NNOptions, NinetyNineView, SeatView } from '@bg/games/ninety-nine';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { TurnTimer } from '../../components/TurnTimer';
import { PlayingCard } from '../../components/cards/PlayingCard';
import { haptic, sounds } from '../../lib/feedback';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const MODE_KO: Record<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
const SUIT_KO: Record<string, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
const SUIT_SYMBOL: Record<string, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
const TRUMP_RANK: Record<string, string> = { '10': 'T' };
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 resolvedLives = (o: NNOptions) => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
/** Short name, e.g. "±9", "조커", "♥K". */
function faceName(f: CardFace): string {
return f.suit ? `${SUIT_SYMBOL[f.suit]}${f.label}` : f.label;
}
/** Screen-reader name, e.g. "하트 K", "±9 카드". */
function ariaFace(f: CardFace): string {
if (f.suit) return `${SUIT_KO[f.suit]} ${f.label}`;
return f.label === '조커' ? '조커' : `${f.label} 카드`;
}
const fmt = (n: number) => String(n);
/** "−9" etc. use a real minus sign; "+9 → 95". */
const arrow = (c: ChoiceView) => `${c.label} → ${c.legal ? fmt(c.total) : c.total > 99 ? '99 초과' : '0 미만'}`;
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
}
/** Counts up/down to `target` (instant when reduced motion is preferred). */
function useCountUp(target: number): number {
const [shown, setShown] = useState(target);
const from = useRef(target);
useEffect(() => {
const start = from.current;
from.current = target;
if (start === target || prefersReducedMotion()) {
setShown(target);
return;
}
let raf = 0;
const t0 = performance.now();
const step = (t: number) => {
const k = Math.min(1, (t - t0) / 420);
setShown(Math.round(start + (target - start) * (1 - (1 - k) * (1 - k))));
if (k < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [target]);
return shown;
}
/** Green → yellow → red as the total nears 99. */
const gaugeColor = (t: number) => `hsl(${Math.max(0, 130 - (t / 99) * 130)} 78% 46%)`;
const CSS = `
.nn { width: 100%; max-width: 1060px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
.nn-opps { display: flex; gap: 8px; overflow-x: auto; padding: 4px 2px 6px; margin: 0; list-style: none; scrollbar-width: thin; }
.nn-seat { flex: none; min-width: 118px; max-width: 176px; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px;
border: 3px solid transparent; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 3px; font-size: .9rem; transition: filter .4s ease, opacity .4s ease; }
.nn-seat.turn { border-color: var(--focus); }
.nn-seat.out { filter: grayscale(1); opacity: .6; }
.nn-seat .nm { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
.nn-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; }
.nn-count i { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #7c2d12; border: 1.5px solid #fdba74; }
.nn-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .76rem; line-height: 1.6; color: #fff; }
.nn-hearts { display: inline-flex; gap: 1px; font-size: 1rem; line-height: 1; }
.nn-heart { color: #e11d48; display: inline-block; }
.nn-heart.lost { color: var(--text-sub); opacity: .55; animation: nn-break .7s ease; }
.nn-table { position: relative; border-radius: 24px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt);
border: 8px solid var(--wood); box-shadow: var(--shadow); padding: 14px 18px; display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: center; color: #fff; }
.nn-left, .nn-right { display: flex; flex-direction: column; gap: 8px; min-width: 0; text-shadow: 0 1px 2px rgba(0,0,0,.5); }
.nn-left { align-items: flex-start; }
.nn-right { align-items: flex-end; }
.nn-dial { position: relative; width: var(--dial); height: var(--dial); display: grid; place-items: center; }
.nn-dial svg { position: absolute; inset: 0; transform: rotate(-90deg); }
.nn-dial svg circle { transition: stroke-dashoffset .4s ease, stroke .4s ease; }
.nn-total { font-size: calc(var(--dial) * .42); font-weight: 900; font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: -.02em; color: #fff; text-shadow: 0 2px 3px rgba(0,0,0,.35); }
.nn-total-cap { position: absolute; bottom: calc(var(--dial) * .2); font-size: .8rem; font-weight: 700; opacity: .9; }
.nn-dir { display: inline-flex; align-items: center; gap: 6px; font-weight: 800; background: rgba(0,0,0,.3); border-radius: 999px; padding: 3px 12px 3px 6px; }
.nn-dir b { display: inline-block; font-size: 1.6rem; line-height: 1; transition: transform .45s ease; }
.nn-dir[data-dir='-1'] b { transform: scaleX(-1) rotate(-30deg); }
.nn-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.32); border-radius: 999px; padding: 2px 10px; font-weight: 700; font-size: .9rem; }
.nn-rule { font-weight: 800; color: #fde68a; }
.nn-recent { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
.nn-recent li { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.28); border-radius: 10px; padding: 2px 8px; font-size: .88rem; max-width: 100%; }
.nn-recent li:last-child { background: rgba(0,0,0,.5); font-weight: 800; animation: nn-pop .3s ease; }
.nn-recent .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
.nn-mini { display: inline-grid; place-items: center; min-width: 30px; height: 22px; border-radius: 5px; background: #fffef9; color: #1f2937; font-weight: 900; font-size: .8rem; padding: 0 4px; text-shadow: none; }
.nn-mini[data-k='plusMinus'] { background: #ede9fe; color: #5b21b6; }
.nn-mini[data-k='reverse'] { background: #ccfbf1; color: #115e59; }
.nn-mini[data-k='joker'], .nn-mini[data-k='to99'] { background: #fef3c7; color: #92400e; }
.nn-mini[data-red='true'] { color: #dc2626; }
.nn-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
.nn-hand-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
.nn-hand { display: flex; gap: 10px; justify-content: center; padding-top: 30px; flex-wrap: wrap; row-gap: 34px; }
.nn-card { position: relative; flex: none; width: var(--cw); height: calc(var(--cw) * 1.4); padding: 0; border: 0; background: none; border-radius: 10px;
transition: transform .14s ease, opacity .14s ease; cursor: pointer; }
.nn-card[data-legal='false'] { opacity: .5; filter: grayscale(.5); }
.nn-card[data-legal='true'] { box-shadow: 0 0 0 3px var(--success); }
.nn-card[aria-pressed='true'] { transform: translateY(-10px); box-shadow: 0 0 0 4px var(--primary); z-index: 2; }
.nn-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; z-index: 3; }
.nn-prev { position: absolute; left: 50%; top: -27px; transform: translateX(-50%); white-space: nowrap; border-radius: 999px; padding: 1px 9px;
font-size: .82rem; font-weight: 800; background: var(--surface-2); color: var(--text); border: 1px solid var(--border); pointer-events: none; }
.nn-prev.ok { background: var(--success-soft); color: var(--success); border-color: var(--success); }
.nn-prev.no { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); }
.nn-face { display: block; width: 100%; height: 100%; }
.nn-face svg, .nn-face > span:not(.nn-art) { width: 100% !important; height: 100% !important; display: block; }
.nn-art { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; border-radius: 10px;
background: #fffef9; border: 2px solid #9ca3af; color: #1f2937; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.nn-art::before { content: ''; position: absolute; inset: 6px; border-radius: 7px; border: 2px solid var(--band); opacity: .55; }
.nn-art .big { font-size: calc(var(--cw, 90px) * .44); font-weight: 900; line-height: 1; color: var(--band); letter-spacing: -.03em; }
.nn-art .sub { margin-top: 4px; font-size: calc(var(--cw, 90px) * .13); font-weight: 800; color: var(--band); opacity: .9; }
.nn-art .corner { position: absolute; top: 5px; left: 8px; font-size: calc(var(--cw, 90px) * .17); font-weight: 900; color: var(--band); }
.nn-art .corner.br { top: auto; left: auto; bottom: 5px; right: 8px; transform: rotate(180deg); }
.nn-art[data-k='add'] { --band: #1d4ed8; }
.nn-art[data-k='add'][data-big='true'] { --band: #b91c1c; }
.nn-art[data-k='plusMinus'] { --band: #6d28d9; background: linear-gradient(160deg, #fffef9 55%, #ede9fe); }
.nn-art[data-k='reverse'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
.nn-art[data-k='joker'] { --band: #b45309; background: radial-gradient(circle at 50% 40%, #fef3c7, #fffef9 70%); }
.nn-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
.nn-pick .btn { flex: 1 1 140px; max-width: 280px; font-size: 1.1rem; font-weight: 800; }
.nn-joker { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
.nn-joker .val { min-width: 84px; text-align: center; font-size: 2rem; font-weight: 900; font-variant-numeric: tabular-nums; border-radius: 12px;
border: 2px solid var(--border); padding: 2px 8px; background: var(--surface-2); color: var(--text); }
.nn-joker .val:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.nn-joker .step { width: 52px; min-width: 52px; padding: 0; font-size: 1.5rem; font-weight: 900; }
.nn-joker .quick { min-width: 60px; }
.nn-quick { display: inline-flex; gap: 8px; }
.nn-joker .btn[aria-pressed='true'] { border-color: var(--primary); background: var(--primary); color: var(--primary-text); }
.nn-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 6px; }
.nn-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 240px; overflow: auto; }
.nn-rules td, .nn-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }
.nn-rules .nn-face { width: 34px; height: 48px; --cw: 34px; }
.nn-rules .nn-art::before { inset: 3px; }
@keyframes nn-pop { 0% { transform: scale(.7); } 70% { transform: scale(1.08); } 100% { transform: scale(1); } }
@keyframes nn-break { 0% { transform: scale(1); color: #e11d48; opacity: 1; } 40% { transform: scale(1.5) rotate(-15deg); } 100% { transform: scale(1); } }
@media (max-width: 719px) {
.nn-table { grid-template-columns: 1fr auto; grid-template-areas: 'dial left' 'right right'; padding: 10px; gap: 8px 10px; border-width: 6px; }
.nn-dial { grid-area: dial; }
.nn-left { grid-area: left; }
.nn-right { grid-area: right; align-items: stretch; }
.nn-recent { flex-direction: row; flex-wrap: wrap; justify-content: center; }
.nn-recent li:not(:nth-last-child(-n+3)) { display: none; }
.nn-seat { min-width: 100px; padding: 5px 6px; }
.nn-hand { gap: 8px; }
.nn-quick { flex-basis: 100%; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.nn-card, .nn-dir b, .nn-dial svg circle, .nn-seat { transition: none; }
}
`;
// ---------------------------------------------------------------- cards
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' };
function trumpId(f: CardFace): string {
return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`;
}
function Face({ face, kind, jokerRange }: { face: CardFace; kind: CardKind; jokerRange?: { min: number; max: number } }) {
if (face.suit) {
return (
<span className="nn-face" aria-hidden>
<PlayingCard card={trumpId(face)} size="lg" style={{ width: '100%', height: '100%', filter: 'none' }} />
</span>
);
}
const sub = kind === 'joker' ? (jokerRange ? `${jokerRange.min}~${jokerRange.max}` : '원하는 수') : (KR_SUB[kind] ?? '');
const big = kind === 'joker' ? '★' : face.label;
return (
<span className="nn-face" aria-hidden>
<span className="nn-art" data-k={kind} data-big={kind === 'add' && face.label === '10'}>
<span className="corner">{kind === 'joker' ? '조커' : face.label}</span>
<span className="big">{big}</span>
{sub && <span className="sub">{sub}</span>}
<span className="corner br">{kind === 'joker' ? '조커' : face.label}</span>
</span>
</span>
);
}
function Mini({ face, kind }: { face: CardFace; kind: CardKind }) {
return (
<span className="nn-mini" data-k={kind} data-red={face.suit === 'H' || face.suit === 'D'} aria-hidden>
{faceName(face)}
</span>
);
}
// ---------------------------------------------------------------- seats
function Hearts({ s, start }: { s: SeatView; start: number }) {
if (start <= 0) return null;
return (
<span className="nn-hearts" aria-hidden>
{Array.from({ length: start }, (_, i) => (
<span key={i} className={`nn-heart${i >= s.lives ? ' lost' : ''}`}>
{i >= s.lives ? '♡' : '♥'}
</span>
))}
</span>
);
}
function seatLabel(s: SeatView, v: NinetyNineView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
if (!s.alive) parts.push(s.left ? '나감' : '탈락');
else parts.push(`카드 ${s.cards}장`);
if (v.livesStart > 0) parts.push(`목숨 ${s.lives}개`);
if (s.onBoard && s.alive) parts.push('목숨 없이 마지막 기회');
if (s.away) parts.push('자리 비움');
if (s.isTurn) parts.push('차례');
return parts.join(', ');
}
function SeatChip({ s, v, players, me, deadline }: { s: SeatView; v: NinetyNineView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
const u = userOf(players, s.id);
const rank = v.result?.ranking.find((r) => r.player === s.id)?.rank;
return (
<li className={`nn-seat${s.isTurn ? ' turn' : ''}${s.alive ? '' : ' out'}`} aria-label={seatLabel(s, v, players, me)}>
<div className="row" style={{ gap: 6, flexWrap: 'nowrap' }}>
<Avatar user={u} size={24} />
<span className="nm">{u.nickname}</span>
{s.isTurn && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={s.id === me} size={28} />}
</div>
<div className="row" style={{ gap: 6 }} aria-hidden>
{s.alive ? (
<span className="nn-count">
<i />
{s.cards}
</span>
) : (
<span className="nn-badge" style={{ background: s.left ? 'var(--text-sub)' : 'var(--danger)' }}>
{s.left ? '나감' : '탈락'}
</span>
)}
<Hearts s={s} start={v.livesStart} />
{s.onBoard && s.alive && <span className="nn-badge" style={{ background: 'var(--warning)' }}>마지막 기회</span>}
{s.away && <span className="nn-badge" style={{ background: 'var(--warning)' }}>자리 비움</span>}
{rank && <span className="nn-badge" style={{ background: rank === 1 ? 'var(--success)' : 'var(--text-sub)' }}>{rank}등</span>}
</div>
</li>
);
}
// ---------------------------------------------------------------- table
function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Record<string, PublicUser>; me: string | null; deadline: number | null }) {
const shown = useCountUp(v.total);
const dial = 'clamp(150px, 20vw, 196px)';
const r = 44;
const c = 2 * Math.PI * r;
const frac = Math.min(1, v.total / 99);
const cur = v.current;
return (
<div className="nn-table" role="region" aria-label="카드 판" style={{ '--dial': dial } as CSSProperties}>
<div className="nn-left">
<span className="nn-dir" data-dir={v.direction} role="img" aria-label={v.direction === 1 ? '진행 방향: 시계 방향' : '진행 방향: 반대 방향'}>
<b aria-hidden>↻</b>
{v.direction === 1 ? '시계 방향' : '반대 방향'}
</span>
{cur && (
<span className="nn-chip">
{cur === me ? '내 차례' : `${nick(players, cur)}님 차례`}
<TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={cur === me} size={40} />
</span>
)}
<span className="nn-chip" aria-label={`뽑을 카드 ${v.drawPileCount}장`}>
더미 {v.drawPileCount}장
</span>
{v.livesStart > 0 && <span className="nn-chip">{v.round}판째</span>}
</div>
<div className="nn-dial" role="img" aria-label={`지금 합계 ${v.total}`}>
<svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden>
<circle cx="50" cy="50" r={r} fill="rgba(0,0,0,.28)" stroke="rgba(255,255,255,.18)" strokeWidth="8" />
<circle cx="50" cy="50" r={r} fill="none" stroke={gaugeColor(v.total)} strokeWidth="8" strokeLinecap="round" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} />
</svg>
<span className="nn-total" aria-hidden>
{shown}
</span>
<span className="nn-total-cap" aria-hidden>
/ 99
</span>
</div>
<div className="nn-right">
<span className="nn-rule">99를 넘기면 안 돼요!</span>
{v.recent.length > 0 ? (
<ol className="nn-recent" aria-label="최근에 낸 카드">
{v.recent.slice(-4).map((e) => (
<li key={`${e.seq}-${e.p}`}>
<span className="who">{nick(players, e.p)}</span>
<Mini face={e.face} kind={e.kind} />
{e.choice && e.kind !== 'joker' ? <span>{e.choice}</span> : null}
<span aria-hidden>→</span>
<strong>{e.total}</strong>
</li>
))}
</ol>
) : (
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------- my hand
function cardAria(c: HandCardView, myTurn: boolean): string {
const name = ariaFace(c.face);
if (c.joker) return `${name}, ${c.joker.min}부터 ${c.joker.max} 사이 수로 만들어요`;
const parts = c.choices.map((x) => (c.choices.length > 1 ? `${x.label}면 ` : '내면 ') + (x.legal ? `${x.total}` : x.total > 99 ? `${x.total}이라 낼 수 없음` : '0 미만이라 낼 수 없음'));
return `${name}, ${parts.join(', ')}${myTurn ? '' : ' (내 차례가 아니에요)'}`;
}
function previewOf(c: HandCardView): { text: string; cls: string } {
if (c.joker) return { text: `${c.joker.min}~${c.joker.max}`, cls: 'ok' };
if (c.badge) return { text: c.badge, cls: 'no' };
const ok = c.choices.filter((x) => x.legal);
if (ok.length === 1) return { text: `→ ${ok[0]!.total}`, cls: 'ok' };
return { text: ok.map((x) => `${x.total}`).join(' / '), cls: 'ok' };
}
function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) {
const { min, max } = c.joker!;
const [n, setN] = useState(() => Math.min(max, Math.max(min, total)));
const clamp = (x: number) => Math.min(max, Math.max(min, x));
const quick = [...new Set([min, 90, 99].filter((x) => x >= min && x <= max))];
const onKey = (e: KeyboardEvent<HTMLSpanElement>) => {
const d: Record<string, number> = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: 10, PageDown: -10 };
if (e.key in d) setN(clamp(n + d[e.key]!));
else if (e.key === 'Home') setN(min);
else if (e.key === 'End') setN(max);
else if (e.key === 'Enter') onPlay(n);
else return;
e.preventDefault();
};
return (
<div className="stack" style={{ gap: 8 }}>
<div className="nn-joker" role="group" aria-label="조커로 만들 수 고르기">
<button type="button" className="btn btn-secondary step" aria-label="1 작게" disabled={n <= min} onClick={() => setN(clamp(n - 1))}>
−
</button>
<span className="val" role="spinbutton" tabIndex={0} aria-label="조커로 만들 수" aria-valuemin={min} aria-valuemax={max} aria-valuenow={n} onKeyDown={onKey}>
{n}
</span>
<button type="button" className="btn btn-secondary step" aria-label="1 크게" disabled={n >= max} onClick={() => setN(clamp(n + 1))}>
+
</button>
<span className="nn-quick" role="group" aria-label="빠른 선택">
{quick.map((q) => (
<button key={q} type="button" className="btn btn-secondary quick" aria-pressed={n === q} onClick={() => setN(q)}>
{q}
</button>
))}
</span>
</div>
<div className="nn-pick">
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => onPlay(n)}>
조커 내기 → {n}
</button>
</div>
</div>
);
}
function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => void; pending: boolean }) {
const my = v.my!;
const [sel, setSel] = useState<string | null>(null);
const [note, setNote] = useState('');
const myTurn = my.myTurn;
useEffect(() => {
setSel(null);
setNote('');
}, [v.turnNo]);
const chosen = my.hand.find((c) => c.id === sel) ?? null;
const send = (c: HandCardView, extra: Record<string, unknown> = {}) => {
if (pending) return;
act({ type: 'play', cardId: c.id, ...extra });
setSel(null);
};
const single = (c: HandCardView) => c.choices.length === 1 && !c.joker;
const tap = (c: HandCardView) => {
if (!myTurn) {
setNote('지금은 내 차례가 아니에요. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.');
setSel(sel === c.id ? null : c.id);
return;
}
if (!c.legal) {
setNote(`${ariaFace(c.face)}은(는) ${c.badge === '0 미만' ? '0보다 작아져서' : '99를 넘어서'} 낼 수 없어요.`);
setSel(null);
return;
}
setNote('');
if (sel === c.id) {
if (single(c)) send(c);
else setSel(null);
return;
}
setSel(c.id);
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
setSel(null);
return;
}
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
const btns = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('button.nn-card'));
const i = btns.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return;
e.preventDefault();
btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus();
};
const n = my.hand.length;
const cw = n <= 3 ? 'clamp(84px, 24vw, 104px)' : n === 4 ? 'clamp(72px, 20vw, 96px)' : 'clamp(62px, 16.5vw, 90px)';
const meSeat = v.seats.find((s) => s.id === v.me);
const legalCount = my.hand.filter((c) => c.legal).length;
return (
<div className="nn-hand-wrap">
<div className="nn-hand-head">
<strong>
내 카드 {n}장{myTurn ? ` · 낼 수 있는 카드 ${legalCount}장` : ''}
</strong>
{meSeat && v.livesStart > 0 && (
<span className="row small" style={{ gap: 6 }} aria-label={`내 목숨 ${meSeat.lives}개`}>
목숨 <Hearts s={meSeat} start={v.livesStart} />
</span>
)}
</div>
<div className="nn-hand" role="group" aria-label="내 카드. 좌우 방향키로 고르고 Enter로 고른 뒤 한 번 더 누르면 내요" onKeyDown={onKey} style={{ '--cw': cw } as CSSProperties}>
{my.hand.map((c) => {
const p = previewOf(c);
return (
<button
key={c.id}
type="button"
className="nn-card"
data-legal={myTurn ? c.legal : undefined}
aria-pressed={sel === c.id}
aria-label={`${cardAria(c, myTurn)}${sel === c.id && myTurn && single(c) ? '. 한 번 더 누르면 내요' : ''}`}
onClick={() => tap(c)}
>
<span className={`nn-prev ${p.cls}`} aria-hidden>
{p.text}
</span>
<Face face={c.face} kind={c.kind} jokerRange={c.joker ?? undefined} />
</button>
);
})}
{n === 0 && <span className="muted">손에 카드가 없어요.</span>}
</div>
{note && (
<p className="small" style={{ margin: 0 }} aria-live="polite">
{note}
</p>
)}
{chosen && myTurn && chosen.legal && (
<div className="stack" style={{ gap: 6 }}>
{chosen.joker ? (
<JokerDial key={chosen.id} c={chosen} total={v.total} pending={pending} onPlay={(jokerValue) => send(chosen, { jokerValue })} />
) : (
<div className="nn-pick" role="group" aria-label={`${ariaFace(chosen.face)} 내는 방법`}>
{chosen.choices.map((x) => (
<button
key={x.label}
type="button"
className="btn btn-primary"
disabled={!x.legal || pending}
aria-label={x.legal ? `${x.label}로 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) })}
>
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
</button>
))}
</div>
)}
</div>
)}
{myTurn && !chosen && <p className="small muted" style={{ margin: 0 }}>카드를 누르면 낼 때 합계가 보여요. 한 번 더 누르면 내요.</p>}
</div>
);
}
// ---------------------------------------------------------------- log
function logText(e: LogEntry, players: Record<string, PublicUser>): string {
const n = (id: string) => nick(players, id);
switch (e.t) {
case 'play': {
const how = e.kind === 'joker' ? `${e.total}로 만들었어요` : e.kind === 'reverse' ? `방향을 바꿨어요(${e.total} 그대로)` : e.kind === 'hold' ? `그대로 넘겼어요(${e.total})` : `${e.from} → ${e.total}`;
return `${n(e.p)}: ${faceName(e.face)}${e.choice && e.kind !== 'joker' ? `(${e.choice})` : ''} ${how}${e.auto ? ' · 자동' : ''}`;
}
case 'noCard':
return `${n(e.p)}님은 낼 수 있는 카드가 없어요`;
case 'life':
return `${n(e.p)}님이 목숨 1개를 잃었어요(남은 목숨 ${e.left}개)`;
case 'out':
return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`;
case 'round':
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`;
case 'reshuffle':
return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`;
case 'timeout':
return `${n(e.p)}님 시간 초과 · 가장 작은 수가 되는 카드를 자동으로 냈어요`;
}
}
// ---------------------------------------------------------------- board
function Board({ view: v, me, players, act, pending, deadline }: BoardProps<NinetyNineView>) {
const [announce, setAnnounce] = useState('');
const mine = isMyTurn(v, me);
const prevMine = useRef(false);
const last = v.log[v.log.length - 1];
useEffect(() => {
if (mine && !prevMine.current) {
haptic(60);
sounds.myTurn();
setAnnounce(`내 차례예요. 지금 합계 ${v.total}`);
}
prevMine.current = mine;
}, [mine]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (last && !mine) setAnnounce(logText(last, players));
}, [last?.seq, last?.t]); // eslint-disable-line react-hooks/exhaustive-deps
const myIdx = v.seats.findIndex((s) => s.id === me);
const others = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx + 1), ...v.seats.slice(0, myIdx)];
const meSeat = v.seats.find((s) => s.id === me);
return (
<div className="nn">
<style>{CSS}</style>
<ul className="nn-opps" aria-label="다른 사람">
{others.map((s) => (
<SeatChip key={s.id} s={s} v={v} players={players} me={me} deadline={deadline} />
))}
</ul>
<Table v={v} players={players} me={me} deadline={deadline} />
{v.my && meSeat?.alive && v.phase === 'playing' && <MyHand v={v} act={act} pending={pending} />}
{meSeat && !meSeat.alive && v.phase === 'playing' && (
<div className="nn-panel">
<strong>{meSeat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function rulesLine(o: NNOptions): string {
const lives = resolvedLives(o);
const parts = [`손패 ${o.handSize}장`, o.refill ? '내고 1장 보충' : '보충 없음', lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락'];
if (lives === 0) parts.push(o.afterElimination === 'reset' ? '탈락 뒤 0부터' : '탈락 뒤 합계 그대로');
parts.push(o.floorAtZero === 'forbid' ? '0 미만 빼기 안 됨' : '0 미만은 0');
if (o.mode === 'kr-product') parts.push(`조커 ${o.jokerRange === '0-99' ? '0~99' : '60~99'}`);
else parts.push(`A는 1 또는 ${o.aceHigh}`);
return parts.join(' · ');
}
function Side({ view: v, me, players, deadline }: BoardProps<NinetyNineView>) {
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}>
<strong>
구십구 · {MODE_KO[v.mode]}
{v.livesStart > 0 ? ` · ${v.round}판` : ''}
{v.phase === 'finished' ? ' · 게임 끝' : ''}
</strong>
{v.current && <TurnTimer deadline={deadline} totalMs={v.deadlineTotalMs} mine={isMyTurn(v, me)} size={36} />}
</div>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.alive ? 1 : 0.55 }} aria-label={seatLabel(s, v, players, me)}>
<span style={{ filter: s.alive ? 'none' : 'grayscale(1)', display: 'inline-flex' }}>
<Avatar user={userOf(players, s.id)} size={24} />
</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{s.isTurn ? '▶ ' : ''}
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{!s.alive ? (s.left ? ' · 나감' : ' · 탈락') : s.away ? ' · 자리 비움' : ''}
</span>
<Hearts s={s} start={v.livesStart} />
{s.alive && (
<span className="nn-count" aria-hidden>
<i />
{s.cards}
</span>
)}
</li>
))}
</ol>
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.player}>
{r.rank}등 {nick(players, r.player)}
<span className="muted small">
{r.eliminatedAtRound === null ? ' · 끝까지 살아남음' : r.left ? ' · 나감' : v.livesStart > 0 ? ` · ${r.eliminatedAtRound}판에서 탈락` : ' · 탈락'}
{v.livesStart > 0 ? ` · 잃은 목숨 ${r.livesLost}개` : ''}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="nn-log">
{[...v.log].reverse().map((e, i) => (
<li key={`${e.seq}-${e.t}-${i}`} className="small">
{logText(e, players)}
</li>
))}
</ol>
</details>
)}
<details className="card" style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>특수 카드</summary>
<SpecialTable o={v.options} />
</details>
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: NinetyNineView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.current === me;
}
function statusText(v: NinetyNineView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요';
if (isMyTurn(v, me)) return `내 차례예요 · 지금 합계 ${v.total}`;
const meSeat = v.seats.find((s) => s.id === me);
const who = v.current ? `${nick(players, v.current)}님 차례예요 · 합계 ${v.total}` : '잠시만 기다려 주세요';
return meSeat && !meSeat.alive && !meSeat.left ? `탈락했어요 · ${who}` : who;
}
// ---------------------------------------------------------------- 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, children, help }: { label: string; children: ReactNode; help?: string }) {
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<NNOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<NNOptions>) => onChange({ ...o, ...patch });
const kr = o.mode === 'kr-product';
const lives = resolvedLives(o);
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="규칙" help={kr ? '1~8·10 숫자 카드와 ±9·±10·0·조커 전용 카드로 해요.' : '트럼프 카드로 해요. K는 99, 4는 방향 바꾸기, 9는 그대로, 10은 빼기 10.'}>
<Seg label="규칙" value={o.mode} options={[['kr-product', '전용 카드'], ['classic', '트럼프 카드']]} onChange={(mode) => set({ mode })} />
</Field>
<Field label="손패 장수">
<Seg label="손패 장수" value={o.handSize} options={[[3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
</Field>
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
<Seg
label="목숨"
value={o.lives}
options={[['auto', kr ? '없음(기본)' : '3개(기본)'], [0, '없음'], [1, '1개'], [2, '2개'], [3, '3개'], [5, '5개']]}
onChange={(v) => set({ lives: v })}
/>
</Field>
<Field label="한 턴 제한 시간">
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
</Field>
{kr && (
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 4 })}>
어린이 추천 설정(조커 0~99, 손패 4장)
</button>
)}
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드를 낸 뒤 1장 보충">
<Seg label="카드를 낸 뒤 1장 보충" value={o.refill} options={[[true, '보충해요'], [false, '보충 안 해요']]} onChange={(refill) => set({ refill })} />
</Field>
<Field label="목숨을 다 잃어도 한 번 더" help="켜면 목숨이 0개여도 남고, 한 번 더 지면 탈락해요.">
<Seg label="목숨을 다 잃어도 한 번 더" value={o.onTheBoard} options={[[false, '끄기'], [true, '켜기']]} onChange={(onTheBoard) => set({ onTheBoard })} />
</Field>
<Field label="(목숨 없음) 탈락 뒤 합계">
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', '0으로 하고 다시 나누기']]} onChange={(afterElimination) => set({ afterElimination })} />
</Field>
<Field label="빼서 0보다 작아질 때">
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
</Field>
{kr ? (
<>
<Field label="±9·±10·0 카드 장수(각각)">
<Seg label="특수 카드 장수" value={o.specialCounts} options={[[2, '2장'], [3, '3장'], [4, '4장'], [5, '5장'], [6, '6장']]} onChange={(specialCounts) => set({ specialCounts })} />
</Field>
<Field label="조커로 부를 수 있는 수">
<Seg label="조커로 부를 수 있는 수" value={o.jokerRange} options={[['60-99', '60~99'], ['0-99', '0~99(어린이용)']]} onChange={(jokerRange) => set({ jokerRange })} />
</Field>
</>
) : (
<>
<Field label="A의 큰 값">
<Seg label="A의 큰 값" value={o.aceHigh} options={[[11, '11'], [10, '10']]} onChange={(aceHigh) => set({ aceHigh })} />
</Field>
<Field label="덱 수">
<Seg label="덱 수" value={o.deckCount} options={[['auto', '자동(7명부터 2벌)'], [1, '1벌'], [2, '2벌']]} onChange={(deckCount) => set({ deckCount })} />
</Field>
</>
)}
</>
)}
</div>
);
}
function optionsSummary(o: NNOptions): string {
const lives = resolvedLives(o);
const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
if (!o.refill) parts.push('보충 없음');
if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99');
parts.push(`${o.turnSeconds}초`);
return parts.join(' · ');
}
// ---------------------------------------------------------------- rules
function SpecialTable({ o }: { o: NNOptions }) {
const kr = o.mode === 'kr-product';
const C = (label: string, kind: CardKind, suit?: CardFace['suit']) => <Face face={suit ? { label, suit } : { label }} kind={kind} />;
const floor = o.floorAtZero === 'forbid' ? '0보다 작아지게는 못 빼요.' : '0보다 작아지면 0이 돼요.';
const rows: [ReactNode, string, string][] = kr
? [
[C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'],
[C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`],
[C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`],
[C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
[C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`],
]
: [
[C('A', 'ace', 'S'), 'A', `1 또는 ${o.aceHigh}을 더해요(고를 수 있어요).`],
[C('4', 'reverse', 'H'), '4', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
[C('9', 'hold', 'C'), '9', '합계는 그대로 넘겨요.'],
[C('10', 'minus', 'D'), '10', `10을 빼요. ${floor}`],
[C('J', 'add', 'S'), 'J·Q', '10을 더해요.'],
[C('K', 'to99', 'H'), 'K', '합계를 바로 99로 만들어요.'],
];
return (
<table className="nn-rules small" style={{ borderCollapse: 'collapse', width: '100%', marginTop: 8 }}>
<tbody>
{rows.map(([pic, name, desc]) => (
<tr key={name}>
<td style={{ width: 44 }}>{pic}</td>
<th scope="row" style={{ whiteSpace: 'nowrap' }}>
{name}
</th>
<td>{desc}</td>
</tr>
))}
</tbody>
</table>
);
}
function Rules({ options }: { options?: NNOptions }) {
const o: NNOptions = {
mode: 'kr-product',
handSize: 3,
refill: true,
lives: 'auto',
onTheBoard: false,
afterElimination: 'continue',
floorAtZero: 'forbid',
specialCounts: 4,
jokerRange: '60-99',
aceHigh: 11,
deckCount: 'auto',
turnSeconds: 15,
...options,
};
const lives = resolvedLives(o);
return (
<div className="stack">
<p>
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
</p>
<p>
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
</p>
<p>
<strong>낼 카드가 없으면</strong>:{' '}
{lives > 0
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
: o.afterElimination === 'reset'
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
마지막까지 남은 1명이 이겨요.
</p>
<SpecialTable o={o} />
<p className="small muted">
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
</p>
</div>
);
}
const ui: GameUI<NinetyNineView, NNOptions> = {
Board,
Side,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -24,6 +24,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
quoridor: () => import('./quoridor'),
'las-vegas': () => import('./las-vegas'),
'cockroach-poker': () => import('./cockroach-poker'),
'ninety-nine': () => import('./ninety-nine'),
};
const cache = new Map<string, GameUI>();

View File

@@ -21,6 +21,7 @@ export const GAME_ICON: Record<string, string> = {
quoridor: '🧱',
'las-vegas': '⚅',
'cockroach-poker': '🐞',
'ninety-nine': '💯',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */