Merge branch 'm4/davinci-code' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
956
apps/web/src/games/davinci-code/index.tsx
Normal file
956
apps/web/src/games/davinci-code/index.tsx
Normal file
@@ -0,0 +1,956 @@
|
||||
/** 다빈치 코드 화면 (docs/games/davinci-code.md §9). */
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DavinciGuess, DavinciOptions, DavinciTileView, DavinciView, PhaseKind, TColor, TileValue } from '@bg/games/davinci-code';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { Avatar } from '../../components/ui';
|
||||
import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
|
||||
const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
|
||||
const COLOR_SHORT: Record<TColor, string> = { black: '검정', white: '흰색' };
|
||||
const NUMBERS = Array.from({ length: 12 }, (_, i) => i);
|
||||
|
||||
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 valueKo = (v: TileValue) => (v === 'joker' ? '조커' : String(v));
|
||||
const faceOf = (v: TileValue) => (v === 'joker' ? '−' : String(v));
|
||||
const hiddenLeft = (row: DavinciTileView[]) => row.filter((t) => !t.revealed).length;
|
||||
|
||||
function totalMsOf(v: DavinciView): number {
|
||||
if (v.phase === 'chooseStart') return 15_000;
|
||||
if ((v.idle[v.current] ?? 0) >= 3) return 5_000;
|
||||
return v.phase === 'guess' ? v.options.turnSeconds * 1000 : 10_000;
|
||||
}
|
||||
|
||||
/** Seats starting with the one after `me` (clockwise); spectators see everyone. */
|
||||
function opponentsOf(v: DavinciView, me: string | null): string[] {
|
||||
const i = me ? v.seats.indexOf(me) : -1;
|
||||
if (i < 0) return v.seats;
|
||||
return v.seats.map((_, k) => v.seats[(i + 1 + k) % v.seats.length]!).filter((p) => p !== me);
|
||||
}
|
||||
|
||||
/** Colour+number combinations the viewer can already see (for the "impossible guess" warning). */
|
||||
function visibleCombos(v: DavinciView): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const row of Object.values(v.rows)) for (const t of row) if (t.n !== undefined) out.add(`${t.color}:${t.n}`);
|
||||
if (v.pending?.n !== undefined) out.add(`${v.pending.color}:${v.pending.n}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function guessLine(g: DavinciGuess, players: Record<string, PublicUser>): string {
|
||||
return `${nick(players, g.by)} → ${nick(players, g.target)} ${g.index + 1}번째 ${COLOR_SHORT[g.color]} = ${valueKo(g.said)} (${g.correct ? '맞힘' : '틀림'}${g.auto ? ', 시간 초과' : ''})`;
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.dv-board { width: 100%; max-width: 980px; max-height: 100%; overflow: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px; }
|
||||
.dv-opps { display: grid; gap: 10px; }
|
||||
.dv-player { background: var(--surface); border-radius: var(--radius-lg); padding: 10px 12px; box-shadow: var(--shadow); border: 3px solid transparent; }
|
||||
.dv-player.turn { border-color: var(--focus); }
|
||||
.dv-player.out { opacity: .7; }
|
||||
.dv-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 32px; }
|
||||
.dv-name { font-weight: 800; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dv-badge { display: inline-block; border-radius: 999px; padding: 1px 9px; font-weight: 800; font-size: .8rem; background: var(--surface-2); color: var(--text); }
|
||||
.dv-badge.turn { background: var(--focus); color: #1c1e21; }
|
||||
.dv-badge.out { background: var(--danger-soft); color: var(--danger); }
|
||||
.dv-badge.ok { background: var(--success-soft); color: var(--success); }
|
||||
.dv-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-end; padding-top: 8px; }
|
||||
.dv-slot { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.dv-pos { font-size: .75rem; color: var(--text-sub); font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.dv-tile { --w: 46px; width: var(--w); height: calc(var(--w) * 1.42); border-radius: 8px; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 900; font-size: calc(var(--w) * .56); line-height: 1; position: relative; user-select: none; font-variant-numeric: tabular-nums;
|
||||
box-shadow: 0 3px 0 rgba(0,0,0,.35); transition: transform .15s ease; }
|
||||
.dv-tile.black { background: linear-gradient(160deg, #42454d, #141519); color: #fff; border: 2px solid color-mix(in srgb, var(--text) 30%, #050505); }
|
||||
.dv-tile.white { background: linear-gradient(160deg, #ffffff, #e2ded3); color: #15161a; border: 2px solid #9d978a; }
|
||||
.dv-tile.back.black { background: repeating-linear-gradient(45deg, #2a2c33 0 5px, #1b1c21 5px 10px); }
|
||||
.dv-tile.back.white { background: repeating-linear-gradient(45deg, #fbf9f4 0 5px, #e6e1d6 5px 10px); }
|
||||
.dv-tile .q { opacity: .45; font-size: .62em; }
|
||||
.dv-tile.open::after { content: ''; position: absolute; left: 18%; right: 18%; bottom: 4px; height: 4px; border-radius: 2px; background: #f59e0b; }
|
||||
.dv-tile.secret { outline: 3px dashed var(--primary); outline-offset: 2px; }
|
||||
.dv-tile.sm { --w: 30px; }
|
||||
.dv-tile.lg { --w: 54px; }
|
||||
.dv-tile.hit { animation: dv-flip .5s ease-out, dv-hit 1.2s ease-out; }
|
||||
.dv-tile.miss { animation: dv-flip .5s ease-out, dv-miss 1.2s ease-out; }
|
||||
.dv-tile.new { animation: dv-drop .35s ease-out; }
|
||||
@keyframes dv-flip { 0% { transform: rotateY(90deg); } 100% { transform: rotateY(0); } }
|
||||
@keyframes dv-hit { 0%, 60% { box-shadow: 0 0 0 4px #22c55e, 0 0 18px 6px rgba(34,197,94,.7); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
|
||||
@keyframes dv-miss { 0%, 60% { box-shadow: 0 0 0 4px #ef4444, 0 0 18px 6px rgba(239,68,68,.6); } 100% { box-shadow: 0 3px 0 rgba(0,0,0,.35); } }
|
||||
@keyframes dv-drop { 0% { transform: translateY(-14px); opacity: .3; } 100% { transform: translateY(0); opacity: 1; } }
|
||||
.dv-pick { background: none; border: 0; padding: 3px; border-radius: 11px; cursor: pointer; color: inherit; }
|
||||
.dv-pick:disabled { cursor: default; }
|
||||
.dv-pick.can { box-shadow: 0 0 0 3px var(--primary); }
|
||||
.dv-pick.can:hover .dv-tile { transform: translateY(-3px); }
|
||||
.dv-pick.sel { box-shadow: 0 0 0 4px var(--focus); }
|
||||
.dv-pick.sel .dv-tile { transform: translateY(-6px); }
|
||||
.dv-pick:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-gap { align-self: stretch; min-width: 28px; min-height: 48px; border: 2px dashed var(--primary); background: var(--primary-soft); color: var(--text);
|
||||
border-radius: 8px; font-weight: 800; cursor: pointer; padding: 0 2px; font-size: .8rem; }
|
||||
.dv-gap:focus-visible { outline: 3px solid var(--focus); }
|
||||
.dv-mid { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
||||
.dv-center { background: var(--felt); color: #fff; border-radius: var(--radius-lg); padding: 12px; display: flex; flex-direction: column; gap: 10px; align-items: center; }
|
||||
.dv-piles { display: flex; gap: 12px; justify-content: center; }
|
||||
.dv-pile { display: flex; flex-direction: column; align-items: center; gap: 4px; background: rgba(0,0,0,.18); border: 2px solid transparent; border-radius: 12px;
|
||||
padding: 8px 10px; color: #fff; font-weight: 800; min-width: 92px; min-height: 48px; cursor: pointer; font-size: .9rem; }
|
||||
.dv-pile:disabled { cursor: default; opacity: .8; }
|
||||
.dv-pile.can { border-color: #fde047; background: rgba(253,224,71,.15); }
|
||||
.dv-pile:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-stack { position: relative; width: 44px; height: 50px; }
|
||||
.dv-stack .dv-tile { position: absolute; left: 0; }
|
||||
.dv-pending { display: flex; align-items: center; gap: 10px; font-size: .9rem; }
|
||||
.dv-panel { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.dv-pad { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.dv-pad button { min-height: 56px; font-size: 1.35rem; font-weight: 900; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); cursor: pointer; font-variant-numeric: tabular-nums; }
|
||||
.dv-pad button.unlikely { opacity: .4; }
|
||||
.dv-pad button[aria-pressed='true'] { background: var(--primary); color: var(--primary-text); border-color: var(--primary); opacity: 1; }
|
||||
.dv-pad button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-pad .joker { grid-column: 1 / -1; font-size: 1.1rem; }
|
||||
.dv-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.dv-big .btn { min-height: 64px; font-size: 1.15rem; }
|
||||
.dv-split { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; }
|
||||
.dv-split button { min-height: 56px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 700; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 8px; }
|
||||
.dv-split button[aria-pressed='true'] { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.dv-split button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.dv-split button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
|
||||
.dv-mini { display: inline-block; width: 12px; height: 17px; border-radius: 3px; vertical-align: middle; }
|
||||
.dv-mini.black { background: #1b1c21; border: 1px solid #000; }
|
||||
.dv-mini.white { background: #fff; border: 1px solid #8f8a7e; }
|
||||
.dv-me { border-color: var(--primary); }
|
||||
.dv-legend { font-size: .85rem; color: var(--text-sub); display: flex; gap: 6px 16px; flex-wrap: wrap; align-items: center; }
|
||||
.dv-legend > span { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.dv-me .dv-slot { gap: 6px; }
|
||||
.dv-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; font-size: .9rem; }
|
||||
.dv-log li.hit::before { content: '✓ '; color: var(--success); font-weight: 900; }
|
||||
.dv-log li.miss::before { content: '✗ '; color: var(--danger); font-weight: 900; }
|
||||
@media (max-width: 719px) {
|
||||
.dv-tile { --w: 34px; border-radius: 6px; }
|
||||
.dv-tile.lg { --w: 37px; }
|
||||
.dv-row { gap: 4px; }
|
||||
.dv-mid { grid-template-columns: minmax(0, 1fr); }
|
||||
.dv-board { max-height: none; overflow: visible; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dv-tile.hit, .dv-tile.miss, .dv-tile.new { animation: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------- tiles
|
||||
|
||||
type Fx = 'hit' | 'miss' | 'new';
|
||||
|
||||
function Tile({ color, n, revealed, mine, size, fx }: { color: TColor; n?: TileValue; revealed: boolean; mine?: boolean; size?: 'sm' | 'lg'; fx?: Fx }) {
|
||||
const known = n !== undefined;
|
||||
const cls = ['dv-tile', color, known ? '' : 'back', revealed ? 'open' : '', mine && !revealed ? 'secret' : '', size ?? '', fx ?? ''].filter(Boolean).join(' ');
|
||||
return (
|
||||
<span className={cls} aria-hidden>
|
||||
{known ? faceOf(n) : <span className="q">?</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function tileLabel(t: DavinciTileView, pos: number, mine: boolean): string {
|
||||
const what = t.n === undefined ? '숨김' : t.n === 'joker' ? '조커' : `${t.n}`;
|
||||
return `${pos + 1}번째, ${COLOR_KO[t.color]} ${what}${t.revealed ? ', 공개됨' : mine ? ', 나만 보임' : ''}${t.wrong.length ? `, 틀린 추리 ${t.wrong.map(valueKo).join('·')}` : ''}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- board
|
||||
|
||||
interface Sel {
|
||||
target: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function DavinciBoard({ view: v, me, players, act, pending, deadline }: BoardProps<DavinciView>) {
|
||||
const amPlayer = !!me && v.seats.includes(me);
|
||||
const myTurn = amPlayer && v.current === me && !!v.alive[me!] && v.phase !== 'chooseStart' && v.phase !== 'finished';
|
||||
const [sel, setSel] = useState<Sel | null>(null);
|
||||
const [val, setVal] = useState<TileValue | null>(null);
|
||||
const [fx, setFx] = useState<Map<string, Fx>>(new Map());
|
||||
const [announce, setAnnounce] = useState('');
|
||||
const prevHist = useRef(v.history.length);
|
||||
const prevPlaced = useRef(JSON.stringify(v.lastPlaced));
|
||||
const prevTurn = useRef(`${v.current}|${v.phase === 'chooseStart'}`);
|
||||
|
||||
const resetKey = `${v.phase}|${v.current}|${v.history.length}`;
|
||||
useEffect(() => {
|
||||
setSel(null);
|
||||
setVal(null);
|
||||
}, [resetKey]);
|
||||
|
||||
// Effects + screen-reader text for new guesses and newly placed tiles.
|
||||
useEffect(() => {
|
||||
const placedNow = JSON.stringify(v.lastPlaced);
|
||||
const next = new Map<string, Fx>();
|
||||
if (v.history.length > prevHist.current) {
|
||||
const g = v.history[v.history.length - 1]!;
|
||||
if (g.correct) next.set(`${g.target}:${g.index}`, 'hit');
|
||||
else if (v.lastPlaced && v.lastPlaced.player === g.by && v.lastPlaced.revealed) next.set(`${g.by}:${v.lastPlaced.index}`, 'miss');
|
||||
setAnnounce(
|
||||
`${nick(players, g.by)}님이 ${nick(players, g.target)}님 ${g.index + 1}번째 타일을 ${valueKo(g.said)}(으)로 추리해서 ${g.correct ? '맞혔어요' : '틀렸어요'}.`,
|
||||
);
|
||||
if (g.correct && g.by === me) haptic(30);
|
||||
} else if (placedNow !== prevPlaced.current && v.lastPlaced) {
|
||||
next.set(`${v.lastPlaced.player}:${v.lastPlaced.index}`, v.lastPlaced.revealed ? 'miss' : 'new');
|
||||
}
|
||||
prevHist.current = v.history.length;
|
||||
prevPlaced.current = placedNow;
|
||||
if (next.size) {
|
||||
setFx(next);
|
||||
const t = setTimeout(() => setFx(new Map()), 1300);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
return undefined;
|
||||
}, [v.history.length, JSON.stringify(v.lastPlaced)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const key = `${v.current}|${v.phase === 'chooseStart'}`;
|
||||
if (key !== prevTurn.current && v.phase !== 'chooseStart' && v.phase !== 'finished') {
|
||||
if (v.current === me) {
|
||||
haptic(40);
|
||||
sounds.myTurn();
|
||||
setAnnounce('내 차례예요.');
|
||||
} else setAnnounce(`${nick(players, v.current)}님 차례예요.`);
|
||||
}
|
||||
prevTurn.current = key;
|
||||
}, [v.current, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canGuess = myTurn && v.phase === 'guess' && !pending;
|
||||
const pick = (target: string, index: number) => {
|
||||
if (sel && sel.target === target && sel.index === index) {
|
||||
setSel(null);
|
||||
setVal(null);
|
||||
} else {
|
||||
setSel({ target, index });
|
||||
setVal(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dv-board" role="region" aria-label="다빈치 코드 판">
|
||||
<style>{CSS}</style>
|
||||
<div className="dv-opps">
|
||||
{opponentsOf(v, me).map((p) => (
|
||||
<PlayerRow key={p} id={p} view={v} me={me} players={players} deadline={deadline} fx={fx} canPick={canGuess && !!v.alive[p]} sel={sel} onPick={pick} />
|
||||
))}
|
||||
</div>
|
||||
<div className="dv-mid">
|
||||
<Center view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} />
|
||||
<ActionPanel view={v} me={me} players={players} act={act} pending={pending} myTurn={myTurn} sel={sel} val={val} setVal={setVal} onCancel={() => (setSel(null), setVal(null))} />
|
||||
</div>
|
||||
{amPlayer && <MyRow view={v} me={me!} players={players} act={act} pending={pending} myTurn={myTurn} deadline={deadline} fx={fx} />}
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{announce}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerHead({ id, view: v, me, players, deadline }: { id: string; view: DavinciView; me: string | null; players: Record<string, PublicUser>; deadline: number | null }) {
|
||||
const u = userOf(players, id);
|
||||
const row = v.rows[id] ?? [];
|
||||
const st = v.stats[id] ?? { guesses: 0, correct: 0 };
|
||||
const outRank = v.eliminationOrder.indexOf(id);
|
||||
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
|
||||
const choosing = v.phase === 'chooseStart' && v.alive[id] && v.picked && !v.picked[id];
|
||||
return (
|
||||
<div className="dv-head">
|
||||
<Avatar user={u} size={26} />
|
||||
<span className="dv-name">
|
||||
{u.nickname}
|
||||
{id === me && <span className="muted"> (나)</span>}
|
||||
</span>
|
||||
{isTurn && <span className="dv-badge turn">차례</span>}
|
||||
{v.final?.winner === id && <span className="dv-badge ok">승리</span>}
|
||||
{!v.alive[id] && <span className="dv-badge out">{v.left[id] ? '나감' : '탈락'}{outRank >= 0 ? ` · ${v.seats.length - outRank}위` : ''}</span>}
|
||||
{v.alive[id] && (v.idle[id] ?? 0) >= 3 && <span className="dv-badge">자리 비움</span>}
|
||||
{v.phase === 'chooseStart' && v.alive[id] && <span className={`dv-badge${choosing ? '' : ' ok'}`}>{choosing ? '고르는 중' : '다 골랐어요'}</span>}
|
||||
<span className="small muted" style={{ marginLeft: 'auto' }}>
|
||||
{v.phase !== 'chooseStart' && `숨은 타일 ${hiddenLeft(row)}개 · `}맞힘 {st.correct}/{st.guesses}
|
||||
</span>
|
||||
{(isTurn || choosing) && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={id === me} size={30} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayerRow({
|
||||
id,
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
deadline,
|
||||
fx,
|
||||
canPick,
|
||||
sel,
|
||||
onPick,
|
||||
}: {
|
||||
id: string;
|
||||
view: DavinciView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
deadline: number | null;
|
||||
fx: Map<string, Fx>;
|
||||
canPick: boolean;
|
||||
sel: Sel | null;
|
||||
onPick: (target: string, index: number) => void;
|
||||
}) {
|
||||
const row = v.rows[id] ?? [];
|
||||
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === id;
|
||||
const name = nick(players, id);
|
||||
return (
|
||||
<section className={`dv-player${isTurn ? ' turn' : ''}${v.alive[id] || v.phase === 'finished' ? '' : ' out'}`} aria-label={`${name}님 타일`}>
|
||||
<PlayerHead id={id} view={v} me={me} players={players} deadline={deadline} />
|
||||
<div className="dv-row" role="group" aria-label={`${name}님 타일 줄`}>
|
||||
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일을 기다리는 중이에요' : '타일이 없어요'}</span>}
|
||||
{row.map((t, i) => {
|
||||
const can = canPick && !t.revealed;
|
||||
const selected = sel?.target === id && sel.index === i;
|
||||
return (
|
||||
<div className="dv-slot" key={i}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dv-pick${can ? ' can' : ''}${selected ? ' sel' : ''}`}
|
||||
disabled={!can}
|
||||
aria-pressed={can ? selected : undefined}
|
||||
aria-label={`${name}님 ${tileLabel(t, i, false)}${can ? ', 눌러서 추리' : ''}`}
|
||||
onClick={() => onPick(id, i)}
|
||||
>
|
||||
<Tile color={t.color} n={t.n} revealed={t.revealed} fx={fx.get(`${id}:${i}`)} />
|
||||
</button>
|
||||
<span className="dv-pos" aria-hidden>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MyRow({
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
act,
|
||||
pending,
|
||||
myTurn,
|
||||
deadline,
|
||||
fx,
|
||||
}: {
|
||||
view: DavinciView;
|
||||
me: string;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
myTurn: boolean;
|
||||
deadline: number | null;
|
||||
fx: Map<string, Fx>;
|
||||
}) {
|
||||
const row = v.rows[me] ?? [];
|
||||
const placing = myTurn && v.phase === 'placeJoker' && !pending;
|
||||
const revealing = myTurn && v.phase === 'revealOwn' && !pending;
|
||||
const isTurn = v.phase !== 'finished' && v.phase !== 'chooseStart' && v.current === me;
|
||||
const gap = (index: number) =>
|
||||
placing && (
|
||||
<button key={`gap${index}`} type="button" className="dv-gap" aria-label={`${index + 1}번째 자리에 조커 넣기`} onClick={() => act({ type: 'placeJoker', index })}>
|
||||
여기
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<section className={`dv-player dv-me${isTurn ? ' turn' : ''}${v.alive[me] || v.phase === 'finished' ? '' : ' out'}`} aria-label="내 타일">
|
||||
<PlayerHead id={me} view={v} me={me} players={players} deadline={deadline} />
|
||||
<div className="dv-row" role="group" aria-label="내 타일 줄">
|
||||
{row.length === 0 && <span className="small muted">{v.phase === 'chooseStart' ? '시작 타일 색을 고르면 여기에 놓여요' : '타일이 없어요'}</span>}
|
||||
{row.map((t, i) => {
|
||||
const can = revealing && !t.revealed;
|
||||
return [
|
||||
gap(i),
|
||||
<div className="dv-slot" key={i}>
|
||||
{can ? (
|
||||
<button type="button" className="dv-pick can" aria-label={`내 ${tileLabel(t, i, true)}, 눌러서 공개`} onClick={() => act({ type: 'revealOwn', index: i })}>
|
||||
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="dv-pick" role="img" aria-label={`내 ${tileLabel(t, i, true)}`}>
|
||||
<Tile color={t.color} n={t.n} revealed={t.revealed} mine size="lg" fx={fx.get(`${me}:${i}`)} />
|
||||
</span>
|
||||
)}
|
||||
<span className="dv-pos" aria-hidden>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>,
|
||||
];
|
||||
})}
|
||||
{gap(row.length)}
|
||||
</div>
|
||||
{row.length > 0 && (
|
||||
<div className="dv-legend" style={{ marginTop: 6 }}>
|
||||
<span>
|
||||
<span className="dv-mini white" style={{ outline: '2px dashed var(--primary)', outlineOffset: 1 }} /> 점선: 상대에게 숨김
|
||||
</span>
|
||||
<span>
|
||||
<span className="dv-mini white" style={{ boxShadow: 'inset 0 -4px 0 #f59e0b' }} /> 아래 노란 줄: 모두에게 공개됨
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Center({ view: v, me, players, act, pending, myTurn }: { view: DavinciView; me: string | null; players: Record<string, PublicUser>; act: (a: unknown) => void; pending: boolean; myTurn: boolean }) {
|
||||
const drawing = myTurn && v.phase === 'draw' && !pending;
|
||||
const total = v.pool.black + v.pool.white;
|
||||
const last = v.history[v.history.length - 1];
|
||||
return (
|
||||
<div className="dv-center" role="group" aria-label="더미">
|
||||
{v.options.chooseColors ? (
|
||||
<div className="dv-piles">
|
||||
{(['black', 'white'] as const).map((c) => {
|
||||
const can = drawing && v.pool[c] > 0;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`dv-pile${can ? ' can' : ''}`}
|
||||
disabled={!can}
|
||||
aria-label={`${COLOR_KO[c]} 타일 가져오기, ${v.pool[c]}개 남음`}
|
||||
onClick={() => act({ type: 'draw', color: c })}
|
||||
>
|
||||
<span className="dv-stack" aria-hidden>
|
||||
{v.pool[c] > 0 ? (
|
||||
[0, 1, 2].slice(0, Math.min(3, v.pool[c])).map((k) => (
|
||||
<span key={k} style={{ position: 'absolute', left: 7 + k * 3, top: 6 - k * 3 }}>
|
||||
<Tile color={c} revealed={false} size="sm" />
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>없음</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{COLOR_SHORT[c]} {v.pool[c]}개
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className={`dv-pile${drawing ? ' can' : ''}`} disabled={!drawing} aria-label={`타일 가져오기, ${total}개 남음`} onClick={() => act({ type: 'draw' })}>
|
||||
<span>타일 가져오기</span>
|
||||
<span className="small">
|
||||
남은 타일 {total}개 (검정 {v.pool.black} · 흰색 {v.pool.white})
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{v.pending ? (
|
||||
<div className="dv-pending">
|
||||
<Tile color={v.pending.color} n={v.pending.n} revealed={false} mine={v.current === me} />
|
||||
<span>
|
||||
{v.current === me ? '내가 가져온 타일' : `${nick(players, v.current)}님이 가져온 타일`}
|
||||
<br />
|
||||
<span className="small" style={{ opacity: 0.85 }}>
|
||||
{v.current === me ? '나만 보여요' : '색만 보여요'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
v.phase !== 'chooseStart' &&
|
||||
v.phase !== 'finished' &&
|
||||
total === 0 && <span className="small">더미가 비었어요. 가져오지 않고 바로 추리해요.</span>
|
||||
)}
|
||||
{last && (
|
||||
<span className="small" style={{ textAlign: 'center' }} aria-hidden>
|
||||
최근: {guessLine(last, players)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionPanel({
|
||||
view: v,
|
||||
me,
|
||||
players,
|
||||
act,
|
||||
pending,
|
||||
myTurn,
|
||||
sel,
|
||||
val,
|
||||
setVal,
|
||||
onCancel,
|
||||
}: {
|
||||
view: DavinciView;
|
||||
me: string | null;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
myTurn: boolean;
|
||||
sel: Sel | null;
|
||||
val: TileValue | null;
|
||||
setVal: (v: TileValue | null) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (v.phase === 'finished') {
|
||||
return (
|
||||
<div className="dv-panel" aria-live="polite">
|
||||
<strong>게임이 끝났어요</strong>
|
||||
<span>{v.final?.summary}</span>
|
||||
<span className="small muted">모든 타일을 공개했어요.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (v.phase === 'chooseStart') return <StartChooser view={v} me={me} act={act} pending={pending} />;
|
||||
if (!myTurn) {
|
||||
const who = nick(players, v.current);
|
||||
const doing: Record<PhaseKind, string> = {
|
||||
chooseStart: '',
|
||||
draw: '타일을 가져오는 중이에요',
|
||||
guess: '추리하는 중이에요',
|
||||
afterCorrect: '맞혔어요! 계속할지 고르는 중이에요',
|
||||
placeJoker: '조커를 넣을 자리를 고르는 중이에요',
|
||||
revealOwn: '틀려서 자기 타일을 공개하는 중이에요',
|
||||
finished: '',
|
||||
};
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>
|
||||
{who}님이 {doing[v.phase]}
|
||||
</strong>
|
||||
{me && v.seats.includes(me) && !v.alive[me] && <span className="small muted">나는 탈락했어요. 끝까지 구경할 수 있어요.</span>}
|
||||
<span className="small muted">공개된 숫자와 타일 위치, 추리 기록을 보고 상대의 숫자를 짐작해 보세요.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (v.phase) {
|
||||
case 'draw':
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>더미에서 타일을 하나 가져와 주세요.</strong>
|
||||
<span className="small muted">
|
||||
{v.options.chooseColors ? '검정과 흰색 중 원하는 색 더미를 누르세요. ' : ''}가져온 타일의 숫자는 나만 봐요.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'guess':
|
||||
return sel ? <NumberPad view={v} players={players} act={act} pending={pending} sel={sel} val={val} setVal={setVal} onCancel={onCancel} /> : (
|
||||
<div className="dv-panel">
|
||||
<strong>맞힐 상대 타일을 눌러 주세요.</strong>
|
||||
<span className="small muted">파란 테두리가 있는 숨은 타일만 고를 수 있어요. 틀리면 {v.pending ? '가져온 타일이 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 타일 하나를 공개해야 해요' : '차례가 넘어가요'}.</span>
|
||||
</div>
|
||||
);
|
||||
case 'afterCorrect':
|
||||
return (
|
||||
<div className="dv-panel" aria-live="polite">
|
||||
<strong style={{ color: 'var(--success)', fontSize: '1.15rem' }}>맞혔어요!</strong>
|
||||
<div className="dv-big">
|
||||
<button type="button" className="btn btn-primary" disabled={pending} onClick={() => act({ type: 'continue' })}>
|
||||
한 번 더 맞히기
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={pending} onClick={() => act({ type: 'stop' })}>
|
||||
여기서 멈추기
|
||||
</button>
|
||||
</div>
|
||||
<span className="small muted">
|
||||
{v.pending
|
||||
? '멈추면 가져온 타일이 숨겨진 채로 들어가요. 계속하다 틀리면 가져온 타일이 공개돼요.'
|
||||
: '멈추면 차례가 끝나요. 계속하다 틀리면 벌칙이 있을 수 있어요.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
case 'placeJoker':
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>조커를 넣을 자리를 골라 주세요.</strong>
|
||||
<span className="small muted">내 줄의 "여기" 버튼 중 하나를 누르세요. 조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.</span>
|
||||
</div>
|
||||
);
|
||||
case 'revealOwn':
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>틀렸어요. 공개할 내 타일을 하나 골라 주세요.</strong>
|
||||
<span className="small muted">더미가 비어서 가져온 타일 대신 내 숨은 타일 하나를 공개해요. 내 줄에서 파란 테두리 타일을 누르세요.</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function NumberPad({
|
||||
view: v,
|
||||
players,
|
||||
act,
|
||||
pending,
|
||||
sel,
|
||||
val,
|
||||
setVal,
|
||||
onCancel,
|
||||
}: {
|
||||
view: DavinciView;
|
||||
players: Record<string, PublicUser>;
|
||||
act: (a: unknown) => void;
|
||||
pending: boolean;
|
||||
sel: Sel;
|
||||
val: TileValue | null;
|
||||
setVal: (v: TileValue | null) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const tile = v.rows[sel.target]?.[sel.index];
|
||||
const padRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
padRef.current?.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
|
||||
}, [sel.target, sel.index]);
|
||||
if (!tile) return null;
|
||||
const seen = visibleCombos(v);
|
||||
const cands = tile.candidates ? new Set(tile.candidates.map(String)) : null;
|
||||
const values: TileValue[] = [...NUMBERS, ...(v.options.jokers ? ['joker' as const] : [])];
|
||||
const impossible = (x: TileValue) => seen.has(`${tile.color}:${x}`) || tile.wrong.includes(x) || (!!cands && !cands.has(String(x)));
|
||||
const warn = val === null ? null : seen.has(`${tile.color}:${val}`) ? `${COLOR_SHORT[tile.color]} ${valueKo(val)}은(는) 이미 보이는 곳에 있어요.` : tile.wrong.includes(val) ? '이 타일에 이미 틀린 숫자예요.' : cands && !cands.has(String(val)) ? '공개된 정보로 보면 어려운 숫자예요.' : null;
|
||||
return (
|
||||
<div
|
||||
className="dv-panel"
|
||||
role="group"
|
||||
aria-label="숫자 고르기"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}}
|
||||
>
|
||||
<strong>
|
||||
{nick(players, sel.target)}님 {sel.index + 1}번째 타일({COLOR_SHORT[tile.color]})은 몇일까요?
|
||||
</strong>
|
||||
{tile.wrong.length > 0 && <span className="small muted">이미 틀린 추리: {tile.wrong.map(valueKo).join(', ')}</span>}
|
||||
<div className="dv-pad" ref={padRef}>
|
||||
{values.map((x) => (
|
||||
<button
|
||||
key={String(x)}
|
||||
type="button"
|
||||
className={`${x === 'joker' ? 'joker' : ''}${(v.options.deductionHelper || seen.has(`${tile.color}:${x}`)) && impossible(x) ? ' unlikely' : ''}`}
|
||||
aria-pressed={val === x}
|
||||
aria-label={x === 'joker' ? '조커' : String(x)}
|
||||
onClick={() => setVal(x)}
|
||||
>
|
||||
{x === 'joker' ? '조커 (−)' : x}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="small" style={{ minHeight: '1.4em', color: 'var(--warning)' }}>
|
||||
{warn ? `${warn} 그래도 말할 수 있어요.` : ''}
|
||||
</span>
|
||||
<div className="row" style={{ flexWrap: 'nowrap' }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ flex: 1 }} onClick={onCancel}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 2 }}
|
||||
disabled={val === null || pending}
|
||||
onClick={() => act({ type: 'guess', target: sel.target, index: sel.index, value: val })}
|
||||
>
|
||||
{val === null ? '숫자를 고르세요' : `${valueKo(val)}, 이 숫자!`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartChooser({ view: v, me, act, pending }: { view: DavinciView; me: string | null; act: (a: unknown) => void; pending: boolean }) {
|
||||
const n = v.startCount;
|
||||
const lim = v.pickLimits ?? { black: n, white: n };
|
||||
const fallbackBlack = Math.min(Math.ceil(n / 2), lim.black);
|
||||
const [black, setBlack] = useState<number>(fallbackBlack);
|
||||
const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0;
|
||||
const total = v.picked ? v.seats.filter((p) => v.alive[p]).length : 0;
|
||||
if (!me || !v.seats.includes(me) || !v.alive[me]) {
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>모두 시작 타일 색을 고르는 중이에요 ({done}/{total})</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (v.myPick) {
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>
|
||||
검정 {v.myPick.black}개 · 흰색 {v.myPick.white}개를 골랐어요.
|
||||
</strong>
|
||||
<span className="small muted">
|
||||
다른 사람을 기다리는 중이에요 ({done}/{total}). 다 고르면 숫자가 정해져 내 줄에 놓여요.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const okPick = (b: number) => b <= lim.black && n - b <= lim.white;
|
||||
const chosen = okPick(black) ? black : null;
|
||||
return (
|
||||
<div className="dv-panel">
|
||||
<strong>시작 타일 {n}개의 색을 골라 주세요.</strong>
|
||||
<span className="small muted">숫자는 고른 색 안에서 무작위로 정해져요. 15초 안에 고르지 않으면 반반으로 정해져요.</span>
|
||||
<div className="dv-split" role="group" aria-label="시작 타일 색">
|
||||
{Array.from({ length: n + 1 }, (_, i) => n - i).map((b) => (
|
||||
<button key={b} type="button" aria-pressed={chosen === b} disabled={!okPick(b)} onClick={() => setBlack(b)} aria-label={`검정 ${b}개 · 흰색 ${n - b}개`}>
|
||||
<span aria-hidden>
|
||||
{Array.from({ length: n }, (_, k) => (
|
||||
<span key={k} className={`dv-mini ${k < b ? 'black' : 'white'}`} style={{ marginRight: 2 }} />
|
||||
))}
|
||||
</span>
|
||||
<span>
|
||||
검 {b} · 흰 {n - b}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(lim.black < n || lim.white < n) && (
|
||||
<span className="small muted">
|
||||
먼저 고른 사람 때문에 검정은 {lim.black}개, 흰색은 {lim.white}개까지 고를 수 있어요.
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="btn btn-primary" disabled={chosen === null || pending} onClick={() => act({ type: 'chooseStart', black: chosen, white: n - chosen! })}>
|
||||
이렇게 받기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- side
|
||||
|
||||
function DavinciSide({ view: v, me, players, deadline }: BoardProps<DavinciView>) {
|
||||
const running = v.phase !== 'finished';
|
||||
const ranking = v.final?.ranking ?? null;
|
||||
return (
|
||||
<>
|
||||
<style>{CSS}</style>
|
||||
<div className="card stack" style={{ gap: 8, padding: 12 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>{running ? (v.phase === 'chooseStart' ? '시작 타일 고르기' : `${nick(players, v.current)}님 차례`) : '게임 끝'}</strong>
|
||||
{running && <TurnTimer deadline={deadline} totalMs={totalMsOf(v)} mine={isMyTurn(v, me)} size={36} />}
|
||||
</div>
|
||||
<span className="small muted">{optionsSummary(v.options)}</span>
|
||||
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }} aria-label="참가자">
|
||||
{v.seats.map((p) => {
|
||||
const st = v.stats[p] ?? { guesses: 0, correct: 0 };
|
||||
const out = v.eliminationOrder.indexOf(p);
|
||||
return (
|
||||
<li key={p} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: v.alive[p] || !running ? 1 : 0.6 }}>
|
||||
<Avatar user={userOf(players, p)} size={24} />
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: p === me ? 800 : 600 }}>
|
||||
{nick(players, p)}
|
||||
{running && v.phase !== 'chooseStart' && v.current === p ? ' · 차례' : ''}
|
||||
{v.final?.winner === p ? ' · 1위' : out >= 0 ? ` · ${v.left[p] ? '나감' : '탈락'} ${v.seats.length - out}위` : ''}
|
||||
</span>
|
||||
<span className="small muted" style={{ whiteSpace: 'nowrap' }}>
|
||||
숨김 {hiddenLeft(v.rows[p] ?? [])} · 맞힘 {st.correct}/{st.guesses}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
{ranking && (
|
||||
<div className="card stack" style={{ gap: 4, padding: 12 }}>
|
||||
<strong>최종 순위</strong>
|
||||
{ranking.map((g, i) => (
|
||||
<span key={i}>
|
||||
{i + 1}위 {g.map((id) => `${nick(players, id)} (맞힘 ${v.stats[id]?.correct ?? 0} / 추리 ${v.stats[id]?.guesses ?? 0})`).join(', ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details className="card" open style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>추리 기록 ({v.history.length})</summary>
|
||||
{v.history.length === 0 ? (
|
||||
<p className="small muted" style={{ margin: '8px 0 0' }}>
|
||||
아직 추리가 없어요.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="dv-log" style={{ marginTop: 8 }} tabIndex={0} aria-label="추리 기록 목록">
|
||||
{[...v.history].reverse().map((g, i) => (
|
||||
<li key={v.history.length - i} className={g.correct ? 'hit' : 'miss'}>
|
||||
{guessLine(g, players)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</details>
|
||||
<details className="card" style={{ padding: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>정렬 규칙</summary>
|
||||
<SortPicture tieOrder={v.options.tieOrder} />
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortPicture({ tieOrder }: { tieOrder: DavinciOptions['tieOrder'] }) {
|
||||
const tiles: [TColor, number][] = tieOrder === 'black-left' ? [['black', 2], ['black', 5], ['white', 5], ['white', 9]] : [['black', 2], ['white', 5], ['black', 5], ['white', 9]];
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6, marginTop: 8 }}>
|
||||
<div className="row" style={{ gap: 6 }} role="img" aria-label={`정렬 예: ${tiles.map(([c, n]) => `${COLOR_SHORT[c]} ${n}`).join(', ')}`}>
|
||||
{tiles.map(([c, n], i) => (
|
||||
<Tile key={i} color={c} n={n} revealed={false} />
|
||||
))}
|
||||
</div>
|
||||
<span className="small muted">작은 수가 왼쪽이에요. 같은 숫자면 {tieOrder === 'black-left' ? '검정' : '흰색'}이 왼쪽이에요. 새 타일은 서버가 알맞은 자리에 끼워 줘요.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- status
|
||||
|
||||
function isMyTurn(v: DavinciView, me: string | null): boolean {
|
||||
if (!me || !v.seats.includes(me) || v.phase === 'finished' || !v.alive[me]) return false;
|
||||
if (v.phase === 'chooseStart') return !!v.picked && !v.picked[me];
|
||||
return v.current === me;
|
||||
}
|
||||
|
||||
function statusText(v: DavinciView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
if (v.phase === 'finished') return v.final?.summary ?? '게임이 끝났어요';
|
||||
if (v.phase === 'chooseStart') return isMyTurn(v, me) ? '내 차례예요. 시작 타일 색을 골라 주세요' : '다른 사람이 시작 타일 색을 고르는 중이에요';
|
||||
if (isMyTurn(v, me)) {
|
||||
switch (v.phase) {
|
||||
case 'draw':
|
||||
return '내 차례예요. 더미에서 타일을 가져와 주세요';
|
||||
case 'guess':
|
||||
return '내 차례예요. 상대 타일을 골라 숫자를 맞혀 보세요';
|
||||
case 'afterCorrect':
|
||||
return '내 차례예요. 맞혔어요! 한 번 더 할지 멈출지 골라 주세요';
|
||||
case 'placeJoker':
|
||||
return '내 차례예요. 조커를 넣을 자리를 골라 주세요';
|
||||
case 'revealOwn':
|
||||
return '내 차례예요. 공개할 내 타일을 골라 주세요';
|
||||
}
|
||||
}
|
||||
const out = !!me && v.seats.includes(me) && !v.alive[me] ? '탈락했어요 · ' : '';
|
||||
return `${out}${nick(players, v.current)}님 차례예요`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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 }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Options({ value: o, onChange }: OptionsProps<DavinciOptions>) {
|
||||
const [more, setMore] = useState(false);
|
||||
const set = (patch: Partial<DavinciOptions>) => onChange({ ...o, ...patch });
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<Field label="조커">
|
||||
<Seg label="조커" value={o.jokers} options={[[false, '안 씀'], [true, '씀(검정·흰색 각 1개)']]} onChange={(jokers) => set({ jokers })} />
|
||||
</Field>
|
||||
<Field label="시작 타일 수">
|
||||
<Seg label="시작 타일 수" value={o.startTiles} options={[['auto', '자동(2·3명 4개, 4명 3개)'], [3, '3개'], [4, '4개'], [5, '5개']]} onChange={(startTiles) => set({ startTiles })} />
|
||||
</Field>
|
||||
<Field label="추리 제한 시간">
|
||||
<Seg label="추리 제한 시간" value={o.turnSeconds} options={[[20, '20초'], [30, '30초'], [45, '45초'], [60, '60초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
<Field label="가능한 숫자 후보 표시(초보 도움)">
|
||||
<Seg label="후보 표시" value={o.deductionHelper} options={[[false, '끄기'], [true, '켜기']]} onChange={(deductionHelper) => set({ deductionHelper })} />
|
||||
</Field>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||
</button>
|
||||
{more && (
|
||||
<>
|
||||
<Field label="같은 숫자일 때 색 순서">
|
||||
<Seg label="같은 숫자일 때 색 순서" value={o.tieOrder} options={[['black-left', '검정이 왼쪽'], ['white-left', '흰색이 왼쪽']]} onChange={(tieOrder) => set({ tieOrder })} />
|
||||
</Field>
|
||||
<Field label="타일을 가져올 때">
|
||||
<Seg label="타일을 가져올 때" value={o.chooseColors} options={[[true, '색을 골라서'], [false, '무작위로']]} onChange={(chooseColors) => set({ chooseColors })} />
|
||||
</Field>
|
||||
<Field label="더미가 빈 뒤 틀렸을 때">
|
||||
<Seg label="더미가 빈 뒤 틀렸을 때" value={o.emptyPoolPenalty} options={[['reveal-own', '내 숨은 타일 1개 공개'], ['none', '벌칙 없음']]} onChange={(emptyPoolPenalty) => set({ emptyPoolPenalty })} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function optionsSummary(o: DavinciOptions): string {
|
||||
return [
|
||||
o.jokers ? '조커 사용' : '조커 없음',
|
||||
o.startTiles === 'auto' ? '시작 타일 자동' : `시작 타일 ${o.startTiles}개`,
|
||||
o.chooseColors ? '색 골라 가져오기' : '무작위로 가져오기',
|
||||
`추리 ${o.turnSeconds}초`,
|
||||
...(o.tieOrder === 'white-left' ? ['같은 숫자는 흰색 먼저'] : []),
|
||||
...(o.deductionHelper ? ['후보 표시'] : []),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
function Rules({ options }: { options?: DavinciOptions }) {
|
||||
const o = options;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
검은색과 흰색 숫자 타일(0~11)을 작은 수부터 큰 수 순서로 세워 두고, <strong>상대 타일의 숫자를 추리해 맞히는</strong> 게임이에요. 2~4명이 할 수 있어요. 숨은 타일이 모두 드러나면 탈락하고, 마지막까지 숨은
|
||||
타일이 남은 사람이 이겨요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>준비</strong>: 각자 시작 타일 {o && o.startTiles !== 'auto' ? `${o.startTiles}개` : '(2·3명은 4개, 4명은 3개)'}를 받아요.
|
||||
{o?.chooseColors === false ? ' 색은 무작위로 정해져요.' : ' 검정과 흰색을 몇 개씩 받을지 직접 골라요.'} 타일 뒷면이 그 타일의 색이라서, 숨은 타일도 색은 모두에게 보이고 숫자만 숨겨져요.
|
||||
</p>
|
||||
<p>
|
||||
<strong>정렬</strong>: 내 줄은 왼쪽에서 오른쪽으로 숫자가 커져요. 같은 숫자면 {o?.tieOrder === 'white-left' ? '흰색' : '검정'}이 왼쪽이에요. 새 타일은 알맞은 자리에 저절로 끼워지고, 이미 놓인 타일의 순서는 바뀌지
|
||||
않아요.
|
||||
</p>
|
||||
<SortPicture tieOrder={o?.tieOrder ?? 'black-left'} />
|
||||
<p>
|
||||
<strong>내 차례</strong>: ① 더미에서 타일을 하나 가져와요(숫자는 나만 봐요). ② 상대의 숨은 타일 하나를 눌러 숫자를 말해요.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>맞히면</strong> 그 타일이 공개돼요. "한 번 더 맞히기"로 계속하거나 "여기서 멈추기"로 끝낼 수 있어요. 멈추면 가져온 타일이 숨겨진 채로 내 줄에 들어가요.
|
||||
</li>
|
||||
<li>
|
||||
<strong>틀리면</strong> 가져온 타일이 공개된 채로 내 줄에 들어가고 차례가 끝나요.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>더미가 비면</strong> 가져오지 않고 바로 추리해요. 이때 틀리면{' '}
|
||||
{o?.emptyPoolPenalty === 'none' ? '벌칙 없이 차례만 넘어가요.' : '내 숨은 타일 하나를 골라 공개해야 해요. 마지막 숨은 타일을 공개하면 탈락해요.'}
|
||||
</p>
|
||||
{o?.jokers && (
|
||||
<p>
|
||||
<strong>조커</strong>: 숫자 대신 "−"가 그려진 타일이에요(검정·흰색 각 1개). 시작 타일에는 들어가지 않아요. 조커를 가져오면 내 줄의 원하는 틈에 직접 넣고, 한 번 놓으면 옮길 수 없어요. 추리할 때
|
||||
"조커"라고 말할 수도 있어요.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
이미 공개된 같은 색·같은 숫자를 말하는 것처럼 말이 안 되는 추리도 할 수는 있어요(화면에서 알려 줘요). 오른쪽 <strong>추리 기록</strong>에서 누가 어떤 숫자를 말했는지 다시 볼 수 있어요.
|
||||
</p>
|
||||
<p className="muted">
|
||||
시간 안에 추리하지 않으면 공개된 정보로 가장 가능성 있는 작은 숫자를 자동으로 말해요. 세 번 연속 시간을 넘기면 제한 시간이 5초로 줄어요. 결과 화면에는 각자 맞힌 횟수와 추리 횟수가 나와요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ui: GameUI<DavinciView, DavinciOptions> = {
|
||||
Board: DavinciBoard,
|
||||
Side: DavinciSide,
|
||||
statusText,
|
||||
isMyTurn,
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
@@ -13,6 +13,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
||||
yut: () => import('./yut'),
|
||||
'one-card': () => import('./one-card'),
|
||||
rummikub: () => import('./rummikub'),
|
||||
'davinci-code': () => import('./davinci-code'),
|
||||
};
|
||||
|
||||
const cache = new Map<string, GameUI>();
|
||||
|
||||
@@ -21,5 +21,10 @@ export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
||||
rummikub: '🔢',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
||||
'davinci-code': '🔢',
|
||||
};
|
||||
|
||||
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
|
||||
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
|
||||
|
||||
Reference in New Issue
Block a user