| 1 | |
| 2 | |
| 3 | |
| 4 | import type { ReactNode } from 'react'; |
| 5 | import { useSyncExternalStore } from 'react'; |
| 6 | |
| 7 | export interface PlayerInfo { |
| 8 | name: string; |
| 9 | |
| 10 | isBot?: boolean; |
| 11 | isMe?: boolean; |
| 12 | } |
| 13 | |
| 14 | function initials(name: string): string { |
| 15 | const words = name.trim().split(/[\s_\-.]+/).filter(Boolean); |
| 16 | if (!words.length) return '?'; |
| 17 | if (!/^[\x20-\x7e]/.test(words[0])) return [...words[0]][0]; |
| 18 | return (words.length > 1 ? words[0][0] + words[1][0] : words[0].slice(0, 2)).toUpperCase(); |
| 19 | } |
| 20 | |
| 21 | export function Avatar({ name, size = 44 }: { name: string; size?: number }) { |
| 22 | let h = 7; |
| 23 | for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 360; |
| 24 | return ( |
| 25 | <span |
| 26 | className="avatar" |
| 27 | style={{ width: size, height: size, fontSize: size * 0.38, background: `hsl(${h} 30% 40%)` }} |
| 28 | aria-hidden="true" |
| 29 | > |
| 30 | {initials(name)} |
| 31 | </span> |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | const COMPACT_QUERY = '(max-height: 500px)'; |
| 36 | |
| 37 | function useCompact(): boolean { |
| 38 | return useSyncExternalStore( |
| 39 | (cb) => { |
| 40 | const m = window.matchMedia(COMPACT_QUERY); |
| 41 | m.addEventListener('change', cb); |
| 42 | return () => m.removeEventListener('change', cb); |
| 43 | }, |
| 44 | () => window.matchMedia(COMPACT_QUERY).matches, |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | |
| 49 | function seatScale(): number { |
| 50 | const s = Math.min(window.innerWidth / 1440, window.innerHeight / 860); |
| 51 | return Math.round(Math.min(1.5, Math.max(1, s)) * 20) / 20; |
| 52 | } |
| 53 | function useSeatScale(): number { |
| 54 | return useSyncExternalStore((cb) => { |
| 55 | window.addEventListener('resize', cb); |
| 56 | return () => window.removeEventListener('resize', cb); |
| 57 | }, seatScale); |
| 58 | } |
| 59 | |
| 60 | export interface SeatProps { |
| 61 | player: PlayerInfo; |
| 62 | active?: boolean; |
| 63 | |
| 64 | tag?: ReactNode; |
| 65 | tagTone?: 'gold' | 'plain' | 'accent'; |
| 66 | |
| 67 | sub?: ReactNode; |
| 68 | |
| 69 | bubble?: ReactNode; |
| 70 | bubbleSide?: 'top' | 'bottom' | 'left' | 'right'; |
| 71 | size?: number; |
| 72 | layout?: 'column' | 'row'; |
| 73 | children?: ReactNode; |
| 74 | } |
| 75 | |
| 76 | export function Seat({ |
| 77 | player, |
| 78 | active, |
| 79 | tag, |
| 80 | tagTone = 'gold', |
| 81 | sub, |
| 82 | bubble, |
| 83 | bubbleSide = 'top', |
| 84 | size = 60, |
| 85 | layout = 'column', |
| 86 | children, |
| 87 | }: SeatProps) { |
| 88 | const compact = useCompact(); |
| 89 | const scale = useSeatScale(); |
| 90 | |
| 91 | const px = compact ? Math.round(size * 0.7) : Math.round(size * scale); |
| 92 | return ( |
| 93 | <div className={`seat seat--${layout} ${active ? 'is-active' : ''}`}> |
| 94 | <div className="seat__avatar"> |
| 95 | <Avatar name={player.name} size={px} /> |
| 96 | {tag && <span className={`seat__tag tone-${tagTone}`}>{tag}</span>} |
| 97 | {player.isBot && ( |
| 98 | <span className="seat__badge" title="电脑"> |
| 99 | <svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" fill="currentColor"> |
| 100 | <rect x="1.5" y="3.5" width="9" height="7" rx="2" /> |
| 101 | <rect x="5.4" y="0.8" width="1.2" height="2.7" /> |
| 102 | <circle cx="4.3" cy="7" r="1" fill="#f3f1ea" /> |
| 103 | <circle cx="7.7" cy="7" r="1" fill="#f3f1ea" /> |
| 104 | </svg> |
| 105 | </span> |
| 106 | )} |
| 107 | </div> |
| 108 | <div className="seat__meta"> |
| 109 | <span className="seat__name"> |
| 110 | {player.name} |
| 111 | {player.isMe && <span className="seat__me">我</span>} |
| 112 | </span> |
| 113 | {sub !== undefined && <span className="seat__sub num">{sub}</span>} |
| 114 | </div> |
| 115 | {bubble && <div className={`seat__bubble side-${bubbleSide}`}>{bubble}</div>} |
| 116 | {children} |
| 117 | </div> |
| 118 | ); |
| 119 | } |