| 1 | import type { Game } from '../game/engine'; |
| 2 | import type { Prefs } from '../game/prefs'; |
| 3 | import type { SeatId } from '../game/types'; |
| 4 | import { labelOf } from '../game/tiles'; |
| 5 | import { Bi } from './Bi'; |
| 6 | import { SoundToggle } from './SoundToggle'; |
| 7 | import { TileView } from './TileView'; |
| 8 | import { SEAT_ROT } from './rotation'; |
| 9 | import { WallRing } from './WallRing'; |
| 10 | |
| 11 | interface Props { |
| 12 | game: Game; |
| 13 | prefs: Prefs; |
| 14 | onPrefs: (p: Prefs) => void; |
| 15 | onSettings: () => void; |
| 16 | } |
| 17 | |
| 18 | export function Center({ game, prefs, onPrefs, onSettings }: Props) { |
| 19 | const s = game.state; |
| 20 | const r = s.result; |
| 21 | // Point the live readout at whoever can move the game along. |
| 22 | const pending = (Object.keys(s.options) as unknown as number[]) |
| 23 | .map(Number) |
| 24 | .filter((k) => s.responses[k as SeatId] === undefined) as SeatId[]; |
| 25 | const facing = game.nextDrawer() ?? s.turn; |
| 26 | // Deliberately not per-seat: whoever spots the mis-tap should be able to |
| 27 | // reach it, not just the player who made it. |
| 28 | const undoable = s.phase === 'gameEnd' ? null : game.undoable(); |
| 29 | |
| 30 | return ( |
| 31 | <div className="center"> |
| 32 | {s.phase !== 'gameEnd' && <WallRing state={s} />} |
| 33 | |
| 34 | <div className="facing" style={{ transform: `rotate(${SEAT_ROT[facing]}deg)` }}> |
| 35 | {s.lastDiscard && s.phase !== 'handEnd' && ( |
| 36 | <div className="last-discard"> |
| 37 | <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div> |
| 38 | <TileView tile={s.lastDiscard.tile} size="hand" highlight /> |
| 39 | </div> |
| 40 | )} |
| 41 | |
| 42 | {s.phase === 'claims' && ( |
| 43 | <div className="prompt"> |
| 44 | {pending.length} 家可吃碰 · {pending.length} may claim |
| 45 | </div> |
| 46 | )} |
| 47 | {s.phase === 'robkong' && <div className="prompt warn">搶槓機會 · Rob the kong?</div>} |
| 48 | </div> |
| 49 | |
| 50 | {r && s.phase !== 'gameEnd' && ( |
| 51 | <div className="result"> |
| 52 | {r.drawGame ? ( |
| 53 | <h2>流局 · Draw</h2> |
| 54 | ) : ( |
| 55 | <> |
| 56 | {/* One block per winner — more than one only under 一炮多響. */} |
| 57 | {r.winners.map((w) => ( |
| 58 | <div className="win" key={w.seat}> |
| 59 | <h2> |
| 60 | {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台 |
| 61 | <span className="wt"> {labelOf(w.winTile)}</span> |
| 62 | </h2> |
| 63 | {w.liable !== null && ( |
| 64 | <div className="liable"> |
| 65 | 包牌 · {s.players[w.liable].name} pays the whole hand |
| 66 | </div> |
| 67 | )} |
| 68 | <div className="tai-list"> |
| 69 | {w.items.map((it, i) => ( |
| 70 | <div key={i} className="tai-item"> |
| 71 | <span className="tai-name"> |
| 72 | {it.name} |
| 73 | <em>{it.en}</em> |
| 74 | </span> |
| 75 | <span className="tai-val">{it.tai}</span> |
| 76 | </div> |
| 77 | ))} |
| 78 | </div> |
| 79 | </div> |
| 80 | ))} |
| 81 | {r.winners.length > 1 && ( |
| 82 | <div className="multi">一炮多響 · one discard, {r.winners.length} winners</div> |
| 83 | )} |
| 84 | <div className="deltas"> |
| 85 | {r.deltas.map((d, i) => ( |
| 86 | <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}> |
| 87 | {s.players[i].name} {d > 0 ? `+${d}` : d} |
| 88 | </span> |
| 89 | ))} |
| 90 | </div> |
| 91 | </> |
| 92 | )} |
| 93 | <button className="btn big-btn" onClick={() => game.nextHand()}> |
| 94 | <Bi zh="下一局" en="Next hand" /> |
| 95 | </button> |
| 96 | </div> |
| 97 | )} |
| 98 | |
| 99 | {s.phase === 'gameEnd' && ( |
| 100 | <div className="result"> |
| 101 | <h2>一將終了 · Game over</h2> |
| 102 | <div className="deltas"> |
| 103 | {[...s.players] |
| 104 | .map((p, i) => ({ p, i })) |
| 105 | .sort((a, b) => b.p.score - a.p.score) |
| 106 | .map(({ p, i }) => ( |
| 107 | <span key={i} className={p.score > s.rules.startingScore ? 'up' : p.score < s.rules.startingScore ? 'down' : ''}> |
| 108 | {p.name} {p.score} |
| 109 | </span> |
| 110 | ))} |
| 111 | </div> |
| 112 | <div className="over-buttons"> |
| 113 | <button className="btn big-btn" onClick={() => game.newGame()}> |
| 114 | <Bi zh="再來一將" en="New game" /> |
| 115 | </button> |
| 116 | {/* The moment anyone is going to want the names or the stakes changed. */} |
| 117 | <button className="btn ghost big-btn" onClick={onSettings}> |
| 118 | <Bi zh="設定" en="Settings" /> |
| 119 | </button> |
| 120 | </div> |
| 121 | </div> |
| 122 | )} |
| 123 | |
| 124 | <div className="table-controls"> |
| 125 | {undoable && ( |
| 126 | <button className="btn ghost undo" onClick={() => game.undo()}> |
| 127 | <span className="undo-arrow" aria-hidden> |
| 128 | ↺ |
| 129 | </span> |
| 130 | <span className="undo-what"> |
| 131 | <span className="zh">復原 {undoable.zh}</span> |
| 132 | <span className="en">Undo · {undoable.en}</span> |
| 133 | </span> |
| 134 | </button> |
| 135 | )} |
| 136 | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 137 | </div> |
| 138 | </div> |
| 139 | ); |
| 140 | } |