@@ -0,0 +1,618 @@
/** 오셀로 화면 (docs/games/othello.md §9). */
import { useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
import type { OthelloLegalMove , OthelloOptions , OthelloView } from '@bg/games/othello' ;
import type { PublicUser } from '@bg/shared/lite' ;
import { Avatar , ConfirmSheet , Sheet } from '../../components/ui' ;
import { TurnTimer , useServerNow } from '../../components/TurnTimer' ;
import { haptic , sounds } from '../../lib/feedback' ;
import { toast } from '../../store/toast' ;
import type { BoardProps , GameUI , OptionsProps } from '../types' ;
type Color = 'black' | 'white' ;
interface Pt {
x : number ;
y : number ;
}
const N = 8 ;
const COLOR_KO : Record < Color , string > = { black : '흑' , white : '백' } ;
const other = ( c : Color ) : Color = > ( c === 'black' ? 'white' : 'black' ) ;
const label = ( p : Pt ) = > ` ${ 'abcdefgh' [ p . x ] } ${ p . y + 1 } ` ;
const FLIP_MS = 240 ;
const CSS = `
@keyframes oth-flip { 0% { transform: scaleX(1); } 50% { transform: scaleX(0.05); } 100% { transform: scaleX(1); } }
@keyframes oth-old { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }
@keyframes oth-drop { 0% { transform: scale(0.4); opacity: 0.4; } 100% { transform: scale(1); opacity: 1; } }
.oth-flip { transform-box: fill-box; transform-origin: center; animation: oth-flip ${ FLIP_MS } ms ease-in-out both; }
.oth-old { animation: oth-old ${ FLIP_MS } ms linear both; }
.oth-drop { transform-box: fill-box; transform-origin: center; animation: oth-drop 160ms ease-out both; }
@media (prefers-reduced-motion: reduce) {
.oth-flip, .oth-drop { animation: none; }
.oth-old { display: none; }
} ` ;
function Disc ( { color , cx , cy , r = 0.4 , ghost } : { color : Color ; cx : number ; cy : number ; r? : number ; ghost? : boolean } ) {
const black = color === 'black' ;
return (
< g opacity = { ghost ? 0.5 : 1 } >
< circle cx = { cx + 0.03 } cy = { cy + 0.05 } r = { r } fill = "#000" opacity = { ghost ? 0 : 0.25 } / >
< circle cx = { cx } cy = { cy } r = { r } fill = { black ? '#161616' : '#fafaf7' } stroke = { black ? '#000' : '#9a9a92' } strokeWidth = { 0.03 } / >
< circle cx = { cx - r * 0.28 } cy = { cy - r * 0.3 } r = { r * 0.32 } fill = "#fff" opacity = { black ? 0.14 : 0.6 } / >
< / g >
) ;
}
interface Anim {
key : number ;
at : number ;
from : Color ;
delay : Map < number , number > ;
}
function OthelloBoard ( { view : v , act , pending , tapConfirm } : BoardProps < OthelloView > ) {
const myTurn = v . phase === 'playing' && v . myColor === v . turn && v . legalMoves !== null ;
const [ hover , setHover ] = useState < Pt | null > ( null ) ;
const [ preview , setPreview ] = useState < Pt | null > ( null ) ;
const [ cursor , setCursor ] = useState < Pt > ( { x : 3 , y : 3 } ) ;
const [ kbd , setKbd ] = useState ( false ) ;
const [ announce , setAnnounce ] = useState ( '' ) ;
const [ anim , setAnim ] = useState < Anim | null > ( null ) ;
const svgRef = useRef < SVGSVGElement > ( null ) ;
const prevCount = useRef ( v . moveCount ) ;
const legal = useMemo ( ( ) = > {
const m = new Map < number , OthelloLegalMove > ( ) ;
for ( const l of v . legalMoves ? ? [ ] ) m . set ( l . y * N + l . x , l ) ;
return m ;
} , [ v . legalMoves ] ) ;
// Flip animation, sounds, pass toasts and screen-reader text for new moves (before paint, so no flash).
useLayoutEffect ( ( ) = > {
const prev = prevCount . current ;
prevCount . current = v . moveCount ;
setPreview ( null ) ;
if ( v . moveCount <= prev ) {
setAnim ( null ) ;
return ;
}
const added = v . moves . slice ( prev ) ;
const texts : string [ ] = [ ] ;
for ( const m of added ) {
if ( m . kind === 'place' ) texts . push ( ` ${ COLOR_KO [ m . color ] } 이 ${ label ( m ) } 에 두어 ${ m . flipped . length } 개를 뒤집었어요. ` ) ;
else {
const msg =
m . color === v . myColor
? '둘 곳이 없어서 차례가 상대에게 넘어갔어요'
: v . myColor
? '상대가 둘 곳이 없어서 내 차례가 한 번 더 와요'
: ` ${ COLOR_KO [ m . color ] } 이 둘 곳이 없어서 차례가 넘어갔어요 ` ;
texts . push ( msg + '.' ) ;
toast ( msg ) ;
}
}
setAnnounce ( texts . join ( ' ' ) ) ;
const lm = v . lastMove ;
if ( lm && added . some ( ( m ) = > m . kind === 'place' ) ) {
sounds . stone ( ) ;
const dist = ( i : number ) = > Math . max ( Math . abs ( ( i % N ) - lm . x ) , Math . abs ( Math . floor ( i / N ) - lm . y ) ) ;
const maxD = Math . max ( 1 , . . . lm . flipped . map ( dist ) ) ;
const step = maxD > 1 ? Math . min ( 60 , ( 400 - FLIP_MS ) / ( maxD - 1 ) ) : 0 ;
setAnim ( { key : v.moveCount , at : lm.y * N + lm . x , from : other ( lm . color ) , delay : new Map ( lm . flipped . map ( ( i ) = > [ i , ( dist ( i ) - 1 ) * step ] ) ) } ) ;
} else setAnim ( null ) ;
if ( v . phase === 'playing' && v . myColor === v . turn ) {
haptic ( 40 ) ;
if ( document . visibilityState === 'hidden' ) sounds . myTurn ( ) ;
}
} , [ v . moveCount ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
const canPlace = ( p : Pt ) = > myTurn && ! pending && legal . has ( p . y * N + p . x ) ;
const place = ( p : Pt ) = > {
act ( { type : 'place' , x : p.x , y : p.y } ) ;
setPreview ( null ) ;
setHover ( null ) ;
} ;
const refuse = ( p : Pt ) = > {
const msg = v . board [ p . y * N + p . x ] !== 0 ? '이미 돌이 있는 칸이에요.' : '여기에 두면 뒤집을 돌이 없어요.' ;
haptic ( [ 30 , 30 , 30 ] ) ;
setAnnounce ( msg ) ;
toast ( msg ) ;
} ;
const tap = ( p : Pt , mouse : boolean ) = > {
setCursor ( p ) ;
if ( ! myTurn ) return ;
if ( ! legal . has ( p . y * N + p . x ) ) {
refuse ( p ) ;
return ;
}
// Mouse: one click (hover already previews). Touch: optional two-step confirm.
if ( mouse || ! tapConfirm ) {
if ( canPlace ( p ) ) place ( p ) ;
return ;
}
if ( preview && preview . x === p . x && preview . y === p . y ) place ( p ) ;
else setPreview ( p ) ;
} ;
const pointToCell = ( e : React.PointerEvent < SVGSVGElement > ) : Pt | null = > {
const svg = svgRef . current ;
if ( ! svg ) return null ;
const pt = svg . createSVGPoint ( ) ;
pt . x = e . clientX ;
pt . y = e . clientY ;
const loc = pt . matrixTransform ( svg . getScreenCTM ( ) ! . inverse ( ) ) ;
const x = Math . floor ( loc . x ) ;
const y = Math . floor ( loc . y ) ;
return x >= 0 && y >= 0 && x < N && y < N ? { x , y } : null ;
} ;
const onKey = ( e : React.KeyboardEvent ) = > {
setKbd ( true ) ;
const d : Record < string , [ number , number ] > = { ArrowLeft : [ - 1 , 0 ] , ArrowRight : [ 1 , 0 ] , ArrowUp : [ 0 , - 1 ] , ArrowDown : [ 0 , 1 ] } ;
if ( d [ e . key ] ) {
e . preventDefault ( ) ;
const next = { x : Math.min ( N - 1 , Math . max ( 0 , cursor . x + d [ e . key ] ! [ 0 ] ) ) , y : Math.min ( N - 1 , Math . max ( 0 , cursor . y + d [ e . key ] ! [ 1 ] ) ) } ;
const l = legal . get ( next . y * N + next . x ) ;
const cell = v . board [ next . y * N + next . x ] ;
setCursor ( next ) ;
setAnnounce ( ` ${ label ( next ) } ${ cell === 1 ? '흑' : cell === 2 ? '백' : l && v . showLegalMoves ? ` 둘 수 있음, ${ l . flips } 개 뒤집힘 ` : '빈칸' } ` ) ;
} else if ( e . key === 'Enter' || e . key === ' ' ) {
e . preventDefault ( ) ;
if ( ! myTurn ) return ;
if ( canPlace ( cursor ) ) place ( cursor ) ;
else refuse ( cursor ) ;
}
} ;
const target = preview ? ? ( hover && myTurn ? hover : null ) ;
const targetMove = target ? legal . get ( target . y * N + target . x ) : undefined ;
const showFlips = ! ! targetMove && ( v . showLegalMoves || ! ! preview ) ;
const flipSet = new Set ( showFlips ? targetMove ! . flipped : [ ] ) ;
const showGhost = ! ! target && v . myColor && v . board [ target . y * N + target . x ] === 0 && ( ! ! targetMove || ! v . showLegalMoves ) ;
const total = v . discs . black + v . discs . white ;
const pad = 0.55 ;
return (
< div className = "board-wrap stack" style = { { gap : 10 } } >
< style > { CSS } < / style >
< div
aria-label = { ` 돌 수: 흑 ${ v . discs . black } 개, 백 ${ v . discs . white } 개 ` }
role = "img"
style = { { display : 'flex' , height : 30 , borderRadius : 15 , overflow : 'hidden' , border : '2px solid var(--border)' , fontWeight : 800 , fontVariantNumeric : 'tabular-nums' } }
>
< div style = { { flex : ` ${ v . discs . black } 1 0 ` , minWidth : 48 , background : '#161616' , color : '#fff' , display : 'flex' , alignItems : 'center' , paddingLeft : 12 , transition : 'flex-grow 0.4s' } } >
● { v . discs . black }
< / div >
< div
style = { { flex : ` ${ total ? v.discs.white : 1 } 1 0 ` , minWidth : 48 , background : '#fafaf7' , color : '#161616' , display : 'flex' , alignItems : 'center' , justifyContent : 'flex-end' , paddingRight : 12 , transition : 'flex-grow 0.4s' } }
>
{ v . discs . white } ○
< / div >
< / div >
< svg
ref = { svgRef }
viewBox = { ` ${ - pad } ${ - pad } ${ N + 2 * pad } ${ N + 2 * pad } ` }
style = { { width : '100%' , display : 'block' , background : '#123d2b' , borderRadius : 14 , touchAction : 'manipulation' , cursor : myTurn ? 'pointer' : 'default' , boxShadow : 'var(--shadow)' } }
onPointerMove = { ( e ) = > {
if ( e . pointerType !== 'mouse' || ! myTurn ) return ;
const p = pointToCell ( e ) ;
if ( p ? . x !== hover ? . x || p ? . y !== hover ? . y ) setHover ( p ) ;
} }
onPointerLeave = { ( ) = > setHover ( null ) }
onPointerUp = { ( e ) = > {
setKbd ( false ) ;
const p = pointToCell ( e ) ;
if ( p ) tap ( p , e . pointerType === 'mouse' ) ;
} }
tabIndex = { 0 }
role = "grid"
aria-label = { ` 오셀로 판 8× 8. 방향키로 움직이고 Enter로 둡니다. 현재 위치 ${ label ( cursor ) } ` }
onKeyDown = { onKey }
>
< rect x = { 0 } y = { 0 } width = { N } height = { N } fill = "var(--felt)" / >
{ Array . from ( { length : N + 1 } , ( _ , i ) = > (
< g key = { i } stroke = "#0b2a1d" strokeWidth = { i === 0 || i === N ? 0.05 : 0.03 } opacity = { 0.75 } >
< line x1 = { 0 } y1 = { i } x2 = { N } y2 = { i } / >
< line x1 = { i } y1 = { 0 } x2 = { i } y2 = { N } / >
< / g >
) ) }
{ [ 2 , 6 ] . flatMap ( ( x ) = > [ 2 , 6 ] . map ( ( y ) = > < circle key = { ` s ${ x } ${ y } ` } cx = { x } cy = { y } r = { 0.07 } fill = "#0b2a1d" opacity = { 0.8 } / > ) ) }
{ Array . from ( { length : N } , ( _ , i ) = > (
< g key = { ` c ${ i } ` } fill = "#d7eadf" fontSize = { 0.3 } fontWeight = { 700 } textAnchor = "middle" dominantBaseline = "central" >
< text x = { i + 0.5 } y = { - 0.28 } >
{ 'abcdefgh' [ i ] }
< / text >
< text x = { - 0.28 } y = { i + 0.5 } >
{ i + 1 }
< / text >
< / g >
) ) }
{ myTurn &&
v . showLegalMoves &&
[ . . . legal . values ( ) ] . map ( ( l ) = >
v . showFlipCount ? (
< g key = { ` l ${ l . x } - ${ l . y } ` } >
< circle cx = { l . x + 0.5 } cy = { l . y + 0.5 } r = { 0.2 } fill = { v . myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)' } / >
< text x = { l . x + 0.5 } y = { l . y + 0.52 } fontSize = { 0.26 } fontWeight = { 800 } textAnchor = "middle" dominantBaseline = "central" fill = { v . myColor === 'black' ? '#fff' : '#111' } >
{ l . flips }
< / text >
< / g >
) : (
< circle key = { ` l ${ l . x } - ${ l . y } ` } cx = { l . x + 0.5 } cy = { l . y + 0.5 } r = { 0.14 } fill = { v . myColor === 'black' ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)' } / >
) ,
) }
{ v . board . map ( ( c , i ) = > {
if ( c === 0 ) return null ;
const color : Color = c === 1 ? 'black' : 'white' ;
const cx = ( i % N ) + 0.5 ;
const cy = Math . floor ( i / N ) + 0.5 ;
const delay = anim ? . delay . get ( i ) ;
if ( anim && delay !== undefined ) {
return (
< g key = { ` a ${ anim . key } - ${ i } ` } className = "oth-flip" style = { { animationDelay : ` ${ delay } ms ` } } >
< Disc color = { color } cx = { cx } cy = { cy } / >
< g className = "oth-old" style = { { animationDelay : ` ${ delay } ms ` } } >
< Disc color = { anim . from } cx = { cx } cy = { cy } / >
< / g >
< / g >
) ;
}
if ( anim && anim . at === i ) {
return (
< g key = { ` a ${ anim . key } - ${ i } ` } className = "oth-drop" >
< Disc color = { color } cx = { cx } cy = { cy } / >
< / g >
) ;
}
return < Disc key = { i } color = { color } cx = { cx } cy = { cy } / > ;
} ) }
{ [ . . . flipSet ] . map ( ( i ) = > (
< circle key = { ` h ${ i } ` } cx = { ( i % N ) + 0.5 } cy = { Math . floor ( i / N ) + 0.5 } r = { 0.44 } fill = "rgba(255, 224, 102, 0.45)" stroke = "#ffd84d" strokeWidth = { 0.05 } / >
) ) }
{ v . lastMove && < circle cx = { v . lastMove . x + 0.5 } cy = { v . lastMove . y + 0.5 } r = { 0.1 } fill = "#ef4444" stroke = "#fff" strokeWidth = { 0.025 } / > }
{ showGhost && target && v . myColor && < Disc color = { v . myColor } cx = { target . x + 0.5 } cy = { target . y + 0.5 } ghost / > }
{ myTurn && kbd && < rect x = { cursor . x + 0.04 } y = { cursor . y + 0.04 } width = { 0.92 } height = { 0.92 } rx = { 0.08 } fill = "none" stroke = "var(--focus)" strokeWidth = { 0.07 } / > }
< / svg >
< p className = "sr-only" aria-live = "polite" >
{ announce }
< / p >
{ preview && myTurn && (
< div className = "row" style = { { justifyContent : 'center' } } >
< button className = "btn btn-primary" style = { { flex : 2 } } disabled = { ! canPlace ( preview ) } onClick = { ( ) = > place ( preview ) } >
{ ` ${ label ( preview ) } 에 두기 ${ targetMove ? ` ( ${ targetMove . flips } 개 뒤집기) ` : '' } ` }
< / button >
< button className = "btn btn-secondary" style = { { flex : 1 } } onClick = { ( ) = > setPreview ( null ) } >
취 소
< / button >
< / div >
) }
< / div >
) ;
}
/** Right-hand panel on PC (below the board on narrow screens). */
function OthelloSide ( { view : v , me , players , act , pending , deadline } : BoardProps < OthelloView > ) {
const [ confirm , setConfirm ] = useState < null | 'resign' | 'draw' > ( null ) ;
const incoming =
v . phase === 'playing' && v . myColor
? v . undo . pending && v . undo . pending . by !== v . myColor
? 'undo'
: v . draw . pendingBy && v . draw . pendingBy !== v . myColor
? 'draw'
: null
: null ;
const outgoing = v . myColor && ( v . undo . pending ? . by === v . myColor || v . draw . pendingBy === v . myColor ) ;
const undoLeft = v . myColor ? v . undo . remaining [ v . myColor ] : null ;
const top : Color = v . myColor ? other ( v . myColor ) : 'black' ;
const bottom : Color = v . myColor ? ? 'white' ;
const undoPlaces = v . undo . pending ? v . moves . slice ( v . moves . length - v . undo . pending . plies ) . filter ( ( m ) = > m . kind === 'place' ) . length : 0 ;
return (
< >
< PlayerBar view = { v } color = { top } players = { players } deadline = { deadline } me = { me } / >
< PlayerBar view = { v } color = { bottom } players = { players } deadline = { deadline } me = { me } / >
{ v . phase === 'playing' && v . myColor && (
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr 1fr' , gap : 8 } } >
{ v . undo . allowed && (
< button
className = "btn btn-secondary btn-sm"
disabled = { ! ! outgoing || undoLeft === 0 || ! v . moves . some ( ( m ) = > m . kind === 'place' && m . color === v . myColor ) }
onClick = { ( ) = > act ( { type : 'requestUndo' } ) }
title = { undoLeft !== null ? ` ${ undoLeft } 번 남음 ` : '무제한' }
>
↩ 무 르 기 { undoLeft !== null ? ` ${ undoLeft } ` : '' }
< / button >
) }
{ v . draw . allowed && (
< button className = "btn btn-secondary btn-sm" disabled = { ! ! outgoing } onClick = { ( ) = > setConfirm ( 'draw' ) } >
🤝 무 승 부
< / button >
) }
< button className = "btn btn-danger btn-sm" onClick = { ( ) = > setConfirm ( 'resign' ) } >
🏳 기 권
< / button >
< / div >
) }
{ outgoing && (
< div className = "card row" style = { { justifyContent : 'space-between' , padding : 10 } } >
< span className = "small" > 상 대 의 답 을 기 다 리 는 중 … < / span >
< button className = "btn btn-ghost btn-sm" onClick = { ( ) = > act ( { type : 'cancelOffer' } ) } >
요 청 취 소
< / button >
< / div >
) }
< MoveList view = { v } / >
{ incoming && (
< Sheet onClose = { ( ) = > { } } label = "상대의 요청" >
< h2 > { incoming === 'undo' ? '상대가 무르기를 요청했어요' : '상대가 무승부를 제안했어요' } < / h2 >
< p className = "muted" >
{ incoming === 'undo' ? ` 수락하면 상대가 마지막으로 둔 수 전으로 돌아가요(돌 ${ undoPlaces } 수). ` : '수락하면 이 판은 무승부로 끝나요.' }
< / p >
< div className = "row" >
< button className = "btn btn-primary" style = { { flex : 1 } } disabled = { pending } onClick = { ( ) = > act ( incoming === 'undo' ? { type : 'respondUndo' , accept : true } : { type : 'respondDraw' , accept : true } ) } >
수 락
< / button >
< button className = "btn btn-secondary" style = { { flex : 1 } } disabled = { pending } onClick = { ( ) = > act ( incoming === 'undo' ? { type : 'respondUndo' , accept : false } : { type : 'respondDraw' , accept : false } ) } >
거 절
< / button >
< / div >
< / Sheet >
) }
{ confirm === 'resign' && (
< ConfirmSheet title = "기권할까요?" body = "기권하면 이 판은 상대가 이겨요." confirm = "기권하기" danger onConfirm = { ( ) = > act ( { type : 'resign' } ) } onClose = { ( ) = > setConfirm ( null ) } / >
) }
{ confirm === 'draw' && < ConfirmSheet title = "무승부를 제안할까요?" confirm = "제안하기" onConfirm = { ( ) = > act ( { type : 'offerDraw' } ) } onClose = { ( ) = > setConfirm ( null ) } / > }
< / >
) ;
}
function MoveList ( { view : v } : { view : OthelloView } ) {
const ref = useRef < HTMLOListElement > ( null ) ;
useEffect ( ( ) = > {
ref . current ? . scrollTo ( { top : ref.current.scrollHeight } ) ;
} , [ v . moveCount ] ) ;
if ( v . moveCount === 0 ) return null ;
let n = 0 ;
return (
< details className = "card wide-open" open style = { { padding : 12 } } >
< summary style = { { cursor : 'pointer' , fontWeight : 700 } } > 기 보 ( { v . moves . filter ( ( m ) = > m . kind === 'place' ) . length } 수 ) < / summary >
< ol ref = { ref } style = { { margin : '8px 0 0' , paddingLeft : 0 , listStyle : 'none' , maxHeight : 132 , overflow : 'auto' , display : 'grid' , gridTemplateColumns : 'repeat(4, 1fr)' , gap : '2px 8px' , fontVariantNumeric : 'tabular-nums' } } >
{ v . moves . map ( ( m , i ) = > (
< li key = { i } className = "small" >
{ m . kind === 'place' ? (
< >
< span className = "muted" > { ++ n } . < / span > { m . color === 'black' ? '●' : '○' } { label ( m ) }
{ m . auto ? '*' : '' }
< / >
) : (
< span className = "muted" >
{ m . color === 'black' ? '●' : '○' } 패 스
< / span >
) }
< / li >
) ) }
< / ol >
< / details >
) ;
}
function PlayerBar ( { view : v , color , players , deadline , me } : { view : OthelloView ; color : Color ; players : Record < string , PublicUser > ; deadline : number | null ; me : string | null } ) {
const id = v . players [ color ] ;
const u = players [ id ] ? ? { id , nickname : '알 수 없음' , avatar : null , kind : 'guest' as const } ;
const active = v . phase === 'playing' && v . turn === color ;
const now = useServerNow ( 500 ) ;
const tc = v . timeControl ;
let clock : React.ReactNode = null ;
if ( tc . kind === 'fischer' ) {
const ms = Math . max ( 0 , v . remainingMs [ color ] - ( active ? now - v.turnStartedAt : 0 ) ) ;
const s = Math . ceil ( ms / 1000 ) ;
clock = (
< span style = { { fontVariantNumeric : 'tabular-nums' , fontWeight : 700 , fontSize : '1.15rem' , color : active && s <= 10 ? 'var(--danger)' : undefined } } >
{ Math . floor ( s / 60 ) } : { String ( s % 60 ) . padStart ( 2 , '0' ) }
< / span >
) ;
} else if ( active ) {
const total = tc . kind === 'perMove' ? tc . perMoveSec * 1000 : deadline ? Math . max ( 1000 , deadline - v . turnStartedAt ) : 300 _000 ;
clock = < TurnTimer deadline = { deadline } totalMs = { total } mine = { id === me } / > ;
}
const score = v . result ? v . result . displayScore [ color ] : v . discs [ color ] ;
return (
< div className = "card row" style = { { padding : '8px 12px' , gap : 10 , border : ` 3px solid ${ active ? 'var(--primary)' : 'transparent' } ` , transition : 'border-color 0.2s' } } >
< svg width = { 28 } height = { 28 } viewBox = "-0.5 -0.5 1 1" aria - hidden >
< Disc color = { color } cx = { 0 } cy = { 0 } r = { 0.45 } / >
< / svg >
< Avatar user = { u } size = { 32 } / >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontWeight : 700 , whiteSpace : 'nowrap' , overflow : 'hidden' , textOverflow : 'ellipsis' } } >
{ u . nickname }
{ id === me && < span className = "muted small" > ( 나 ) < / span > }
< / div >
< div className = "muted small" >
{ COLOR_KO [ color ] }
{ active ? ' · 두는 중' : '' }
< / div >
< / div >
< span aria-label = { ` ${ COLOR_KO [ color ] } 돌 ${ score } 개 ` } style = { { fontWeight : 800 , fontSize : '1.4rem' , fontVariantNumeric : 'tabular-nums' , minWidth : '2ch' , textAlign : 'right' } } >
{ score }
< / span >
{ clock }
< / div >
) ;
}
function statusText ( v : OthelloView , me : string | null , players : Record < string , PublicUser > ) : string {
if ( v . phase === 'finished' ) return v . result ? . summary ? ? '게임이 끝났어요' ;
if ( v . myColor && v . undo . pending && v . undo . pending . by !== v . myColor ) return '상대가 무르기를 요청했어요' ;
if ( v . myColor && v . draw . pendingBy && v . draw . pendingBy !== v . myColor ) return '상대가 무승부를 제안했어요' ;
if ( v . myColor === v . turn ) {
return v . showLegalMoves ? ` 내 차례예요 ( ${ COLOR_KO [ v . turn ] } ). 점이 있는 칸에 둘 수 있어요 ` : ` 내 차례예요 ( ${ COLOR_KO [ v . turn ] } ). 상대 돌을 끼울 수 있는 칸에 둬요 ` ;
}
const nick = players [ v . players [ v . turn ] ] ? . nickname ? ? COLOR_KO [ v . turn ] ;
return v . myColor ? ` ${ nick } 님이 생각 중이에요… ` : ` ${ nick } 님( ${ COLOR_KO [ v . turn ] } ) 차례예요 ` ;
}
// ---------------------------------------------------------------- options
function Seg < T extends string | number > ( { value , options , onChange } : { value : T ; options : [ T , string ] [ ] ; onChange : ( v : T ) = > void } ) {
return (
< div className = "segmented" >
{ options . map ( ( [ k , l ] ) = > (
< button key = { String ( k ) } type = "button" aria-pressed = { value === k } onClick = { ( ) = > onChange ( k ) } >
{ l }
< / button >
) ) }
< / div >
) ;
}
const TIME_PRESETS : [ string , string , Partial < OthelloOptions [ 'timeControl' ] > ] [ ] = [
[ 'pm10' , '한 수 10초' , { kind : 'perMove' , perMoveSec : 10 } ] ,
[ 'pm20' , '한 수 20초' , { kind : 'perMove' , perMoveSec : 20 } ] ,
[ 'pm30' , '한 수 30초' , { kind : 'perMove' , perMoveSec : 30 } ] ,
[ 'pm60' , '한 수 60초' , { kind : 'perMove' , perMoveSec : 60 } ] ,
[ 'f3' , '3분 + 2초' , { kind : 'fischer' , baseSec : 180 , incSec : 2 } ] ,
[ 'f5' , '5분 + 3초' , { kind : 'fischer' , baseSec : 300 , incSec : 3 } ] ,
[ 'f10' , '10분 + 5초' , { kind : 'fischer' , baseSec : 600 , incSec : 5 } ] ,
[ 'none' , '시간 제한 없음' , { kind : 'none' } ] ,
] ;
function timeKey ( tc : OthelloOptions [ 'timeControl' ] ) : string {
if ( tc . kind === 'none' ) return 'none' ;
if ( tc . kind === 'perMove' ) return ` pm ${ tc . perMoveSec } ` ;
return ` f ${ Math . round ( tc . baseSec / 60 ) } ` ;
}
function timeLabel ( tc : OthelloOptions [ 'timeControl' ] ) : string {
if ( tc . kind === 'none' ) return '시간 제한 없음' ;
if ( tc . kind === 'perMove' ) return ` 한 수 ${ tc . perMoveSec } 초 ` ;
const m = Math . floor ( tc . baseSec / 60 ) ;
const sec = tc . baseSec % 60 ;
return ` ${ m } 분 ${ sec ? ` ${ sec } 초 ` : '' } + ${ tc . incSec } 초 ` ;
}
function Options ( { value : o , onChange } : OptionsProps < OthelloOptions > ) {
const [ more , setMore ] = useState ( false ) ;
const set = ( patch : Partial < OthelloOptions > ) = > onChange ( { . . . o , . . . patch } ) ;
const onOff = ( v : boolean ) = > ( v ? 'on' : 'off' ) ;
return (
< div className = "stack" style = { { gap : 16 } } >
< div className = "stack" style = { { gap : 6 } } >
< strong > 시 간 < / strong >
< Seg
value = { timeKey ( o . timeControl ) }
options = { [
. . . TIME_PRESETS . map ( ( [ k , l ] ) = > [ k , l ] as [ string , string ] ) ,
. . . ( TIME_PRESETS . some ( ( p ) = > p [ 0 ] === timeKey ( o . timeControl ) ) ? [ ] : [ [ timeKey ( o . timeControl ) , ` ${ timeLabel ( o . timeControl ) } (관리자 설정) ` ] as [ string , string ] ] ) ,
] }
onChange = { ( k ) = > {
const preset = TIME_PRESETS . find ( ( p ) = > p [ 0 ] === k ) ;
if ( preset ) set ( { timeControl : { . . . o . timeControl , . . . preset [ 2 ] } } ) ;
} }
/ >
< / div >
< div className = "stack" style = { { gap : 6 } } >
< strong > 흑 ( 먼 저 두 는 쪽 ) < / strong >
< Seg
value = { o . colorAssignment }
options = { [ [ 'random' , '무작위' ] , [ 'hostBlack' , '방장이 흑' ] , [ 'hostWhite' , '방장이 백' ] , [ 'alternate' , '판마다 번갈아' ] ] }
onChange = { ( colorAssignment ) = > set ( { colorAssignment } ) }
/ >
< / div >
< div className = "stack" style = { { gap : 6 } } >
< strong > 둘 수 있 는 칸 < / strong >
< Seg value = { onOff ( o . showLegalMoves ) } options = { [ [ 'on' , '점으로 보여 주기' ] , [ 'off' , '보여 주지 않기' ] ] } onChange = { ( k ) = > set ( { showLegalMoves : k === 'on' } ) } / >
< / div >
< button type = "button" className = "btn btn-ghost" onClick = { ( ) = > setMore ( ! more ) } aria-expanded = { more } >
{ more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정' }
< / button >
{ more && (
< >
< div className = "stack" style = { { gap : 6 } } >
< strong > 뒤 집 히 는 개 수 숫 자 < / strong >
< Seg value = { onOff ( o . showFlipCount ) } options = { [ [ 'on' , '보여 주기(초보 도움)' ] , [ 'off' , '안 보여 주기' ] ] } onChange = { ( k ) = > set ( { showFlipCount : k === 'on' } ) } / >
< / div >
< div className = "stack" style = { { gap : 6 } } >
< strong > 점 수 계 산 < / strong >
< Seg value = { onOff ( o . emptyToWinner ) } options = { [ [ 'off' , '돌 개수 그대로' ] , [ 'on' , '남은 빈칸은 이긴 쪽에(대회 방식)' ] ] } onChange = { ( k ) = > set ( { emptyToWinner : k === 'on' } ) } / >
< / div >
< div className = "stack" style = { { gap : 6 } } >
< strong > 무 르 기 < / strong >
< Seg value = { o . undo } options = { [ [ 'off' , '안 씀' ] , [ '1' , '1번' ] , [ '3' , '3번' ] , [ 'unlimited' , '무제한' ] ] } onChange = { ( undo ) = > set ( { undo } ) } / >
< / div >
{ o . timeControl . kind === 'perMove' && (
< div className = "stack" style = { { gap : 6 } } >
< strong > 시 간 이 다 되 면 < / strong >
< Seg value = { o . timeoutPolicy } options = { [ [ 'autoMove' , '자동으로 두기' ] , [ 'lose' , '시간패' ] ] } onChange = { ( timeoutPolicy ) = > set ( { timeoutPolicy } ) } / >
< / div >
) }
< div className = "stack" style = { { gap : 6 } } >
< strong > 무 승 부 제 안 < / strong >
< Seg value = { onOff ( o . drawOffer ) } options = { [ [ 'on' , '허용' ] , [ 'off' , '안 함' ] ] } onChange = { ( k ) = > set ( { drawOffer : k === 'on' } ) } / >
< / div >
< / >
) }
< / div >
) ;
}
function optionsSummary ( o : OthelloOptions ) : string {
return [ '8× 8' , timeLabel ( o . timeControl ) , o . showLegalMoves ? '둘 곳 표시' : '둘 곳 숨김' , . . . ( o . emptyToWinner ? [ '대회 점수' ] : [ ] ) ] . join ( ' · ' ) ;
}
/** One-row diagram for the rules: cells are 'B' | 'W' | '.' with optional highlighted cell. */
function RowDiagram ( { cells , mark , caption } : { cells : string ; mark? : number ; caption : string } ) {
return (
< figure style = { { margin : 0 , textAlign : 'center' } } >
< svg viewBox = { ` 0 0 ${ cells . length } 1 ` } style = { { width : '100%' , maxWidth : 220 , display : 'block' , margin : '0 auto' , background : 'var(--felt)' , borderRadius : 6 } } aria - hidden >
{ cells . split ( '' ) . map ( ( c , i ) = > (
< g key = { i } >
< rect x = { i } y = { 0 } width = { 1 } height = { 1 } fill = "none" stroke = "#0b2a1d" strokeWidth = { 0.04 } / >
{ c !== '.' && < Disc color = { c === 'B' ? 'black' : 'white' } cx = { i + 0.5 } cy = { 0.5 } r = { 0.38 } / > }
< / g >
) ) }
{ mark !== undefined && < circle cx = { mark + 0.5 } cy = { 0.5 } r = { 0.1 } fill = "#ef4444" stroke = "#fff" strokeWidth = { 0.025 } / > }
< / svg >
< figcaption className = "small muted" style = { { marginTop : 4 } } >
{ caption }
< / figcaption >
< / figure >
) ;
}
function Rules ( { options } : { options? : OthelloOptions } ) {
return (
< div className = "stack" >
< p >
흑 과 백 이 번 갈 아 8 × 8 판 의 빈 칸 에 돌 을 하 나 씩 놓 아 요 . 흑 이 먼 저 둬 요 . 오 셀 로 와 같 은 규 칙 이 에 요 . 게 임 이 끝 났 을 때 < strong > 내 색 돌 이 더 많 으 면 < / strong > 이 겨 요 .
< / p >
< p >
< strong > 끼 워 서 뒤 집 기 < / strong > : 상 대 돌 을 내 돌 두 개 사 이 에 끼 우 면 , 끼 인 상 대 돌 이 모 두 내 색 으 로 뒤 집 혀 요 . 가 로 , 세 로 , 대 각 선 어 느 방 향 이 든 돼 요 .
< / p >
< div style = { { display : 'grid' , gridTemplateColumns : 'repeat(auto-fit, minmax(150px, 1fr))' , gap : 12 } } >
< RowDiagram cells = "BWW." caption = "① 흑 돌 옆에 백 돌이 있어요" / >
< RowDiagram cells = "BWWB" mark = { 3 } caption = "② 끝에 흑을 놓아 백을 끼워요" / >
< RowDiagram cells = "BBBB" mark = { 3 } caption = "③ 끼인 백이 흑으로 뒤집혀요" / >
< / div >
< p > 상 대 돌 을 하 나 도 뒤 집 을 수 없 는 칸 에 는 둘 수 없 어 요 . 뒤 집 을 수 있 는 칸 이 있 으 면 꼭 그 중 한 곳 에 둬 야 해 요 . < / p >
< p >
< strong > 패 스 < / strong > : 둘 곳 이 하 나 도 없 으 면 자 동 으 로 차 례 가 상 대 에 게 넘 어 가 요 . 누 를 것 은 없 어 요 . 둘 다 둘 곳 이 없 으 면 ( 판 이 가 득 차 도 ) 게 임 이 끝 나 요 .
< / p >
{ options ? . emptyToWinner && < p > 이 방 은 대 회 방 식 이 라 , 빈 칸 이 남 은 채 끝 나 면 남 은 빈 칸 수 를 이 긴 쪽 점 수 에 더 해 요 ( 승 패 는 그 대 로 예 요 ) . < / p > }
< p > 판 의 작 은 점 은 지 금 둘 수 있 는 칸 이 에 요 ( 설 정 에 서 끌 수 있 어 요 ) . PC에서는 마 우 스 를 올 리 면 뒤 집 힐 돌 이 노 랗 게 보 이 고 , 한 번 누 르 면 놓 여 요 . < / p >
< p className = "muted" > 팁 : 네 귀 퉁 이 ( 모 서 리 ) 에 놓 은 돌 은 다 시 뒤 집 히 지 않 아 서 아 주 유 리 해 요 . < / p >
< / div >
) ;
}
const ui : GameUI < OthelloView , OthelloOptions > = {
Board : OthelloBoard ,
Side : OthelloSide ,
statusText ,
isMyTurn : ( v , me ) = > v . phase === 'playing' && ! ! v . myColor && v . players [ v . turn ] === me ,
Options ,
optionsSummary ,
Rules ,
} ;
export default ui ;