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 { TurnTimer, useServerNow } from '../../components/TurnTimer';
|
||||||
import { haptic, sounds } from '../../lib/feedback';
|
import { haptic, sounds } from '../../lib/feedback';
|
||||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||||
|
import { helpNow } from './help';
|
||||||
|
import { quickStart } from './quick';
|
||||||
|
|
||||||
type Color = 'black' | 'white';
|
type Color = 'black' | 'white';
|
||||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||||
@@ -483,6 +485,8 @@ const ui: GameUI<OmokView, OmokOptions> = {
|
|||||||
Options,
|
Options,
|
||||||
optionsSummary,
|
optionsSummary,
|
||||||
Rules,
|
Rules,
|
||||||
|
quickStart,
|
||||||
|
helpNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ui;
|
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';
|
import type { PublicUser } from '@bg/shared/lite';
|
||||||
|
|
||||||
export interface BoardProps<V = unknown> {
|
export interface BoardProps<V = unknown> {
|
||||||
@@ -17,6 +17,16 @@ export interface OptionsProps<O = unknown> {
|
|||||||
onChange: (next: O) => void;
|
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> {
|
export interface GameUI<V = any, O = any> {
|
||||||
/** The board / table only (center of the game screen). */
|
/** The board / table only (center of the game screen). */
|
||||||
Board: ComponentType<BoardProps<V>>;
|
Board: ComponentType<BoardProps<V>>;
|
||||||
@@ -29,4 +39,8 @@ export interface GameUI<V = any, O = any> {
|
|||||||
Rules: ComponentType<{ options?: O }>;
|
Rules: ComponentType<{ options?: O }>;
|
||||||
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
|
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
|
||||||
ownChat?: boolean;
|
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 { sounds } from '../lib/feedback';
|
||||||
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
|
import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel';
|
||||||
import { useWide } from '../lib/media';
|
import { useWide } from '../lib/media';
|
||||||
|
import { GameHelp } from '../components/GameHelp';
|
||||||
|
|
||||||
export default function RoomPage() {
|
export default function RoomPage() {
|
||||||
const { code = '' } = useParams();
|
const { code = '' } = useParams();
|
||||||
@@ -159,6 +160,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
|||||||
</strong>
|
</strong>
|
||||||
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
|
{inGame && game && <GameHelp room={room} ui={ui} title={g.nameKo} me={me.id} players={players} />}
|
||||||
{!roomChatClosed && (
|
{!roomChatClosed && (
|
||||||
<span className="phone-only">
|
<span className="phone-only">
|
||||||
<ChatButton onOpen={() => setChatOpen(true)} />
|
<ChatButton onOpen={() => setChatOpen(true)} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TopBar } from '../components/ui';
|
|||||||
import { loadGameUI } from '../games/registry';
|
import { loadGameUI } from '../games/registry';
|
||||||
import type { GameUI } from '../games/types';
|
import type { GameUI } from '../games/types';
|
||||||
import { GameNotice, gameName } from '../components/GameNotice';
|
import { GameNotice, gameName } from '../components/GameNotice';
|
||||||
|
import { QuickStartButton } from '../components/GameHelp';
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const { gameId = '' } = useParams();
|
const { gameId = '' } = useParams();
|
||||||
@@ -18,6 +19,7 @@ export default function RulesPage() {
|
|||||||
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<GameNotice gameId={gameId} />
|
<GameNotice gameId={gameId} />
|
||||||
|
{ui && <QuickStartButton title={g.nameKo} cards={ui.quickStart} />}
|
||||||
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user