@@ -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 ) < 1 e - 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 ;