Files
joke-app/apps/web/src/games/halli-galli/index.tsx
2026-10-06 13:35:21 +09:00

965 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 할리갈리 화면 (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';
import { helpNow } from './help';
import { quickStart } from './quick';
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,
quickStart,
helpNow,
};
export default ui;