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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/games/help-kit.test-util.ts
Normal file
66
apps/web/src/games/help-kit.test-util.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Test helpers for the beginner help (docs/07 §5): real views from the rule modules, so help text
|
||||
* is checked against the actual view shape. Only imported by *.test.ts files.
|
||||
*/
|
||||
import { GAMES } from '@bg/games';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { SeededRng, type RngState } from '../../../../packages/engine/src/rng';
|
||||
|
||||
export const NICKS = ['호랑이', '고양이', '토끼', '거북이', '여우', '사슴', '곰', '부엉이', '다람쥐', '오리'];
|
||||
|
||||
/** Player ids are 'A', 'B', ...; nicknames from NICKS. */
|
||||
export function seatIds(n: number): string[] {
|
||||
return Array.from({ length: n }, (_, i) => String.fromCharCode(65 + i));
|
||||
}
|
||||
|
||||
export function usersOf(ids: string[]): Record<string, PublicUser> {
|
||||
return Object.fromEntries(ids.map((id, i) => [id, { id, nickname: NICKS[i % NICKS.length]!, avatar: null, kind: 'guest' as const }]));
|
||||
}
|
||||
|
||||
/** Fresh state for a game with `n` players and options merged over the defaults. */
|
||||
export function setupGame(id: string, n: number, options: Record<string, unknown> = {}, seed: RngState = [1, 2, 3, 4], now = 1_700_000_000_000) {
|
||||
const def = GAMES[id]!;
|
||||
const players = seatIds(n);
|
||||
const opts = def.optionsSchema.parse({ ...(def.defaultOptions as object), ...options });
|
||||
const rng = SeededRng.fromSeed(seed);
|
||||
const state = def.setup({ players, options: opts, rng, now });
|
||||
return { def, players, rng, state: state as any, now };
|
||||
}
|
||||
|
||||
/** Apply an action (validated) and return the new state. */
|
||||
export function applyAction(id: string, state: any, actor: string, action: unknown, rng = SeededRng.fromSeed([9, 9, 9, 9]), now = 1_700_000_100_000): any {
|
||||
const def = GAMES[id]!;
|
||||
const v = def.validate(state, actor, action, { now });
|
||||
if (!v.ok) throw new Error(`${id}: rejected ${JSON.stringify(action)}: ${v.reason}`);
|
||||
return def.apply(state, actor, action, { rng, now }).state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random playout that hands every intermediate view (each player + spectator) to `visit`.
|
||||
* Uses legal actions when the module lists them, otherwise the timeout action.
|
||||
*/
|
||||
export function eachView(id: string, n: number, visit: (view: any, viewer: string | null, state: any) => void, { steps = 400, seed = [5, 6, 7, 8] as RngState, options = {} as Record<string, unknown> } = {}) {
|
||||
const { def, players, rng, state: s0 } = setupGame(id, n, options, seed);
|
||||
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
|
||||
let state = s0;
|
||||
let now = 1_700_000_000_000;
|
||||
for (let step = 0; step <= steps; step++) {
|
||||
for (const p of [...players, null]) visit(def.view(state, p), p, state);
|
||||
if (def.result(state)) return;
|
||||
const actives = def.activePlayers(state);
|
||||
if (actives.length === 0) return;
|
||||
const actor = actives[chooser.int(actives.length)]!;
|
||||
const legal = def.legalActions?.(state, actor) ?? [];
|
||||
const action = legal.length > 0 ? legal[chooser.int(legal.length)] : def.onTimeout(state, actor);
|
||||
now += 1000;
|
||||
if (!def.validate(state, actor, action, { now }).ok) return;
|
||||
state = def.apply(state, actor, action, { rng, now }).state;
|
||||
}
|
||||
}
|
||||
|
||||
/** Help text must read as finished Korean: no leaked placeholders. */
|
||||
export function assertCleanText(text: string, where: string) {
|
||||
if (typeof text !== 'string' || text.trim().length === 0) throw new Error(`${where}: empty help`);
|
||||
const bad = /undefined|NaN|null|\[object|\$\{|님님/.exec(text);
|
||||
if (bad) throw new Error(`${where}: "${bad[0]}" in help: ${text}`);
|
||||
}
|
||||
47
apps/web/src/games/help.test.ts
Normal file
47
apps/web/src/games/help.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/** Beginner help (docs/07 §5) for every game: 3 summary cards, and help text on every view of a random game. */
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { GAME_UI } from './registry';
|
||||
import { assertCleanText, eachView, seatIds, usersOf } from './help-kit.test-util';
|
||||
|
||||
const IDS = Object.keys(GAME_UI);
|
||||
|
||||
test('모든 게임에 도움말이 있어요', () => {
|
||||
expect(IDS).toHaveLength(26);
|
||||
for (const id of IDS) {
|
||||
const src = readFileSync(join(import.meta.dir, id, 'index.tsx'), 'utf8');
|
||||
expect(src, id).toMatch(/\bquickStart,/);
|
||||
expect(src, id).toMatch(/\bhelpNow,/);
|
||||
}
|
||||
});
|
||||
|
||||
describe.each(IDS)('%s', (id) => {
|
||||
test('30초 요약 카드 3장', async () => {
|
||||
const { quickStart } = await import(`./${id}/quick`);
|
||||
expect(quickStart).toHaveLength(3);
|
||||
for (const c of quickStart) {
|
||||
expect(c.pic).toBeTruthy();
|
||||
expect(c.alt.length).toBeGreaterThan(4);
|
||||
expect(c.title.length).toBeGreaterThan(1);
|
||||
expect(c.title.length).toBeLessThanOrEqual(20);
|
||||
expect(c.text.length).toBeGreaterThan(10);
|
||||
expect(c.text.length).toBeLessThanOrEqual(110);
|
||||
}
|
||||
});
|
||||
|
||||
test('무작위 한 판의 모든 화면(각 자리 + 관전)에서 지금 할 수 있는 것', async () => {
|
||||
const { helpNow } = await import(`./${id}/help`);
|
||||
const def = GAMES[id]!;
|
||||
const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers };
|
||||
const n = Math.min(range.max, Math.max(range.min, 3));
|
||||
const users = usersOf(seatIds(n));
|
||||
let count = 0;
|
||||
eachView(id, n, (view, who) => {
|
||||
assertCleanText(helpNow(view, who, users), `${id} viewer=${who}`);
|
||||
count++;
|
||||
});
|
||||
expect(count).toBeGreaterThan(n);
|
||||
});
|
||||
});
|
||||
26
apps/web/src/games/omok/help.test.ts
Normal file
26
apps/web/src/games/omok/help.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { helpNow } from './help';
|
||||
|
||||
const users = usersOf(['A', 'B']);
|
||||
const view = (s: unknown, who: string | null) => GAMES.omok!.view(s, who) as any;
|
||||
|
||||
test('오목: 내 차례 / 상대 차례 / 관전 / 첫 수 가운데', () => {
|
||||
const { state } = setupGame('omok', 2, { colorAssignment: 'hostBlack', ruleSet: 'renju' });
|
||||
expect(helpNow(view(state, 'A'), 'A', users)).toContain('첫 수는 판 한가운데');
|
||||
expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요');
|
||||
expect(helpNow(view(state, null), null, users)).toContain('호랑이님(흑) 차례예요');
|
||||
const s2 = applyAction('omok', state, 'A', { type: 'place', x: 7, y: 7 });
|
||||
const t = helpNow(view(s2, 'B'), 'B', users);
|
||||
expect(t).toContain('내 차례예요(백)');
|
||||
expect(t).toContain('5개');
|
||||
});
|
||||
|
||||
test('오목: 무르기 요청을 받으면 수락/거절 안내', () => {
|
||||
const { state } = setupGame('omok', 2, { colorAssignment: 'hostBlack', ruleSet: 'free' });
|
||||
let s = applyAction('omok', state, 'A', { type: 'place', x: 7, y: 7 });
|
||||
s = applyAction('omok', s, 'A', { type: 'requestUndo' });
|
||||
expect(helpNow(view(s, 'B'), 'B', users)).toContain('[수락]');
|
||||
expect(helpNow(view(s, 'A'), 'A', users)).toContain('무르기 요청');
|
||||
});
|
||||
25
apps/web/src/games/omok/help.ts
Normal file
25
apps/web/src/games/omok/help.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** 오목 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { OmokView } from '@bg/games/omok';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
const COLOR_KO = { black: '흑', white: '백' } as const;
|
||||
|
||||
export function helpNow(v: OmokView, _me: string | null, players: Record<string, PublicUser>): string {
|
||||
const goal = '가로·세로·대각선 중 한 줄로 내 돌 5개를 먼저 이으면 이겨요.';
|
||||
if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.';
|
||||
const turnNick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||
if (!v.myColor) return `${turnNick}님(${COLOR_KO[v.turn]}) 차례예요. ${goal}`;
|
||||
const mine = COLOR_KO[v.myColor];
|
||||
if (v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 부탁했어요. [수락]을 누르면 돌이 되돌아가고, [거절]을 누르면 그대로 계속해요.';
|
||||
if (v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.';
|
||||
if (v.myColor !== v.turn) {
|
||||
const waiting = v.undo.pending ? ' 내 무르기 요청에 상대가 답하기를 기다리는 중이에요.' : v.draw.pendingBy ? ' 내 무승부 제안에 상대가 답하기를 기다리는 중이에요.' : '';
|
||||
return `상대(${turnNick}님) 차례예요. 상대가 돌을 놓을 때까지 기다려 주세요.${waiting} 나는 ${mine}이에요.`;
|
||||
}
|
||||
if (v.moveCount === 0 && v.firstMoveCenter) return `내 차례예요(${mine}). 첫 수는 판 한가운데에만 놓을 수 있어요.`;
|
||||
const lines = [`내 차례예요(${mine}). 빈 곳을 눌러 돌을 놓아요. ${goal}`];
|
||||
if (v.hints?.urgent.length) lines.push('빨간 동그라미 자리를 막지 않으면 상대가 다음에 5개를 이어요!');
|
||||
else if (v.hints?.warn.length) lines.push('노란 점선 동그라미는 상대가 4개를 만들 수 있는 자리예요. 막는 게 좋아요.');
|
||||
if (v.forbidden.length) lines.push('빨간 ✕ 자리는 금수라서 둘 수 없어요.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui';
|
||||
import { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
type Color = 'black' | 'white';
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
@@ -483,6 +485,8 @@ const ui: GameUI<OmokView, OmokOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
47
apps/web/src/games/omok/quick.tsx
Normal file
47
apps/web/src/games/omok/quick.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/** 오목 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
function MiniBoard({ stones, marks = [] }: { stones: [number, number, 'b' | 'w'][]; marks?: [number, number][] }) {
|
||||
const n = 7;
|
||||
return (
|
||||
<svg viewBox="-0.7 -0.7 7.4 7.4" width={170} height={170} aria-hidden>
|
||||
<rect x={-0.7} y={-0.7} width={7.4} height={7.4} rx={0.4} fill="#e9c27a" />
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<g key={i} stroke="#7a5a2a" strokeWidth={0.05}>
|
||||
<line x1={0} y1={i} x2={n - 1} y2={i} />
|
||||
<line x1={i} y1={0} x2={i} y2={n - 1} />
|
||||
</g>
|
||||
))}
|
||||
{stones.map(([x, y, c]) => (
|
||||
<circle key={`${x}-${y}`} cx={x} cy={y} r={0.44} fill={c === 'b' ? '#151515' : '#fbfbf8'} stroke={c === 'b' ? '#000' : '#666'} strokeWidth={0.06} />
|
||||
))}
|
||||
{marks.map(([x, y]) => (
|
||||
<g key={`m${x}-${y}`} stroke="#b91c1c" strokeWidth={0.12}>
|
||||
<line x1={x - 0.25} y1={y - 0.25} x2={x + 0.25} y2={y + 0.25} />
|
||||
<line x1={x + 0.25} y1={y - 0.25} x2={x - 0.25} y2={y + 0.25} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const quickStart: QuickCard[] = [
|
||||
{
|
||||
pic: <MiniBoard stones={[[3, 3, 'b'], [4, 3, 'w'], [3, 4, 'b'], [2, 2, 'w']]} />,
|
||||
alt: '바둑판 위에 검은 돌과 흰 돌이 번갈아 놓여 있어요',
|
||||
title: '번갈아 한 개씩 놓아요',
|
||||
text: '흑이 먼저 시작해요. 내 차례에 빈 곳(줄이 만나는 점)을 누르면 돌이 놓여요.',
|
||||
},
|
||||
{
|
||||
pic: <MiniBoard stones={[[1, 5, 'b'], [2, 4, 'b'], [3, 3, 'b'], [4, 2, 'b'], [5, 1, 'b'], [2, 3, 'w'], [4, 4, 'w']]} />,
|
||||
alt: '검은 돌 5개가 대각선으로 이어져 있어요',
|
||||
title: '5개를 이으면 이겨요',
|
||||
text: '가로·세로·대각선 어느 쪽이든 내 돌 5개를 먼저 한 줄로 이으면 이겨요.',
|
||||
},
|
||||
{
|
||||
pic: <MiniBoard stones={[[2, 3, 'b'], [3, 3, 'b'], [4, 4, 'b'], [4, 5, 'b'], [1, 1, 'w'], [5, 1, 'w']]} marks={[[4, 3]]} />,
|
||||
alt: '빨간 ✕ 표시가 있는 자리는 둘 수 없어요',
|
||||
title: '✕ 자리는 못 둬요',
|
||||
text: '규칙에 따라 둘 수 없는 자리(금수)는 ✕로 보여 줘요. 상대가 4개를 이으면 꼭 막아요.',
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
export interface BoardProps<V = unknown> {
|
||||
@@ -17,6 +17,16 @@ export interface OptionsProps<O = unknown> {
|
||||
onChange: (next: O) => void;
|
||||
}
|
||||
|
||||
/** One card of the "30초 규칙 요약" (docs/07 §5): picture first, a sentence or two. */
|
||||
export interface QuickCard {
|
||||
/** Picture: inline SVG / existing card components (no image files). Decorative; `alt` describes it. */
|
||||
pic: ReactNode;
|
||||
/** What the picture shows, for screen readers. */
|
||||
alt: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface GameUI<V = any, O = any> {
|
||||
/** The board / table only (center of the game screen). */
|
||||
Board: ComponentType<BoardProps<V>>;
|
||||
@@ -29,4 +39,8 @@ export interface GameUI<V = any, O = any> {
|
||||
Rules: ComponentType<{ options?: O }>;
|
||||
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
|
||||
ownChat?: boolean;
|
||||
/** 30초 규칙 요약: 3 cards shown the first time this device plays the game (docs/07 §5). */
|
||||
quickStart?: QuickCard[];
|
||||
/** [?] "지금 할 수 있는 것": the current situation in plain words for this viewer (docs/07 §5). */
|
||||
helpNow?(view: V, me: string | null, players: Record<string, PublicUser>): string;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GameUI } from '../games/types';
|
||||
import { sounds } from '../lib/feedback';
|
||||
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
|
||||
import { useWide } from '../lib/media';
|
||||
import { GameHelp } from '../components/GameHelp';
|
||||
|
||||
export default function RoomPage() {
|
||||
const { code = '' } = useParams();
|
||||
@@ -159,6 +160,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
</strong>
|
||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||
<span style={{ flex: 1 }} />
|
||||
{inGame && game && <GameHelp room={room} ui={ui} title={g.nameKo} me={me.id} players={players} />}
|
||||
{!roomChatClosed && (
|
||||
<span className="phone-only">
|
||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TopBar } from '../components/ui';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
import { QuickStartButton } from '../components/GameHelp';
|
||||
|
||||
export default function RulesPage() {
|
||||
const { gameId = '' } = useParams();
|
||||
@@ -18,6 +19,7 @@ export default function RulesPage() {
|
||||
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
||||
<main className="page">
|
||||
<GameNotice gameId={gameId} />
|
||||
{ui && <QuickStartButton title={g.nameKo} cards={ui.quickStart} />}
|
||||
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
||||
</main>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user