|
|
|
|
@@ -7,7 +7,7 @@ import { TurnTimer } from '../../components/TurnTimer';
|
|
|
|
|
import { PlayingCard } from '../../components/cards/PlayingCard';
|
|
|
|
|
import { haptic, sounds } from '../../lib/feedback';
|
|
|
|
|
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
|
|
|
|
import { helpNow } from './help';
|
|
|
|
|
import { helpNow, resolvedHandSize } from './help';
|
|
|
|
|
import { quickStart } from './quick';
|
|
|
|
|
|
|
|
|
|
const MODE_KO: Record<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
|
|
|
|
|
@@ -18,6 +18,10 @@ 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);
|
|
|
|
|
/** Rounds start from the start card's number (2명 40 · 3명 20 · 4명 이상 0) rather than always 0. */
|
|
|
|
|
const startCard = (o: NNOptions) => o.mode === 'kr-product' && o.startTotal === 'auto';
|
|
|
|
|
/** Where a (new) round starts, in words: "시작 숫자" or "0". */
|
|
|
|
|
const startWord = (o: NNOptions) => (startCard(o) ? '시작 숫자' : '0');
|
|
|
|
|
|
|
|
|
|
/** Short name, e.g. "±9", "조커", "♥K". */
|
|
|
|
|
function faceName(f: CardFace): string {
|
|
|
|
|
@@ -98,7 +102,7 @@ const CSS = `
|
|
|
|
|
.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='reverse'], .nn-mini[data-k='zero'] { 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; }
|
|
|
|
|
@@ -126,7 +130,7 @@ const CSS = `
|
|
|
|
|
.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='reverse'], .nn-art[data-k='zero'] { --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; }
|
|
|
|
|
@@ -163,7 +167,7 @@ const CSS = `
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------- cards
|
|
|
|
|
|
|
|
|
|
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' };
|
|
|
|
|
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', zero: '방향 고르기', joker: '' };
|
|
|
|
|
|
|
|
|
|
function trumpId(f: CardFace): string {
|
|
|
|
|
return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`;
|
|
|
|
|
@@ -309,7 +313,7 @@ function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Recor
|
|
|
|
|
))}
|
|
|
|
|
</ol>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
|
|
|
|
|
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 ${v.total}` : v.total > 0 ? `시작 숫자 ${v.total} · 첫 카드를 기다려요` : '첫 카드를 기다려요'}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -328,9 +332,9 @@ function cardAria(c: HandCardView, myTurn: boolean): string {
|
|
|
|
|
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' };
|
|
|
|
|
const totals = [...new Set(c.choices.filter((x) => x.legal).map((x) => x.total))];
|
|
|
|
|
if (totals.length === 1) return { text: `→ ${totals[0]}`, cls: 'ok' };
|
|
|
|
|
return { text: totals.join(' / '), cls: 'ok' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) {
|
|
|
|
|
@@ -478,8 +482,8 @@ function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => v
|
|
|
|
|
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 } : {}) })}
|
|
|
|
|
aria-label={x.legal ? `${x.dir ? `${x.label}로 두고` : `${x.label}로`} 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
|
|
|
|
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}), ...(x.dir ? { dir: x.dir } : {}) })}
|
|
|
|
|
>
|
|
|
|
|
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
|
|
|
|
|
</button>
|
|
|
|
|
@@ -499,7 +503,16 @@ 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}`;
|
|
|
|
|
const how =
|
|
|
|
|
e.kind === 'joker'
|
|
|
|
|
? `${e.total}로 만들었어요`
|
|
|
|
|
: e.kind === 'reverse'
|
|
|
|
|
? `방향을 바꿨어요(${e.total} 그대로)`
|
|
|
|
|
: e.kind === 'zero'
|
|
|
|
|
? `합계 ${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':
|
|
|
|
|
@@ -509,7 +522,7 @@ function logText(e: LogEntry, players: Record<string, PublicUser>): string {
|
|
|
|
|
case 'out':
|
|
|
|
|
return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`;
|
|
|
|
|
case 'round':
|
|
|
|
|
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`;
|
|
|
|
|
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 ${e.total ?? 0}`;
|
|
|
|
|
case 'reshuffle':
|
|
|
|
|
return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`;
|
|
|
|
|
case 'timeout':
|
|
|
|
|
@@ -565,8 +578,13 @@ function Board({ view: v, me, players, act, pending, deadline }: BoardProps<Nine
|
|
|
|
|
|
|
|
|
|
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부터' : '탈락 뒤 합계 그대로');
|
|
|
|
|
const parts = [
|
|
|
|
|
`손패 ${resolvedHandSize(o)}장`,
|
|
|
|
|
startCard(o) ? '시작 숫자 2명 40 · 3명 20 · 4명 이상 0' : '0부터 시작',
|
|
|
|
|
o.refill ? '내고 1장 보충' : '보충 없음',
|
|
|
|
|
lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락',
|
|
|
|
|
];
|
|
|
|
|
if (lives === 0) parts.push(o.afterElimination === 'reset' ? `탈락 뒤 ${startWord(o)}부터` : '탈락 뒤 합계 그대로');
|
|
|
|
|
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}`);
|
|
|
|
|
@@ -694,9 +712,14 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
|
|
|
|
<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 })} />
|
|
|
|
|
<Seg label="손패 장수" value={o.handSize} options={[['auto', kr ? '5장(기본)' : '3장(기본)'], [3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
|
|
|
|
|
{kr && (
|
|
|
|
|
<Field label="시작 숫자" help={o.startTotal === 'auto' ? '가운데 시작 카드처럼 2명이면 40, 3명이면 20, 4명 이상이면 0에서 시작해요.' : '인원과 상관없이 0에서 시작해요.'}>
|
|
|
|
|
<Seg label="시작 숫자" value={o.startTotal} options={[['auto', '시작 카드(기본)'], ['zero', '항상 0']]} onChange={(startTotal) => set({ startTotal })} />
|
|
|
|
|
</Field>
|
|
|
|
|
)}
|
|
|
|
|
<Field label="목숨" help={lives > 0 ? `낼 카드가 없으면 목숨 1개를 잃고 ${startWord(o)}부터 새 판을 해요.` : '낼 카드가 없으면 바로 탈락해요.'}>
|
|
|
|
|
<Seg
|
|
|
|
|
label="목숨"
|
|
|
|
|
value={o.lives}
|
|
|
|
|
@@ -708,8 +731,8 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
|
|
|
|
<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 type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 'auto' })}>
|
|
|
|
|
어린이 추천 설정(조커 0~99, 손패 5장)
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
|
|
|
|
@@ -724,7 +747,7 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
|
|
|
|
<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 })} />
|
|
|
|
|
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', `${startWord(o)}${startCard(o) ? '로' : '으로'} 하고 다시 나누기`]]} onChange={(afterElimination) => set({ afterElimination })} />
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="빼서 0보다 작아질 때">
|
|
|
|
|
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
|
|
|
|
|
@@ -756,7 +779,8 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
|
|
|
|
|
|
|
|
|
function optionsSummary(o: NNOptions): string {
|
|
|
|
|
const lives = resolvedLives(o);
|
|
|
|
|
const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
|
|
|
|
const parts = [MODE_KO[o.mode], `손패 ${resolvedHandSize(o)}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
|
|
|
|
if (o.mode === 'kr-product' && o.startTotal === 'zero') parts.push('0부터 시작');
|
|
|
|
|
if (!o.refill) parts.push('보충 없음');
|
|
|
|
|
if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99');
|
|
|
|
|
parts.push(`${o.turnSeconds}초`);
|
|
|
|
|
@@ -774,7 +798,7 @@ function SpecialTable({ o }: { o: NNOptions }) {
|
|
|
|
|
[C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'],
|
|
|
|
|
[C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`],
|
|
|
|
|
[C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`],
|
|
|
|
|
[C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
|
|
|
|
|
[C('0', 'zero'), '0', '합계는 그대로예요. 진행 방향을 그대로 둘지 반대로 바꿀지 골라요.'],
|
|
|
|
|
[C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`],
|
|
|
|
|
]
|
|
|
|
|
: [
|
|
|
|
|
@@ -805,7 +829,8 @@ function SpecialTable({ o }: { o: NNOptions }) {
|
|
|
|
|
function Rules({ options }: { options?: NNOptions }) {
|
|
|
|
|
const o: NNOptions = {
|
|
|
|
|
mode: 'kr-product',
|
|
|
|
|
handSize: 3,
|
|
|
|
|
handSize: 'auto',
|
|
|
|
|
startTotal: 'auto',
|
|
|
|
|
refill: true,
|
|
|
|
|
lives: 'auto',
|
|
|
|
|
onTheBoard: false,
|
|
|
|
|
@@ -819,26 +844,28 @@ function Rules({ options }: { options?: NNOptions }) {
|
|
|
|
|
...options,
|
|
|
|
|
};
|
|
|
|
|
const lives = resolvedLives(o);
|
|
|
|
|
const start = startWord(o);
|
|
|
|
|
return (
|
|
|
|
|
<div className="stack">
|
|
|
|
|
<p>
|
|
|
|
|
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
|
|
|
|
|
</p>
|
|
|
|
|
<p>
|
|
|
|
|
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
|
|
|
|
|
처음에 {resolvedHandSize(o)}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).{' '}
|
|
|
|
|
{startCard(o) ? '합계는 가운데 시작 카드의 숫자에서 시작해요. 2명이면 40, 3명이면 20, 4명 이상이면 0이에요.' : '합계는 0에서 시작해요.'}
|
|
|
|
|
</p>
|
|
|
|
|
<p>
|
|
|
|
|
<strong>낼 카드가 없으면</strong>:{' '}
|
|
|
|
|
{lives > 0
|
|
|
|
|
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
|
|
|
|
? `목숨 1개를 잃고, ${start}부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
|
|
|
|
: o.afterElimination === 'reset'
|
|
|
|
|
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
|
|
|
|
|
? `바로 탈락하고, 합계를 ${start}${startCard(o) ? '로' : '으로'} 해서 카드를 다시 나눠요.`
|
|
|
|
|
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
|
|
|
|
|
마지막까지 남은 1명이 이겨요.
|
|
|
|
|
</p>
|
|
|
|
|
<SpecialTable o={o} />
|
|
|
|
|
<p className="small muted">
|
|
|
|
|
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
|
|
|
|
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99를 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|