Merge branch 'm5/halli-galli' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
This commit is contained in:
EJClaw
2026-10-05 14:07:23 +09:00
13 changed files with 2722 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
/** 할리갈리 through a real Room: bell arrival order, "늦었어요" rejects, away players' auto flips (docs/games/halli-galli.md §5.4, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { CARDS, type HalliGalliState } from '@bg/games/halli-galli';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { 'halli-galli': GAMES['halli-galli']! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'halli-galli',
options: games['halli-galli'].defaultOptions,
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'halli-galli', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as HalliGalliState;
return { room, clock, conns, state };
}
/** Moves the named cards (fruit + count) from wherever they are onto the players' face-up piles. */
function placeTops(s: HalliGalliState, tops: Record<string, [string, number]>): void {
for (const p of s.order) {
s.decks[p]!.push(...s.faceUp[p]!);
s.faceUp[p] = [];
}
for (const [p, [fruit, count]] of Object.entries(tops)) {
const c = CARDS.find((x) => x.fruit === fruit && x.count === count && !Object.values(s.faceUp).flat().includes(x.id))!;
for (const q of s.order) s.decks[q] = s.decks[q]!.filter((id) => id !== c.id);
s.pot = s.pot.filter((id) => id !== c.id);
s.faceUp[p]!.push(c.id);
}
}
describe('할리갈리 room flow', () => {
test('거의 동시에 친 두 종: 서버 도착 순서로 먼저 온 사람만 수거, 나중 사람은 "늦었어요" 거절(벌칙 없음)', () => {
const { room, conns, state } = harness(['A', 'B', 'C']);
// §5.5: banana 3 + strawberry 2 + banana 2 → banana 5.
const [x, y, z] = state().order as [string, string, string];
placeTops(state(), { [x]: ['banana', 3], [y]: ['strawberry', 2], [z]: ['banana', 2] });
const v = state().tableVersion;
const decks = Object.fromEntries(state().order.map((p) => [p, state().decks[p]!.length]));
const pot = state().pot.length; // 3 players: 2 leftover cards under the bell
// Same millisecond: the room's single-threaded queue decides — B's message is handled first.
room.act('B', 1, { type: 'ring', version: v });
room.act('C', 1, { type: 'ring', version: v });
room.clearTimer();
expect(state().decks.B).toHaveLength(decks.B! + 3 + pot);
expect(state().decks.C).toHaveLength(decks.C!);
expect(state().wrongCount.C).toBe(0);
expect(state().turn).toBe('B');
expect(conns.C!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' }]);
// B got the success echo (state with its cs) and everyone sees who rang.
expect(conns.B!.msgs.some((m) => m.t === 'state' && m.cs === 1)).toBe(true);
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastBell: { player: string; correct: boolean } }).lastBell).toMatchObject({ player: 'B', correct: true });
});
test('틀린 종은 바로 벌칙, 버전이 지난 종은 무시', () => {
const { room, clock, conns, state } = harness(['A', 'B']);
placeTops(state(), { A: ['lime', 1] });
const v = state().tableVersion;
const before = state().decks.B!.length;
room.act('B', 1, { type: 'ring', version: v });
room.clearTimer();
expect(state().decks.B).toHaveLength(before - 1);
// The turn player flips (after the guard) — a bell for the old table is ignored as late.
clock.advance(1000);
room.act(state().turn, 2, { type: 'flip' });
room.clearTimer();
expect(state().tableVersion).toBe(v + 1);
room.act('A', 3, { type: 'ring', version: v });
expect(conns.A!.msgs.find((m) => m.t === 'reject')).toMatchObject({ reason: expect.stringContaining('늦었어요') });
});
test('연결이 끊겨 자리 비움이 된 사람도 자동 뒤집기로 계속 진행(1초 간격, 바쁜 반복 없음)', () => {
const { room, clock, conns, state } = harness(['A', 'B']);
const away = state().turn;
const other = away === 'A' ? 'B' : 'A';
room.disconnect(conns[away]!);
room.clearTimer();
clock.advance(61_000); // grace period (60s) over
const gaps: [string, number][] = [];
for (let i = 0; i < 6; i++) {
const s = state();
const d = room.effectiveDeadline()!;
gaps.push([s.turn, d - s.turnStartedAt]);
if (d > clock.now()) clock.set(d);
const flips = s.faceUp[s.turn]!.length;
room.onTimer();
room.clearTimer();
expect(state().faceUp[s.turn]!.length).toBe(flips + 1);
}
// The first (overdue) flip happens at once; afterwards the away player flips 1s into each turn,
// the connected player gets the normal 3 seconds.
for (const [who, gap] of gaps.slice(1)) expect(gap).toBe(who === other ? 3000 : 1000);
expect(room.status).toBe('playing');
});
});

View File

