| 1 | |
| 2 | |
| 3 | |
| 4 | import { useMemo, useState } from 'react'; |
| 5 | import { CardRow, Hand } from './deskpark/Hand'; |
| 6 | import { PlayingCard } from './deskpark/PlayingCard'; |
| 7 | import type { Card } from './deskpark/PlayingCard'; |
| 8 | import { Avatar, Seat } from './deskpark/Seat'; |
| 9 | import { CountChip, MoveZone, useHintCycle, useSelection } from './deskpark/trick'; |
| 10 | import type { Move, Req, View } from './model'; |
| 11 | import { ORDER, cardsFor, isBomb, moveOf, rankChar, sortHand } from './model'; |
| 12 | |
| 13 | const LABEL: Record<string, string> = { B: '小', R: '大', T: '10' }; |
| 14 | |
| 15 | function CardCounter({ v, me }: { v: View; me: number | null }) { |
| 16 | const seen = [...v.played, ...(me !== null ? (v.hands[me] ?? []) : [])].map(rankChar); |
| 17 | const order = [...ORDER].reverse(); |
| 18 | return ( |
| 19 | <div className="counter" aria-label="记牌器"> |
| 20 | {order.map((r) => { |
| 21 | const joker = r === 'B' || r === 'R'; |
| 22 | const n = (joker ? 1 : 4) - seen.filter((x) => x === r).length; |
| 23 | return ( |
| 24 | <span key={r} className={n === 0 ? 'is-zero' : n >= 4 || (joker && n === 1) ? 'is-full' : ''}> |
| 25 | <em>{LABEL[r] ?? r}</em> |
| 26 | <b className="num">{n}</b> |
| 27 | </span> |
| 28 | ); |
| 29 | })} |
| 30 | </div> |
| 31 | ); |
| 32 | } |
| 33 | |
| 34 | const zone = (m: Move) => (m === null ? null : m === 'pass' ? { type: 'pass' as const } : { type: 'play' as const, cards: m }); |
| 35 | |
| 36 | |
| 37 | function hintOrder(a: string, b: string) { |
| 38 | const main = (m: string) => Math.max(...[...m].map((r) => [...m].filter((x) => x === r).length * 100 + ORDER.indexOf(r))); |
| 39 | const key = (m: string) => (isBomb(m) ? 1e4 : 0) + (main(m) % 100); |
| 40 | return key(a) - key(b) || b.length - a.length; |
| 41 | } |
| 42 | |
| 43 | export interface BoardProps { |
| 44 | v: View; |
| 45 | |
| 46 | me: number; |
| 47 | |
| 48 | peek: boolean; |
| 49 | |
| 50 | mine: number[]; |
| 51 | req: Req | null; |
| 52 | onAct?: (action: string) => void; |
| 53 | } |
| 54 | |
| 55 | export function Board({ v, me, peek, mine, req, onAct }: BoardProps) { |
| 56 | const right = (me + 1) % 3; |
| 57 | const left = (me + 2) % 3; |
| 58 | const over = !!v.result; |
| 59 | const myTurn = !!req && req.seat === me && !over; |
| 60 | const hand = useMemo(() => sortHand(v.hands[me] ?? []), [v.hands, me]); |
| 61 | const [sel, setSel] = useSelection(hand, req?.version); |
| 62 | const [sent, setSent] = useState(0); |
| 63 | const waiting = myTurn && sent === req!.version; |
| 64 | const canAct = myTurn && !waiting; |
| 65 | const legal = useMemo(() => new Set(req?.legal ?? []), [req]); |
| 66 | |
| 67 | const candidates = useMemo(() => { |
| 68 | if (!req) return [] as number[][]; |
| 69 | return req.legal |
| 70 | .filter((m) => m !== 'pass') |
| 71 | .sort(hintOrder) |
| 72 | .map((m) => cardsFor(hand, m).map((c) => c.id)); |
| 73 | |
| 74 | }, [req?.version, me]); |
| 75 | const nextHint = useHintCycle(candidates, req?.version); |
| 76 | |
| 77 | const selected = hand.filter((c) => sel.has(c.id)); |
| 78 | const move = moveOf(selected); |
| 79 | const playable = canAct && selected.length > 0 && legal.has(move); |
| 80 | const send = (a: string) => { |
| 81 | if (!req) return; |
| 82 | onAct?.(a); |
| 83 | setSent(req.version); |
| 84 | setSel(new Set()); |
| 85 | }; |
| 86 | |
| 87 | const role = (s: number) => (v.landlord === null ? undefined : s === v.landlord ? '地主' : '农民'); |
| 88 | const tone = (s: number) => (s === v.landlord ? 'gold' : 'plain') as 'gold' | 'plain'; |
| 89 | |
| 90 | const bubble = (s: number) => (v.step === 0 && s === v.landlord && !over ? '叫地主' : undefined); |
| 91 | const player = (s: number) => ({ name: v.names[s], isBot: v.bots[s], isMe: mine.includes(s) }); |
| 92 | const shown = (s: number): Card[] | null => (peek || over ? v.hands[s] : null); |
| 93 | |
| 94 | const opponent = (s: number, side: 'left' | 'right') => ( |
| 95 | <div className={`ddz__seat is-${side}`}> |
| 96 | <Seat |
| 97 | player={player(s)} |
| 98 | active={v.turn === s && !over} |
| 99 | tag={role(s)} |
| 100 | tagTone={tone(s)} |
| 101 | sub={ |
| 102 | <span className="ddz__sub"> |
| 103 | <CountChip n={v.counts[s]} /> |
| 104 | </span> |
| 105 | } |
| 106 | bubble={bubble(s)} |
| 107 | bubbleSide={side === 'left' ? 'right' : 'left'} |
| 108 | /> |
| 109 | {shown(s) && ( |
| 110 | <div className={`peek is-${side}`}> |
| 111 | {sortHand(shown(s)!).map((c) => ( |
| 112 | <PlayingCard key={c.id} card={c} w="var(--cw-xs)" /> |
| 113 | ))} |
| 114 | </div> |
| 115 | )} |
| 116 | </div> |
| 117 | ); |
| 118 | |
| 119 | return ( |
| 120 | <div className="ddz"> |
| 121 | <div className="ddz__info"> |
| 122 | <div className="ddz__bottom"> |
| 123 | {v.bottom.length |
| 124 | ? sortHand(v.bottom).map((c) => <PlayingCard key={c.id} card={c} w="var(--cw-xs)" />) |
| 125 | : [0, 1, 2].map((i) => <PlayingCard key={i} faceDown w="var(--cw-xs)" />)} |
| 126 | </div> |
| 127 | <div className="info-chips"> |
| 128 | <span className="chip"> |
| 129 | 底分 <b className="num">1</b> |
| 130 | </span> |
| 131 | <span className="chip is-gold"> |
| 132 | 倍数 <b className="num">×{2 ** v.bombs * (v.result?.spring ? 2 : 1)}</b> |
| 133 | </span> |
| 134 | {v.bombs > 0 && ( |
| 135 | <span className="chip"> |
| 136 | 炸弹 <b className="num">{v.bombs}</b> |
| 137 | </span> |
| 138 | )} |
| 139 | </div> |
| 140 | </div> |
| 141 | |
| 142 | {v.landlord !== null && <CardCounter v={v} me={peek || mine.includes(me) ? me : null} />} |
| 143 | |
| 144 | {opponent(left, 'left')} |
| 145 | {opponent(right, 'right')} |
| 146 | |
| 147 | <div className="ddz__center"> |
| 148 | <div className="ddz__zone is-left"> |
| 149 | <MoveZone move={zone(v.moves[left])} from="left" /> |
| 150 | </div> |
| 151 | <div className="ddz__zone is-right"> |
| 152 | <MoveZone move={zone(v.moves[right])} from="right" /> |
| 153 | </div> |
| 154 | <div className="ddz__zone is-me"> |
| 155 | <MoveZone move={zone(v.moves[me])} from="bottom" /> |
| 156 | </div> |
| 157 | </div> |
| 158 | |
| 159 | <div className="ddz__me"> |
| 160 | <div className="ddz__myseat"> |
| 161 | <Seat |
| 162 | player={player(me)} |
| 163 | active={v.turn === me && !over} |
| 164 | tag={role(me)} |
| 165 | tagTone={tone(me)} |
| 166 | sub={hand.length ? undefined : <CountChip n={v.counts[me]} />} |
| 167 | bubble={bubble(me)} |
| 168 | bubbleSide="right" |
| 169 | layout="row" |
| 170 | size={54} |
| 171 | /> |
| 172 | </div> |
| 173 | {canAct && ( |
| 174 | <div className="action-bar"> |
| 175 | {legal.size === 1 && legal.has('pass') ? ( |
| 176 | <button className="btn btn--felt-primary btn--md" onClick={() => send('pass')}> |
| 177 | 要不起 |
| 178 | </button> |
| 179 | ) : ( |
| 180 | <> |
| 181 | {legal.has('pass') && ( |
| 182 | <button className="btn btn--felt btn--md" onClick={() => send('pass')}> |
| 183 | 不出 |
| 184 | </button> |
| 185 | )} |
| 186 | <button className="btn btn--felt btn--md" onClick={() => setSel(new Set(nextHint() ?? []))}> |
| 187 | 提示 |
| 188 | </button> |
| 189 | <button className="btn btn--felt-primary btn--md" disabled={!playable} onClick={() => send(move)}> |
| 190 | 出牌 |
| 191 | </button> |
| 192 | </> |
| 193 | )} |
| 194 | </div> |
| 195 | )} |
| 196 | {waiting && <div className="action-hint">已出牌,等待牌桌</div>} |
| 197 | {!myTurn && !over && v.turn !== null && mine.length > 0 && ( |
| 198 | <div className="action-hint"> |
| 199 | 等待 <b>{v.names[v.turn]}</b> 出牌 |
| 200 | </div> |
| 201 | )} |
| 202 | {mine.length === 0 && !peek && !over && <div className="action-hint">观战中</div>} |
| 203 | </div> |
| 204 | |
| 205 | <div className="ddz__hand"> |
| 206 | {hand.length > 0 && ( |
| 207 | <Hand cards={hand} selected={sel} onChange={canAct ? setSel : undefined} disabled={!canAct} dealKey={peek ? 'replay' : 'live'} /> |
| 208 | )} |
| 209 | </div> |
| 210 | |
| 211 | {v.fx && ( |
| 212 | <div key={`${v.step}-${v.fx}`} className={`fx ${v.fx === '春天' ? 'is-spring' : 'is-bomb'}`}> |
| 213 | <span>{v.fx}</span> |
| 214 | </div> |
| 215 | )} |
| 216 | {over && <Result v={v} me={me} mine={mine} />} |
| 217 | </div> |
| 218 | ); |
| 219 | } |
| 220 | |
| 221 | function Result({ v, me, mine }: { v: View; me: number; mine: number[] }) { |
| 222 | const r = v.result!; |
| 223 | const [open, setOpen] = useState(true); |
| 224 | if (!open) return null; |
| 225 | const playing = mine.includes(me); |
| 226 | const iWon = r.landlordWon !== null && r.landlordWon === (me === v.landlord); |
| 227 | const title = |
| 228 | r.landlordWon === null ? '牌局中止' : playing ? (iWon ? '胜利' : '失败') : r.landlordWon ? '地主胜' : '农民胜'; |
| 229 | const details = [ |
| 230 | v.landlord !== null ? `地主 ${v.names[v.landlord]}` : '', |
| 231 | v.bombs ? `炸弹 ${v.bombs}` : '', |
| 232 | r.spring ? (r.landlordWon ? '春天' : '反春') : '', |
| 233 | `×${2 ** v.bombs * (r.spring ? 2 : 1)}`, |
| 234 | ].filter(Boolean); |
| 235 | return ( |
| 236 | <div className="sheet-backdrop is-late" onClick={() => setOpen(false)}> |
| 237 | <div className="sheet" onClick={(e) => e.stopPropagation()}> |
| 238 | <div className="sheet__body"> |
| 239 | <div className="result"> |
| 240 | <div className={`result__title ${!playing || iWon ? 'is-win' : 'is-lose'}`}>{title}</div> |
| 241 | <div className="result__sub">{details.join(' · ')}</div> |
| 242 | <table className="result__table"> |
| 243 | <thead> |
| 244 | <tr> |
| 245 | <th>玩家</th> |
| 246 | <th className="num">本局</th> |
| 247 | </tr> |
| 248 | </thead> |
| 249 | <tbody> |
| 250 | {[0, 1, 2].map((s) => ( |
| 251 | <tr key={s} className={mine.includes(s) ? 'is-me' : ''}> |
| 252 | <td> |
| 253 | <span className="result__who"> |
| 254 | <Avatar name={v.names[s]} size={26} /> |
| 255 | {v.names[s]} |
| 256 | <span className={`mini-tag ${s === v.landlord ? '' : 'is-plain'}`}> |
| 257 | {s === v.landlord ? '地主' : '农民'} |
| 258 | </span> |
| 259 | </span> |
| 260 | </td> |
| 261 | <td className={`num ${r.points[s] >= 0 ? 'delta-pos' : 'delta-neg'}`}> |
| 262 | {r.points[s] > 0 ? `+${r.points[s]}` : r.points[s]} |
| 263 | </td> |
| 264 | </tr> |
| 265 | ))} |
| 266 | </tbody> |
| 267 | </table> |
| 268 | <div className="result__hands"> |
| 269 | {[0, 1, 2] |
| 270 | .filter((s) => (v.hands[s] ?? []).length > 0) |
| 271 | .map((s) => ( |
| 272 | <div className="result__hand" key={s}> |
| 273 | <span>{v.names[s]}</span> |
| 274 | <CardRow cards={sortHand(v.hands[s]!)} w="var(--cw-xs)" overlap={0.5} /> |
| 275 | </div> |
| 276 | ))} |
| 277 | </div> |
| 278 | </div> |
| 279 | </div> |
| 280 | <div className="sheet__foot"> |
| 281 | <button className="btn btn--secondary btn--md" onClick={() => setOpen(false)}> |
| 282 | 看牌桌 |
| 283 | </button> |
| 284 | </div> |
| 285 | </div> |
| 286 | </div> |
| 287 | ); |
| 288 | } |