M9: 게임 26종 30초 요약 카드·[?] 지금 할 수 있는 것 내용

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:35:21 +09:00
parent 32e3e89d8d
commit 68edd9b434
102 changed files with 6555 additions and 1 deletions

View File

@@ -0,0 +1,47 @@
import { expect, test } from 'bun:test';
import { applyAction, setupGame, usersOf } from '../help-kit.test-util';
import { GAMES } from '@bg/games';
import { answerArt, pairsFor } from '@bg/games/dobble';
import { helpNow } from './help';
const users = usersOf(['A', 'B', 'C']);
const view = (s: unknown, who: string | null) => GAMES.dobble!.view(s, who) as any;
function playing(options: Record<string, unknown> = {}) {
const { state } = setupGame('dobble', 3, options);
state.phase = 'playing';
return state;
}
test('도블: 카운트다운 / 탑 쌓기 진행 중 / 관전', () => {
const { state: s0 } = setupGame('dobble', 3);
expect(helpNow(view(s0, 'A'), 'A', users)).toContain('곧 카드가 공개돼요');
const s = playing();
const t = helpNow(view(s, 'A'), 'A', users);
expect(t).toContain('탑 쌓기');
expect(t).toContain('가운데 카드와 내 카드');
expect(t).toContain('1초 동안');
const spec = helpNow(view(s, null), null, users);
expect(spec).toContain('게임 중이에요');
expect(spec).not.toContain('누르면 돼요');
});
test('도블: 뜨거운 감자 — 대상 고르기, 탈출, 라운드 끝', () => {
const s = playing({ miniGame: 'hotPotato', wrongLockoutMs: 0 });
expect(helpNow(view(s, 'A'), 'A', users)).toContain('넘길 사람을 먼저 골라요');
expect(helpNow(view(s, 'A'), 'A', users)).toContain('잠기지 않아요');
const pair = pairsFor(s, 'A')[0]!;
const s2 = applyAction('dobble', s, 'A', { type: 'claim', base: pair.base, target: pair.target!, mine: pair.mine!, symbol: answerArt(s, 'A', pair)! });
expect(helpNow(view(s2, 'A'), 'A', users)).toContain('탈출했어요');
s2.phase = 'roundEnd';
s2.lastRound = { round: 1, loser: 'B', cards: 2 };
expect(helpNow(view(s2, 'C'), 'C', users)).toContain('고양이님이 카드 2장을 벌점으로');
});
test('도블: 독이 든 선물 목표와 게임 끝', () => {
const s = playing({ miniGame: 'poisonedGift' });
expect(helpNow(view(s, 'B'), 'B', users)).toContain('가장 적게 받으면 1등');
s.phase = 'ended';
s.final = { miniGame: 'poisonedGift', ranking: [], stats: {}, summary: '독이 든 선물 1등 호랑이님!' };
expect(helpNow(view(s, 'B'), 'B', users)).toContain('1등 호랑이님');
});

View File