@@ -766,6 +766,7 @@ export class Room {
if (!g || g.finished || this.status !== 'playing') return null;
const def = this.def;
const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
let best: number | null = null;
for (const u of timed) {
@@ -777,6 +778,7 @@ export class Room {
}
if (d === null) continue;
if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor);
if (notBefore !== null) d = Math.max(d, notBefore);
best = best === null ? d : Math.min(best, d);
}
return best;
@@ -803,6 +805,7 @@ export class Room {
const def = this.def;
const now = this.now();
const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
for (const u of timed) {
let d = base;
@@ -812,6 +815,7 @@ export class Room {
d = d === null ? grace : Math.min(d, grace);
}
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
if (notBefore !== null && d !== null) d = Math.max(d, notBefore);
if (d === null || d > now) continue;
const action = def.onTimeout(g.state, u);
const err = this.runAction(u, action, undefined, true);

View File

@@ -0,0 +1,960 @@
/** 할리갈리 화면 (docs/games/halli-galli.md §9). */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { Fruit, FruitCount, HalliGalliOptions, HalliGalliView, LogEntry, SeatView, TopCard } from '@bg/games/halli-galli';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { socket, useRoom } from '../../net/socket';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum'];
const FRUIT_KO: Record<Fruit, string> = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' };
const FRUIT_INK: Record<Fruit, string> = { strawberry: '#e11d48', banana: '#ca8a04', lime: '#4d7c0f', plum: '#7e22ce' };
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const cardName = (c: Pick<TopCard, 'fruit' | 'count'>) => `${FRUIT_KO[c.fruit]} ${c.count}개`;
const CSS = `
.hg { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
.hg-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; justify-content: space-between; font-weight: 700; }
.hg-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.hg-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; }
.hg-pill.warn { background: var(--warning-soft); color: var(--warning); }
.hg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.hg-table { position: relative; width: 100%; height: clamp(440px, 64vh, 600px); border-radius: 28px; border: 8px solid var(--wood); box-shadow: var(--shadow);
background: radial-gradient(ellipse at center, rgba(255,255,255,.14), rgba(255,255,255,0) 62%), var(--felt); overflow: hidden; touch-action: manipulation; }
.hg-seat { position: absolute; transform: translate(-50%, -50%); width: var(--sw); display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 5px 5px 6px; border-radius: 14px; background: rgba(0,0,0,.28); color: #fff; border: 3px solid transparent; z-index: 2; }
.hg-seat[data-me='true'] { background: rgba(0,0,0,.42); }
.hg-seat[data-turn='true'] { border-color: #fde047; animation: hg-blink 1s ease-in-out infinite; }
.hg-seat[data-out='true'] { opacity: .6; }
.hg-seat .nm { display: flex; align-items: center; gap: 4px; max-width: 100%; font-weight: 800; font-size: .86rem; line-height: 1.2; }
.hg-seat .nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hg-pile { position: relative; width: var(--cw); height: calc(var(--cw) * 1.4); }
.hg-pile .stack { position: absolute; inset: 0; border-radius: 9px; }
.hg-pile .empty { position: absolute; inset: 0; border-radius: 9px; border: 2px dashed rgba(255,255,255,.45); }
.hg-pile .shadow1 { position: absolute; inset: 0; transform: translate(3px, 3px); border-radius: 9px; background: #e7e2d3; border: 1px solid #b9b2a0; }
.hg-pile .face { position: absolute; inset: 0; animation: hg-flip .22s ease-out; }
.hg-pile .gauge { position: absolute; right: -12px; bottom: -8px; width: 30px; height: 30px; }
.hg-meta { display: flex; gap: 6px; align-items: center; font-size: .82rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.hg-back { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; vertical-align: -2px; }
.hg-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; }
.hg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; z-index: 3; }
.hg-bell { width: var(--bell); height: var(--bell); border-radius: 50%; border: 0; padding: 0; background: radial-gradient(circle at 40% 35%, #fff7d1, #fbbf24 45%, #b45309 100%);
box-shadow: 0 8px 0 #7c2d12, 0 12px 24px rgba(0,0,0,.35); cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center;
transition: transform .06s ease, box-shadow .06s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; }
.hg-bell .art { width: 70%; height: 70%; display: block; pointer-events: none; }
.hg-bell .art svg { width: 100%; height: 100%; display: block; }
.hg-bell .art[data-ring='true'] { animation: hg-ring .45s ease-in-out; }
.hg-bell:active:not(:disabled) { transform: translateY(6px) scale(.97); box-shadow: 0 2px 0 #7c2d12, 0 4px 10px rgba(0,0,0,.35); }
.hg-bell:disabled { cursor: not-allowed; filter: grayscale(.85) brightness(.85); }
.hg-bell:focus-visible { outline: 4px solid var(--focus); outline-offset: 6px; }
.hg-bell .lock { position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; }
.hg-bell .lbl { position: absolute; bottom: 14%; left: 0; right: 0; text-align: center; font-weight: 900; color: #451a03; font-size: calc(var(--bell) * .11); pointer-events: none; }
.hg-keys { color: var(--text-sub); font-weight: 600; font-size: .85rem; }
.hg-pot { position: absolute; left: 50%; top: -2px; transform: translateX(-50%); color: #fff; font-weight: 800; font-size: .78rem; z-index: 4; pointer-events: none;
background: #1e3a8a; border: 2px solid #93c5fd; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
.hg-bubble { position: absolute; left: 50%; top: 2px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.25rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: hg-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; }
.hg-bubble.ok { border: 4px solid #16a34a; }
.hg-bubble.bad { border: 4px solid #dc2626; }
.hg-bubble.late { border: 4px solid #6b7280; font-size: .95rem; }
.hg-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.35); color: #fff;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; }
.hg-count small { font-size: 1.1rem; }
.hg-ghost { position: absolute; width: var(--cw); height: calc(var(--cw) * 1.4); transform: translate(-50%, -50%); z-index: 5; pointer-events: none;
animation: hg-fly .6s cubic-bezier(.5,.1,.3,1) forwards; animation-delay: var(--d, 0ms); left: var(--fx); top: var(--fy); }
.hg-mine { display: flex; gap: 10px; align-items: stretch; }
.hg-mine .info { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 8px 12px; display: flex; flex-direction: column; justify-content: center; min-width: 96px; }
.hg-mine .info b { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.hg-flipbtn { flex: 1; min-height: 64px; font-size: 1.25rem !important; }
.hg-kid { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: grid; gap: 6px; }
.hg-bar { display: grid; grid-template-columns: 28px 52px 1fr 28px; gap: 8px; align-items: center; font-weight: 800; }
.hg-bar .track { position: relative; height: 14px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.hg-bar .track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; }
.hg-bar .track::after { content: ''; position: absolute; left: calc(100% * 5 / 8); top: -2px; bottom: -2px; width: 2px; background: var(--text); }
.hg-bar[data-five='true'] { color: var(--success); }
.hg-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; }
.hg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.hg-fruit-ic { width: 26px; height: 26px; display: inline-block; vertical-align: middle; }
@keyframes hg-blink { 0%,100% { box-shadow: 0 0 0 0 rgba(253,224,71,.0); } 50% { box-shadow: 0 0 0 6px rgba(253,224,71,.55); } }
@keyframes hg-flip { from { transform: rotateY(85deg) scale(.9); } to { transform: none; } }
@keyframes hg-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
@keyframes hg-ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(-14deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(5deg); } }
@keyframes hg-fly { from { left: var(--fx); top: var(--fy); opacity: 1; } 85% { opacity: .9; } to { left: var(--tx); top: var(--ty); opacity: 0; transform: translate(-50%, -50%) scale(.6); } }
@media (max-width: 719px) {
.hg-table { height: 450px; border-width: 6px; border-radius: 22px; }
.hg-seat { padding: 3px 3px 4px; gap: 2px; }
.hg-seat .nm { font-size: .78rem; }
.hg-meta { font-size: .76rem; gap: 4px; }
.hg-bubble { font-size: .95rem; padding: 6px 12px; }
}
@media (prefers-reduced-motion: reduce) {
.hg-seat[data-turn='true'] { animation: none; box-shadow: 0 0 0 4px rgba(253,224,71,.55); }
.hg-pile .face, .hg-bell .art[data-ring='true'], .hg-bubble { animation: none; }
.hg-ghost { display: none; }
}
`;
// ---------------------------------------------------------------- fruit art
/** One fruit in a 40×40 box. Distinct silhouettes so colour is never the only cue. */
function FruitShape({ fruit }: { fruit: Fruit }) {
switch (fruit) {
case 'strawberry':
return (
<g>
<path d="M20 38 C9 30 4 20 7.5 13.5 C10.5 8.5 29.5 8.5 32.5 13.5 C36 20 31 30 20 38 Z" fill="#e11d48" stroke="#9f1239" strokeWidth="1.5" />
{[
[14, 17],
[20, 15],
[26, 17],
[12, 23],
[18, 22],
[24, 22],
[28, 23],
[16, 28],
[22, 28],
[19, 33],
].map(([x, y]) => (
<ellipse key={`${x}-${y}`} cx={x} cy={y} rx="1.1" ry="1.6" fill="#fde68a" />
))}
<path d="M11 11 L16 9 L20 4 L24 9 L29 11 L23.5 12.5 L20 15 L16.5 12.5 Z" fill="#16a34a" stroke="#14532d" strokeWidth="1" />
</g>
);
case 'banana':
return (
<g>
<path d="M7 9 C7 27 22 36 36 30 C37 28 36 27 34 27.5 C22 30 13 22 12 8 C11 6 8 6 7 9 Z" fill="#facc15" stroke="#a16207" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M11 12 C12 22 19 28 30 29" fill="none" stroke="#eab308" strokeWidth="1.4" />
<path d="M7 9 L8.5 4.5 L11.5 5.5 L12 8" fill="#78350f" />
<circle cx="35.6" cy="29" r="1.6" fill="#78350f" />
</g>
);
case 'lime':
return (
<g>
<circle cx="20" cy="20" r="16" fill="#3f6212" />
<circle cx="20" cy="20" r="13.5" fill="#f7fee7" />
<circle cx="20" cy="20" r="12" fill="#a3e635" />
{Array.from({ length: 8 }, (_, i) => {
const a = (i * Math.PI) / 4;
return <line key={i} x1="20" y1="20" x2={20 + Math.cos(a) * 12} y2={20 + Math.sin(a) * 12} stroke="#f7fee7" strokeWidth="1.6" />;
})}
<circle cx="20" cy="20" r="2.2" fill="#f7fee7" />
</g>
);
case 'plum':
return (
<g>
<path d="M20 8 C30 8 34 17 33 25 C32 33 26 37 20 37 C14 37 8 33 7 25 C6 17 10 8 20 8 Z" fill="#7e22ce" stroke="#4c1d95" strokeWidth="1.5" />
<path d="M20 10 C17 18 17 28 20 36" fill="none" stroke="#4c1d95" strokeWidth="1.6" />
<ellipse cx="13" cy="18" rx="2.4" ry="4.5" fill="#c4b5fd" opacity=".75" />
<path d="M20 9 L21 3" stroke="#78350f" strokeWidth="2" strokeLinecap="round" />
<path d="M21 5 C25 2 30 3 31 5 C28 8 24 8 21 5 Z" fill="#16a34a" />
</g>
);
}
}
const SPOTS: Record<FruitCount, [number, number][]> = {
1: [[50, 70]],
2: [
[50, 38],
[50, 102],
],
3: [
[28, 34],
[50, 70],
[72, 106],
],
4: [
[29, 38],
[71, 38],
[29, 102],
[71, 102],
],
5: [
[27, 32],
[73, 32],
[50, 70],
[27, 108],
[73, 108],
],
};
export function FruitCardArt({ card }: { card: Pick<TopCard, 'fruit' | 'count'> | null }) {
if (!card) {
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="2" y="2" width="96" height="136" rx="11" fill="#1e3a8a" stroke="#93c5fd" strokeWidth="3" />
<rect x="12" y="12" width="76" height="116" rx="7" fill="none" stroke="#93c5fd" strokeWidth="2" strokeDasharray="6 5" />
<circle cx="50" cy="70" r="16" fill="#fbbf24" stroke="#fde68a" strokeWidth="3" />
</svg>
);
}
const size = card.count === 1 ? 54 : card.count === 5 ? 37 : 41;
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="2" y="2" width="96" height="136" rx="11" fill="#fffef8" stroke={FRUIT_INK[card.fruit]} strokeWidth="4" />
{SPOTS[card.count].map(([x, y], i) => (
<g key={i} transform={`translate(${x - size / 2} ${y - size / 2}) scale(${size / 40})`}>
<FruitShape fruit={card.fruit} />
</g>
))}
</svg>
);
}
function FruitIcon({ fruit }: { fruit: Fruit }) {
return (
<svg viewBox="0 0 40 40" className="hg-fruit-ic" aria-hidden>
<FruitShape fruit={fruit} />
</svg>
);
}
function BellArt() {
return (
<svg viewBox="0 0 100 100" aria-hidden>
<ellipse cx="50" cy="80" rx="40" ry="9" fill="#78350f" />
<path d="M18 76 C18 50 30 26 50 26 C70 26 82 50 82 76 Z" fill="#fde68a" stroke="#92400e" strokeWidth="3" />
<path d="M30 70 C30 52 37 36 48 33" fill="none" stroke="#fffbeb" strokeWidth="5" strokeLinecap="round" opacity=".8" />
<rect x="43" y="14" width="14" height="13" rx="4" fill="#92400e" />
<circle cx="50" cy="12" r="8" fill="#b91c1c" stroke="#7f1d1d" strokeWidth="2" />
</svg>
);
}
// ---------------------------------------------------------------- geometry
interface Pos {
left: string;
top: string;
}
const CENTER: Pos = { left: '50%', top: '50%' };
/**
* Seat centres on an ellipse (me at the bottom, then clockwise: left → top → right). The radii are
* "half the table minus half a seat", so seats always stay inside the felt whatever its size.
* `tall` (narrow phone table): seats that are not level with the bell move further up/down so they clear it.
*/
function seatPositions(n: number, seat: { w: number; h: number }, maxRx: number, tall: boolean): Pos[] {
const f = (x: number) => (Math.abs(x) < 1e-9 ? 0 : Number(x.toFixed(4)));
return Array.from({ length: n }, (_, k) => {
const t = (2 * Math.PI * k) / n;
const c = f(Math.cos(t));
const y = tall && c !== 0 ? Math.sign(c) * Math.max(Math.abs(c), 0.75) : c;
return {
left: `calc(50% - ${f(Math.sin(t))} * min(50% - ${seat.w / 2 + 6}px, ${maxRx}px))`,
top: `calc(50% + ${y} * (50% - ${seat.h / 2 + 6}px))`,
};
});
}
function usePhone(): boolean {
const q = '(max-width: 719px)';
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const on = () => setM(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return m;
}
// ---------------------------------------------------------------- seat
function Gauge({ start, end, now }: { start: number; end: number; now: number }) {
const frac = Math.max(0, Math.min(1, (end - now) / Math.max(1, end - start)));
const r = 12;
const c = 2 * Math.PI * r;
const color = frac < 0.34 ? '#ef4444' : frac < 0.67 ? '#f59e0b' : '#22c55e';
return (
<svg className="gauge" viewBox="0 0 30 30" aria-hidden>
<circle cx="15" cy="15" r={r + 2} fill="#111827" />
<circle cx="15" cy="15" r={r} fill="none" stroke="#374151" strokeWidth="4" />
<circle cx="15" cy="15" r={r} fill="none" stroke={color} strokeWidth="4" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} transform="rotate(-90 15 15)" strokeLinecap="round" />
</svg>
);
}
function seatLabel(s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
parts.push(s.top ? `펼친 카드 ${cardName(s.top)}` : '펼친 카드 없음');
parts.push(`덱 ${s.deck}장`);
if (s.pile > 1) parts.push(`펼친 더미 ${s.pile}장`);
if (s.status === 'out') parts.push('탈락');
if (s.status === 'left') parts.push('나감');
if (s.isTurn) parts.push('뒤집을 차례');
return parts.join(', ');
}
function Seat({ s, v, pos, players, me, now }: { s: SeatView; v: HalliGalliView; pos: Pos; players: Record<string, PublicUser>; me: string | null; now: number }) {
const u = userOf(players, s.id);
const locked = s.lockedUntil > now && s.status === 'playing';
return (
<li
className="hg-seat"
data-turn={s.isTurn && v.phase === 'playing'}
data-out={s.status !== 'playing'}
data-me={s.id === me}
data-seat={s.id}
style={{ left: pos.left, top: pos.top }}
aria-label={seatLabel(s, players, me)}
>
<div className="nm">
<Avatar user={u} size={18} />
<span>{s.id === me ? '나' : u.nickname}</span>
</div>
<div className="hg-pile">
{s.pile > 1 && <span className="shadow1" />}
{s.top ? (
<span key={s.top.key} className="face">
<FruitCardArt card={s.top} />
</span>
) : (
<span className="empty" />
)}
{s.isTurn && v.phase === 'playing' && <Gauge start={v.turnStartedAt} end={v.turnDeadline} now={now} />}
</div>
<div className="hg-meta" aria-hidden>
<span>
<i className="hg-back" /> {s.deck}
</span>
{s.status === 'out' && (
<span className="hg-tag" style={{ background: '#dc2626' }}>
탈락
</span>
)}
{s.status === 'left' && (
<span className="hg-tag" style={{ background: '#6b7280' }}>
나감
</span>
)}
{locked && (
<span className="hg-tag" style={{ background: '#f59e0b', color: '#111827' }}>
잠금
</span>
)}
</div>
</li>
);
}
// ---------------------------------------------------------------- bell feedback
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late';
text: string;
}
interface Ghost {
key: string;
from: Pos;
to: Pos;
card: Pick<TopCard, 'fruit' | 'count'> | null;
delay: number;
}
function bellText(v: HalliGalliView, players: Record<string, PublicUser>): Bubble | null {
const b = v.lastBell;
if (!b) return null;
const who = b.player === v.me ? '내가' : `${nick(players, b.player)}님이`;
if (b.correct) return { key: `b${b.seq}`, kind: 'ok', text: `땡! ${who} 먼저 쳤어요 · ${b.fruit ? FRUIT_KO[b.fruit] : ''} 5개 · ${b.cards}장 가져가요` };
const duel = v.phase === 'ended' && (v.options.duelWrongLoses || v.officialDuel);
if (duel) return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요 · 승부가 끝났어요` };
const lost = b.cards === 0 ? '' : v.options.wrongToPot ? ` · 종 밑에 ${b.cards}장` : ` · ${b.cards}장 나눠 줘요`;
return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요${lost}` };
}
// ---------------------------------------------------------------- board
function HalliGalliBoard({ view: v, me, players, act, pending }: BoardProps<HalliGalliView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [ghosts, setGhosts] = useState<Ghost[]>([]);
const [hit, setHit] = useState(0);
const [announce, setAnnounce] = useState('');
const sentFor = useRef<number | null>(null);
const prev = useRef<HalliGalliView | null>(null);
// Seats rotated so that I sit at the bottom (spectators: first seat at the bottom).
const myIdx = v.seats.findIndex((s) => s.id === me);
const rot = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx), ...v.seats.slice(0, myIdx)];
const many = rot.length >= 5;
const cw = phone ? (many ? 46 : 54) : many ? 70 : 86;
const sw = phone ? (many ? 74 : 86) : many ? 116 : 132;
const pos = seatPositions(rot.length, { w: sw, h: cw * 1.4 + (phone ? 50 : 58) }, phone ? 400 : 380, phone);
const posOf = (id: string) => pos[rot.findIndex((s) => s.id === id)] ?? CENTER;
const mine = v.seats.find((s) => s.id === me) ?? null;
const playing = v.phase === 'playing';
const lockedLeft = mine ? Math.max(0, mine.lockedUntil - now) : 0;
const canRing = !!mine && mine.status === 'playing' && playing && lockedLeft === 0;
const myTurn = !!mine && playing && v.turn === me;
const canFlip = myTurn && !v.options.autoFlip && !v.pendingFlip && mine.deck > 0 && !pending;
const ring = () => {
if (!canRing || sentFor.current === v.tableVersion) return;
sentFor.current = v.tableVersion;
setHit((h) => h + 1);
sounds.ding();
haptic(30);
act({ type: 'ring', version: v.tableVersion, ...(v.options.latencyComp === 'light' && rtt !== null ? { rtt } : {}) });
};
const flip = () => {
if (!canFlip) return;
act({ type: 'flip' });
};
const ringRef = useRef(ring);
const flipRef = useRef(flip);
ringRef.current = ring;
flipRef.current = flip;
// Keyboard: Space = bell, Enter = flip (not while typing; Enter on a focused button keeps its own meaning).
useEffect(() => {
const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
const down = (e: KeyboardEvent) => {
if (typing(e.target) || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.code === 'Space' || e.key === ' ') {
e.preventDefault();
if (!e.repeat) ringRef.current();
} else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement)) {
if (!e.repeat) flipRef.current();
}
};
const up = (e: KeyboardEvent) => {
if ((e.code === 'Space' || e.key === ' ') && !typing(e.target)) e.preventDefault();
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, []);
// "늦었어요" and other bell rejects come back as `reject` messages.
useEffect(
() =>
socket.subscribe((m) => {
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
setAnnounce(m.reason);
}
}),
[],
);
// Diff against the previous view: flips, bells, card flights.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const b = v.lastBell;
if (b && b.seq !== p.lastBell?.seq) {
const bub = bellText(v, players);
if (bub) {
setBubble(bub);
setAnnounce(bub.text);
}
const to = posOf(b.player);
const fly: Ghost[] = [];
if (b.correct) {
if (b.player !== me) sounds.ding();
else haptic([40, 30, 80]);
p.seats.forEach((s, i) => s.top && fly.push({ key: `g${b.seq}-${s.id}`, from: posOf(s.id), to, card: s.top, delay: i * 40 }));
if (p.pot > 0) fly.push({ key: `g${b.seq}-pot`, from: CENTER, to, card: null, delay: 0 });
} else {
sounds.buzz();
if (b.player === me) haptic([90, 50, 90]);
const from = posOf(b.player);
const targets = v.options.wrongToPot ? [CENTER] : p.seats.filter((s) => s.id !== b.player && s.status === 'playing').map((s) => posOf(s.id));
targets.slice(0, Math.max(1, b.cards)).forEach((t, i) => fly.push({ key: `w${b.seq}-${i}`, from, to: t, card: null, delay: i * 60 }));
}
setGhosts(fly);
return;
}
if (v.tableVersion !== p.tableVersion && v.lastFlipBy) {
sounds.stone();
const s = v.seats.find((x) => x.id === v.lastFlipBy);
if (s?.top) setAnnounce(`${s.id === me ? '내가' : `${nick(players, s.id)}님이`} ${cardName(s.top)}를 뒤집었어요`);
}
if (v.turn === me && p.turn !== me && playing) haptic(20);
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
// A new table (or a judged bell) lets me ring again.
useEffect(() => {
if (sentFor.current !== null && sentFor.current !== v.tableVersion) sentFor.current = null;
}, [v.tableVersion]);
useEffect(() => {
if (!ghosts.length) return;
const t = setTimeout(() => setGhosts([]), 1000);
return () => clearTimeout(t);
}, [ghosts]);
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1900);
return () => clearTimeout(t);
}, [bubble]);
const bellSize = phone ? (rot.length >= 5 ? 128 : 'min(40vw, 156px)') : 'clamp(140px, 20vh, 190px)';
const countdown = v.phase === 'countdown' ? Math.max(0, Math.ceil((v.countdownEndsAt - now) / 1000)) : 0;
const totals = FRUITS.map((f) => ({ f, n: v.seats.reduce((a, s) => a + (s.top && s.top.fruit === f ? s.top.count : 0), 0) }));
const timeLeft = v.endsAt !== null ? Math.max(0, v.endsAt - Math.max(now, v.countdownEndsAt)) : null;
const ping = rtt;
return (
<div className="hg">
<style>{CSS}</style>
<div className="hg-top">
<div className="grp">
{timeLeft !== null && (
<span className={`hg-pill${timeLeft < 60_000 ? ' warn' : ''}`} role="timer" aria-label={`남은 시간 ${Math.floor(timeLeft / 60_000)}분 ${Math.floor((timeLeft % 60_000) / 1000)}초`}>
⏱ {Math.floor(timeLeft / 60_000)}:{String(Math.floor((timeLeft % 60_000) / 1000)).padStart(2, '0')}
</span>
)}
{v.officialDuel && playing && <span className="hg-pill warn">2명 남음 · 다음 종으로 끝나요</span>}
</div>
<div className="grp">
{!phone && <span className="hg-pill hg-keys">스페이스 바 = 종 · Enter = 뒤집기</span>}
{v.options.showRtt && ping !== null && (
<span className={`hg-pill${ping >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${ping}밀리초`}>
<i className="hg-dot" style={{ background: ping < 80 ? '#16a34a' : ping < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{ping >= 200 ? `연결이 느려요 · ${ping}ms` : `핑 ${ping}ms`}
</span>
)}
</div>
</div>
<div
className="hg-table"
role="region"
aria-label="카드 테이블"
style={{ '--bell': typeof bellSize === 'number' ? `${bellSize}px` : bellSize, '--cw': `${cw}px`, '--sw': `${sw}px` } as CSSProperties}
>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }} aria-label="사람들의 펼친 카드">
{rot.map((s, i) => (
<Seat key={s.id} s={s} v={v} pos={pos[i]!} players={players} me={me} now={now} />
))}
</ul>
<div className="hg-center">
<button
type="button"
className="hg-bell"
disabled={!canRing}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (e.button !== 0) return;
e.preventDefault();
ring();
}}
onClick={(e) => {
// Keyboard activation (Enter on the focused bell); pointer presses were handled on pointerdown.
if (e.detail === 0) ring();
}}
aria-label={canRing ? '종 치기 (스페이스 바)' : lockedLeft > 0 ? '종 잠금 중이에요' : '종 (지금은 칠 수 없어요)'}
aria-keyshortcuts="Space"
>
<span key={hit} className="art" data-ring={hit > 0}>
<BellArt />
</span>
<span className="lbl" aria-hidden>
종!
</span>
{lockedLeft > 0 && (
<span
className="lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.55) ${(lockedLeft / Math.max(1, v.options.wrongRingLockoutMs)) * 360}deg, transparent 0)` }}
/>
)}
</button>
{v.pot > 0 && (
<span className="hg-pot" aria-label={`종 밑 카드 ${v.pot}장`}>
종 밑 {v.pot}장
</span>
)}
</div>
{ghosts.map((g) => (
<span
key={g.key}
className="hg-ghost"
style={{ '--fx': g.from.left, '--fy': g.from.top, '--tx': g.to.left, '--ty': g.to.top, '--d': `${g.delay}ms` } as CSSProperties}
>
<FruitCardArt card={g.card} />
</span>
))}
{bubble && (
<div key={bubble.key} className={`hg-bubble ${bubble.kind}`} aria-hidden>
{bubble.text}
</div>
)}
{countdown > 0 && (
<div className="hg-count" aria-hidden>
{countdown}
<small>과일이 딱 5개가 되면 종을 쳐요!</small>
</div>
)}
</div>
{mine && mine.status === 'playing' && v.phase !== 'ended' && (
<div className="hg-mine">
<div className="info" aria-label={`내 덱 ${mine.deck}장`}>
<span className="small muted">내 덱</span>
<b>
<i className="hg-back" /> {mine.deck}장
</b>
</div>
<button type="button" className={`btn ${myTurn ? 'btn-primary' : 'btn-secondary'} hg-flipbtn`} disabled={!canFlip} onClick={flip} aria-keyshortcuts="Enter">
{v.options.autoFlip ? (myTurn ? '곧 자동으로 뒤집혀요' : '자동 뒤집기') : v.pendingFlip && myTurn ? '곧 뒤집혀요…' : myTurn ? '뒤집기' : `${v.turn ? nick(players, v.turn) : ''}님 차례`}
</button>
</div>
)}
{mine && mine.status !== 'playing' && v.phase !== 'ended' && (
<div className="hg-panel">
<strong>{mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
</div>
)}
{v.options.kidMode && v.phase !== 'ended' && (
<div className="hg-kid" aria-label="과일 합계">
<strong>5개가 되면 종을 쳐요</strong>
{totals.map(({ f, n }) => (
<div key={f} className="hg-bar" data-five={n === 5}>
<FruitIcon fruit={f} />
<span>{FRUIT_KO[f]}</span>
<span className="track">
<i style={{ width: `${Math.min(100, (n / 8) * 100)}%`, background: FRUIT_INK[f] }} />
</span>
<span>{n}</span>
</div>
))}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
const n = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
switch (e.t) {
case 'collect':
return `${n(e.p)} 종을 쳐서 ${e.cards}장을 가져갔어요 (${FRUIT_KO[e.fruit]} 5개)`;
case 'wrong':
return e.toPot ? `${n(e.p)} 틀려서 ${e.gave}장을 종 밑에 넣었어요` : `${n(e.p)} 틀려서 ${e.gave}장을 나눠 줬어요`;
case 'duelWrong':
return `${n(e.p)} 틀려서 ${nick(players, e.by)}님이 ${e.cards}장을 가져갔어요`;
case 'out':
return e.why === 'left' ? `${n(e.p)} 나갔어요` : e.why === 'strikeOut' ? `${n(e.p)} 3번 틀려서 탈락했어요` : `${n(e.p)} 탈락했어요`;
}
}
function rulesLine(o: HalliGalliOptions): string {
const parts = [o.endMode === 'official' ? '2명 남으면 다음 종에서 끝' : '한 명 남을 때까지', o.timeLimitMin ? `${o.timeLimitMin}분 제한` : '시간 제한 없음'];
parts.push(o.autoFlip ? `자동 뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초` : `뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초`);
if (o.wrongRingLockoutMs) parts.push(`틀리면 종 ${o.wrongRingLockoutMs / 1000}초 잠금`);
if (o.penaltyEscalation) parts.push('벌칙 점점 늘어남');
if (o.wrongToPot) parts.push('벌칙은 종 밑으로');
if (o.strikeOut) parts.push('3번 틀리면 탈락');
if (o.duelWrongLoses) parts.push('1대1 오답은 패배');
if (o.latencyComp === 'light') parts.push('지연 보정');
return parts.join(' · ');
}
function HalliGalliSide({ view: v, me, players }: BoardProps<HalliGalliView>) {
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>할리갈리{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'playing' ? 1 : 0.55 }} aria-label={`${seatLabel(s, players, me)}, 정답 ${s.correct}번, 오답 ${s.wrong}번`}>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{s.isTurn ? '▶ ' : ''}
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{s.status === 'out' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : ''}
</span>
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{s.cards}장
</strong>
</li>
))}
</ol>
{(v.pot > 0 || v.removed > 0) && (
<span className="small muted">
{v.pot > 0 ? `종 밑 ${v.pot}장` : ''}
{v.pot > 0 && v.removed > 0 ? ' · ' : ''}
{v.removed > 0 ? `게임에서 뺀 카드 ${v.removed}장` : ''}
</span>
)}
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.id}>
{r.rank}등 {nick(players, r.id)}
<span className="muted small">
{' '}
· {r.cards}장 · 정답 {r.correct} · 오답 {r.wrong}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="hg-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: HalliGalliView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.turn === me && v.seats.some((s) => s.id === me && s.status === 'playing');
}
function statusText(v: HalliGalliView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return '곧 시작해요! 과일이 딱 5개가 되면 종을 쳐요';
const mine = v.seats.find((s) => s.id === me);
if (mine && mine.status !== 'playing') return '탈락했어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) return v.options.autoFlip ? '내 차례예요 · 곧 자동으로 뒤집혀요' : '내 차례예요 · 카드를 뒤집어 주세요';
return `${v.turn ? nick(players, v.turn) : ''}님 차례예요 · 5개가 되면 종!`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean | null>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
function Options({ value: o, onChange }: OptionsProps<HalliGalliOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<HalliGalliOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="끝나는 방식" help={o.endMode === 'official' ? '3명 이상일 때, 2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '한 사람이 남을 때까지 해요.'}>
<Seg
label="끝나는 방식"
value={o.endMode}
options={[
['lastStanding', '한 명 남을 때까지'],
['official', '2명 남으면 다음 종에서'],
]}
onChange={(endMode) => set({ endMode })}
/>
</Field>
<Field label="전체 제한 시간" help="시간이 다 되면 카드가 많은 사람이 이겨요.">
<Seg
label="전체 제한 시간"
value={o.timeLimitMin}
options={[
[5, '5분'],
[10, '10분'],
[15, '15분'],
[null, '없음'],
]}
onChange={(timeLimitMin) => set({ timeLimitMin })}
/>
</Field>
<Field label="뒤집기 제한 시간" help="시간 안에 안 뒤집으면 자동으로 뒤집혀요.">
<Seg
label="뒤집기 제한 시간"
value={o.flipSeconds}
options={[
[2, '2초'],
[3, '3초'],
[5, '5초'],
[8, '8초'],
]}
onChange={(flipSeconds) => set({ flipSeconds })}
/>
</Field>
<Field label="어린이 모드" help="8초 뒤집기, 과일 합계 막대를 보여 줘요.">
<Seg label="어린이 모드" value={o.kidMode} options={onOff} onChange={(kidMode) => set({ kidMode })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드를 자동으로 뒤집기">
<Seg label="카드를 자동으로 뒤집기" value={o.autoFlip} options={onOff} onChange={(autoFlip) => set({ autoFlip })} />
</Field>
<Field label="뒤집은 뒤 다음 뒤집기까지 최소 간격" help="연결이 느린 사람도 같은 카드를 보고 종을 칠 시간이에요.">
<Seg
label="뒤집기 최소 간격"
value={o.flipGuardMs}
options={[
[400, '0.4초'],
[600, '0.6초'],
[1000, '1초'],
]}
onChange={(flipGuardMs) => set({ flipGuardMs })}
/>
</Field>
<Field label="틀린 종 뒤 종 잠금">
<Seg
label="틀린 종 뒤 종 잠금"
value={o.wrongRingLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongRingLockoutMs) => set({ wrongRingLockoutMs })}
/>
</Field>
<Field label="나누고 남은 카드">
<Seg
label="나누고 남은 카드"
value={o.leftoverCards}
options={[
['pot', '종 밑에 두기'],
['remove', '빼기'],
]}
onChange={(leftoverCards) => set({ leftoverCards })}
/>
</Field>
<Field label="지연 보정" help="켜면 연결 지연의 절반(최대 0.08초)만큼 먼저 친 것으로 봐요.">
<Seg
label="지연 보정"
value={o.latencyComp}
options={[
['off', '끄기'],
['light', '가볍게'],
]}
onChange={(latencyComp) => set({ latencyComp })}
/>
</Field>
<Field label="내 핑 표시">
<Seg label="내 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
</Field>
<Field label="1대1에서 틀린 종을 치면 바로 패배">
<Seg label="1대1 오답 패배" value={o.duelWrongLoses} options={onOff} onChange={(duelWrongLoses) => set({ duelWrongLoses })} />
</Field>
<Field label="틀린 종 3번이면 탈락">
<Seg label="오답 3번 탈락" value={o.strikeOut} options={onOff} onChange={(strikeOut) => set({ strikeOut })} />
</Field>
<Field label="틀릴 때마다 벌칙 장수 늘리기">
<Seg label="벌칙 늘리기" value={o.penaltyEscalation} options={onOff} onChange={(penaltyEscalation) => set({ penaltyEscalation })} />
</Field>
<Field label="벌칙 카드를 종 밑으로">
<Seg label="벌칙 카드를 종 밑으로" value={o.wrongToPot} options={onOff} onChange={(wrongToPot) => set({ wrongToPot })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: HalliGalliOptions): string {
return rulesLine(o) + (o.kidMode ? ' · 어린이 모드' : '');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: HalliGalliOptions }) {
const o = options;
return (
<div className="stack">
<p>
과일 카드 56장(딸기·바나나·라임·자두)을 똑같이 나눠 받고, 차례대로 내 덱 맨 위 카드를 뒤집어 내 앞에 펼쳐요. 펼쳐진 카드 맨 위에 보이는 과일 가운데 <strong>한 종류가 딱 5개</strong>가 되면 가운데 종을 쳐요.
</p>
<div className="row" style={{ gap: 8, alignItems: 'center' }}>
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'strawberry', count: 2 }} />
</span>
+
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'strawberry', count: 3 }} />
</span>
+
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'banana', count: 4 }} />
</span>
<span>= 딸기 5개 → 땡!</span>
</div>
<p>
<strong>먼저 친 사람</strong>(서버에 먼저 도착한 사람)이 펼쳐진 카드를 모두 가져가 덱 맨 아래에 넣고, 다음에 뒤집어요. 6개나 4개일 때 치면 틀린 거예요. 틀리면 다른 사람에게 1장씩 줘요
{o?.wrongToPot ? '(이 방에서는 종 밑에 넣어요)' : ''}. 조금 늦게 친 종은 "늦었어요"로 무시되고 벌칙은 없어요.
</p>
<p>
차례가 왔는데 덱이 비어 있으면 탈락해요. 덱이 비어도 차례가 오기 전에 종을 맞히면 다시 살아나요.{' '}
{o?.endMode === 'official' ? '2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '마지막까지 남은 사람이 이겨요.'}{' '}
{o?.timeLimitMin === null ? '' : `${o?.timeLimitMin ?? 10}분이 지나면 카드(덱 + 내 펼친 카드)가 많은 사람이 이겨요.`}
</p>
<p>
PC에서는 <strong>스페이스 바</strong>로 종을, <strong>Enter</strong>로 뒤집기를 할 수 있어요. 휴대폰은 종 그림을 누르면 바로 울려요.
</p>
</div>
);
}
const ui: GameUI<HalliGalliView, HalliGalliOptions> = {
Board: HalliGalliBoard,
Side: HalliGalliSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -17,6 +17,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
mahjong: () => import('./mahjong'),
gostop: () => import('./gostop'),
bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'),
};
const cache = new Map<string, GameUI>();

View File

@@ -22,3 +22,8 @@ export const UPCOMING = ['뱅', '할리갈리'];
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['마작', '고스톱', '할리갈리'];
'halli-galli': '🔔',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['마작', '고스톱', '뱅'];

View File

@@ -38,6 +38,13 @@ export const sounds = {
warn: () => tone(520, 90, 'square', 0.06),
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)),
lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)),
/** Bell "땡!" (할리갈리 correct bell). */
ding: () => {
tone(1568, 700, 'sine', 0.22);
tone(2350, 450, 'sine', 0.08);
},
/** Buzzer "삐!" (wrong answer). */
buzz: () => tone(180, 260, 'square', 0.08),
};
export function haptic(pattern: number | number[]): void {