M9: 초보자 도움 틀 — 30초 규칙 요약 카드, 게임 중 [?] 지금 할 수 있는 것, 오목 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
156
apps/web/src/components/GameHelp.tsx
Normal file
156
apps/web/src/components/GameHelp.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user