@@ -0,0 +1,45 @@
/** 도블 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { DobbleView, MiniGame } from '@bg/games/dobble';
import type { PublicUser } from '@bg/shared/lite';
const MINI_KO: Record<MiniGame, string> = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' };
/** What to compare, what a find does, how to win (§5.2). */
const GOAL: Record<MiniGame, string> = {
tower: '가운데 카드와 내 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 가운데 카드를 내 탑으로 가져와요. 카드를 가장 많이 모으면 1등이에요.',
well: '가운데 카드와 내 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 내 카드를 가운데에 내려놓아요. 내 카드를 먼저 다 비우면 1등이에요.',
hotPotato:
'내 카드와 다른 사람 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 내 카드 묶음을 그 사람에게 넘기고 이번 라운드는 빠져요. 마지막까지 카드를 들고 있으면 그 카드가 벌점이에요.',
poisonedGift: '가운데 카드와 다른 사람 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등이에요.',
};
export function helpNow(v: DobbleView, me: string | null, players: Record<string, PublicUser>): string {
const nick = (id: string) => players[id]?.nickname ?? '다른 사람';
const mode = v.options.miniGame;
const name = MINI_KO[mode];
if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위에서 각자 카드 수와 찾은 횟수를 볼 수 있어요.';
const round = mode === 'hotPotato' ? ` (${v.round}/${v.rounds}라운드)` : '';
if (v.phase === 'countdown') return `곧 카드가 공개돼요. 이번 게임은 '${name}'${round}이에요. 차례 없이 모두 동시에 찾아요.\n${GOAL[mode]}`;
if (v.phase === 'roundEnd') {
const r = v.lastRound;
const who = r?.loser ? (r.loser === me ? '내가' : `${nick(r.loser)}님이`) : null;
const what = r && who ? `${r.round}라운드에서 ${who} 카드 ${r.cards}장을 벌점으로 받았어요. ` : '';
return `라운드가 끝났어요. ${what}잠시 뒤 다음 라운드가 시작돼요.`;
}
const mine = v.seats.find((s) => s.id === me) ?? null;
if (!mine) return `'${name}' 게임 중이에요${round}. 모두 동시에 두 카드의 같은 그림을 찾고 있어요.\n${GOAL[mode]}`;
if (mine.status === 'finished') return `내 카드를 다 비웠어요!${mine.place ? ` ${mine.place}등이에요.` : ''} 남은 사람들의 게임을 지켜봐요.`;
if (mine.status === 'escaped') return `이번 라운드는 탈출했어요${round}. 남은 사람들이 카드를 넘기는 걸 지켜보고 다음 라운드를 기다려요.`;
if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요. 남은 사람들의 게임을 지켜봐요.';
if (v.pairs.length === 0) return `지금은 비교할 카드가 없어요. 잠시 기다려 주세요.\n${GOAL[mode]}`;
const lines = [`'${name}'${round} 게임 중이에요. 차례 없이 모두 동시에 찾아요. 어느 두 카드든 똑같은 그림은 딱 하나예요.`, GOAL[mode]];
lines.push(`두 카드 중 아무 쪽에서나 그 그림을 누르면 돼요.${v.options.symbolStyle === 'classic' ? ' 크기와 방향이 달라도 같은 그림이에요.' : ''}`);
if ((mode === 'hotPotato' || mode === 'poisonedGift') && v.pairs.length > 1) {
lines.push(mode === 'poisonedGift' ? '위쪽 사람 목록에서 선물을 줄 사람을 먼저 골라요.' : '위쪽 사람 목록에서 카드를 넘길 사람을 먼저 골라요.');
}
const o = v.options;
const lock = o.wrongLockoutMs ? `틀린 그림을 누르면 ${o.wrongLockoutMs / 1000}초 동안 못 눌러요.` : '이 방은 틀려도 잠기지 않아요.';
const give = mode === 'tower' && o.wrongGivesCard ? ' 틀리면 내 탑 맨 위 카드도 1장 돌려줘요.' : '';
lines.push(lock + give);
return lines.join('\n');
}

View File

