M9: 초보자 도움 틀 — 30초 규칙 요약 카드, 게임 중 [?] 지금 할 수 있는 것, 오목 내용

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 03:04:24 +09:00
parent c27cb0a11a
commit 57753a037c
10 changed files with 390 additions and 1 deletions

View File

@@ -0,0 +1,156 @@
/**
* 초보자 도움 (docs/07 §5): "30초 규칙 요약" cards (first game of a kind on this device) and the
* in-game [?] sheet ("지금 할 수 있는 것" + full rules + summary again). Content lives in each game's chunk.
*/
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import type { PublicUser, RoomView } from '@bg/shared/lite';
import { Sheet } from './ui';
import { useRoom } from '../net/socket';
import type { GameUI, QuickCard } from '../games/types';
const seenKey = (gameId: string) => `bg:qs:${gameId}`;
export function quickStartSeen(gameId: string): boolean {
try {
return localStorage.getItem(seenKey(gameId)) === '1';
} catch {
return true; // storage blocked: don't nag every game
}
}
function markQuickStartSeen(gameId: string) {
try {
localStorage.setItem(seenKey(gameId), '1');
} catch {
/* ignore */
}
}
/** The 3 summary cards with [다음] / [건너뛰기] / [시작하기]. */
export function QuickStartCards({ title, cards, onClose, finish = '시작하기' }: { title: string; cards: QuickCard[]; onClose: () => void; finish?: string }) {
const [i, setI] = useState(0);
const card = cards[i]!;
const last = i === cards.length - 1;
return (
<Sheet onClose={onClose} label={`${title} 30초 규칙 요약`}>
<p className="muted small" style={{ margin: 0, textAlign: 'center' }}>
{title} 30초 규칙 요약 · {i + 1}/{cards.length}
</p>
<div
role="img"
aria-label={card.alt}
style={{ minHeight: 170, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, flexWrap: 'wrap', padding: 12, borderRadius: 16, background: 'var(--bg)' }}
>
{card.pic}
</div>
<h2 style={{ margin: 0, textAlign: 'center' }} aria-live="polite">
{card.title}
</h2>
<p style={{ margin: 0, fontSize: '1.15rem', lineHeight: 1.6, textAlign: 'center' }}>{card.text}</p>
<div className="row" style={{ justifyContent: 'center', gap: 6 }} aria-hidden>
{cards.map((_, k) => (
<span key={k} className="dot" style={{ width: 10, height: 10, background: k === i ? 'var(--primary)' : 'var(--border)' }} />
))}
</div>
{last ? (
<button className="btn btn-primary btn-block" style={{ minHeight: 60 }} onClick={onClose} autoFocus>
{finish}
</button>
) : (
<div className="row" style={{ flexWrap: 'nowrap' }}>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
건너뛰기
</button>
<button className="btn btn-primary" style={{ flex: 2, minHeight: 60 }} onClick={() => setI(i + 1)}>
다음 →
</button>
</div>
)}
</Sheet>
);
}
/** "30초 요약 보기" button for places outside a game (rules page). */
export function QuickStartButton({ title, cards }: { title: string; cards: QuickCard[] | undefined }) {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
if (!cards?.length) return null;
return (
<>
<button className="btn btn-secondary btn-block" onClick={() => setOpen(true)}>
🃏 30초 요약 보기
</button>
{open && <QuickStartCards title={title} cards={cards} finish="닫기" onClose={close} />}
</>
);
}
/**
* [?] button for the room bar while a game is on screen. Also opens the summary cards by itself
* the first time this device sees a running game of this kind.
*/
export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui: GameUI; title: string; me: string; players: Record<string, PublicUser> }) {
const view = useRoom((s) => s.game?.view);
const [open, setOpen] = useState(false);
const [rules, setRules] = useState(false);
const [cards, setCards] = useState(false);
const playing = room.status === 'playing';
const gameId = room.gameId;
useEffect(() => {
if (playing && ui.quickStart?.length && !quickStartSeen(gameId)) setCards(true);
}, [playing, gameId, ui]);
// Stable callbacks: Sheet re-runs its focus effect when onClose changes (every game update otherwise).
const closeCards = useCallback(() => {
markQuickStartSeen(gameId);
setCards(false);
}, [gameId]);
const closeHelp = useCallback(() => setOpen(false), []);
const seated = room.seats.some((s) => s?.user.id === me);
const now =
room.status === 'paused'
? '오류로 게임이 멈췄어요. 방장이 메뉴에서 게임을 끝낼 수 있어요.'
: view === undefined
? ''
: (ui.helpNow ?? ui.statusText)(view, me, players);
return (
<>
<button className="btn btn-ghost btn-sm" onClick={() => (setRules(false), setOpen(true))} aria-label="도움말: 지금 할 수 있는 것" aria-haspopup="dialog">
❓ 도움
</button>
{open &&
createPortal(
<Sheet onClose={closeHelp} label="지금 할 수 있는 것">
<h2 style={{ margin: 0 }}>지금 할 수 있는 것</h2>
{!seated && <p className="muted" style={{ margin: 0 }}>지금은 구경하고 있어요. 게임에는 손댈 수 없어요.</p>}
<p className="help-now" style={{ margin: 0, fontSize: '1.2rem', lineHeight: 1.6, whiteSpace: 'pre-line', fontWeight: 600 }}>
{now}
</p>
<div className="stack" style={{ gap: 8 }}>
<button className="btn btn-secondary btn-block" aria-expanded={rules} onClick={() => setRules(!rules)}>
📖 {rules ? '규칙 접기' : '전체 규칙 보기'}
</button>
{rules && (
<section className="card stack" aria-label={`${title} 규칙`}>
<ui.Rules options={room.options} />
</section>
)}
{!!ui.quickStart?.length && (
<button className="btn btn-secondary btn-block" onClick={() => (setOpen(false), setCards(true))}>
🃏 30초 요약 다시 보기
</button>
)}
<button className="btn btn-primary btn-block" onClick={() => setOpen(false)}>
닫기
</button>
</div>
</Sheet>,
document.body,
)}
{cards && ui.quickStart && createPortal(<QuickStartCards title={title} cards={ui.quickStart} onClose={closeCards} />, document.body)}
</>
);
}