@@ -7,6 +7,8 @@ import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { socket, useRoom } from '../../net/socket';
import type { BoardProps, GameUI, OptionsProps } from '../types';
import { helpNow } from './help';
import { quickStart } from './quick';
import { ICON_NAMES, SymbolArt } from './art';
/** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */
@@ -849,6 +851,8 @@ const ui: GameUI<DobbleView, DobbleOptions> = {
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View File

@@ -0,0 +1,98 @@
/** 도블 30초 규칙 요약 (docs/07 §5). Pictures reuse the game's own icons (art.tsx). */
import type { ReactNode } from 'react';
import { ICONS } from './art';
import type { QuickCard } from '../types';
// Icon ids (art.tsx order): 사과 0, 해 1, 별 3, 하트 4, 꽃 11, 고양이 18, 물고기 20, 열쇠 28, 모자 36, 종 38, 자동차 41, 주사위 45.
const SLOTS: [number, number][] = [
[0, 0],
[0, -26],
[24, -12],
[24, 14],
[0, 28],
[-24, 14],
[-24, -12],
];
function Icon({ art, x, y, size = 20, ring }: { art: number; x: number; y: number; size?: number; ring?: 'ok' | 'bad' }) {
const draw = ICONS[art];
return (
<g>
{ring && <circle cx={x} cy={y} r={size * 0.62} fill={ring === 'ok' ? '#bbf7d0' : '#fecaca'} stroke={ring === 'ok' ? '#16a34a' : '#dc2626'} strokeWidth={2.5} />}
<g transform={`translate(${x - size / 2} ${y - size / 2}) scale(${size / 100})`}>{draw ? draw() : null}</g>
</g>
);
}
/** A round card at (cx, cy) with up to 7 icons; `rings` marks icons by id. */
function RoundCard({ cx, cy, arts, rings = {} }: { cx: number; cy: number; arts: number[]; rings?: Record<number, 'ok' | 'bad'> }) {
return (
<g>
<circle cx={cx} cy={cy} r={50} fill="#fff" stroke="#334155" strokeWidth={2.5} />
{arts.map((a, i) => (
<Icon key={a} art={a} x={cx + SLOTS[i]![0]} y={cy + SLOTS[i]![1]} ring={rings[a]} />
))}
</g>
);
}
function Pic({ children }: { children: ReactNode }) {
return (
<svg viewBox="0 0 250 150" width={250} height={150} aria-hidden>
{children}
</svg>
);
}
const A = [18, 0, 1, 3, 11, 28, 45];
const B = [20, 41, 18, 36, 4, 38, 33];
export const quickStart: QuickCard[] = [
{
pic: (
<Pic>
<RoundCard cx={64} cy={70} arts={A} />
<RoundCard cx={186} cy={70} arts={B} />
<text x={64} y={140} textAnchor="middle" fontSize={13} fontWeight={700} fill="currentColor">
가운데 카드
</text>
<text x={186} y={140} textAnchor="middle" fontSize={13} fontWeight={700} fill="currentColor">
내 카드
</text>
</Pic>
),
alt: '그림이 여러 개 그려진 동그란 카드 두 장이 나란히 있어요',
title: '두 카드를 비교해요',
text: '동그란 카드 두 장에는 똑같은 그림이 언제나 딱 하나 있어요. 차례 없이 모두 동시에 그 그림을 찾아요.',
},
{
pic: (
<Pic>
<RoundCard cx={64} cy={70} arts={A} rings={{ 18: 'ok' }} />
<RoundCard cx={186} cy={70} arts={B} rings={{ 18: 'ok', 20: 'bad' }} />
<text x={125} y={142} textAnchor="middle" fontSize={14} fontWeight={800} fill="#16a34a">
고양이 찾았다!
</text>
</Pic>
),
alt: '두 카드 모두에 있는 고양이 그림에 초록 동그라미, 한쪽에만 있는 물고기에 빨간 동그라미가 있어요',
title: '같은 그림을 먼저 눌러요',
text: '찾은 그림을 두 카드 중 아무 쪽에서나 누르면 돼요. 가장 먼저 누른 사람만 성공이에요. 틀리면 방 규칙에 따라 잠깐 못 눌러요.',
},
{
pic: (
<Pic>
{[0, 1, 2, 3].map((i) => (
<ellipse key={i} cx={90} cy={120 - i * 14} rx={50} ry={16} fill="#fff" stroke="#334155" strokeWidth={2.5} />
))}
<Icon art={18} x={90} y={78} size={22} />
<text x={190} y={100} textAnchor="middle" fontSize={56} aria-hidden>
🏆
</text>
</Pic>
),
alt: '동그란 카드가 탑처럼 쌓여 있고 옆에 트로피가 있어요',
title: '미니게임마다 목표가 달라요',
text: '탑 쌓기는 많이 모으면, 우물은 내 카드를 먼저 비우면, 독이 든 선물은 적게 받으면, 뜨거운 감자는 벌점이 적으면 이겨요.',
},
];