| 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 | // Against the computer there is only one person reading this, and they are |
| 26 | // sitting at the bottom of the screen. |
| 27 | const human = s.bots.indexOf(false); |
| 28 | const facing = human >= 0 && game.hasBots ? (human as SeatId) : game.nextDrawer() ?? s.turn; |
| 29 | // Deliberately not per-seat: whoever spots the mis-tap should be able to |
| 30 | // reach it, not just the player who made it. |
| 31 | const undoable = s.phase === 'gameEnd' ? null : game.undoable(); |
| 32 | |
| 33 | return ( |
| 34 | <div className="center"> |
| 35 | {s.phase !== 'gameEnd' && <WallRing state={s} />} |
| 36 | |
| 37 | <div className="facing" style={{ transform: `rotate(${SEAT_ROT[facing]}deg)` }}> |
| 38 | {s.lastDiscard && s.phase !== 'handEnd' && ( |
| 39 | <div className="last-discard"> |
| 40 | <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div> |
| 41 | <TileView tile={s.lastDiscard.tile} size="hand" highlight /> |
| 42 | </div> |
| 43 | )} |
| 44 | |
| 45 | {s.phase === 'claims' && ( |
| 46 | <div className="prompt"> |
| 47 | {pending.length} 家可吃碰 · {pending.length} may claim |
| 48 | </div> |
| 49 | )} |
| 50 | {s.phase === 'robkong' && <div className="prompt warn">搶槓機會 · Rob the kong?</div>} |
| 51 | </div> |
| 52 | |
| 53 | {r && s.phase !== 'gameEnd' && ( |
| 54 | <div className="result"> |
| 55 | {r.drawGame ? ( |
| 56 | <> |
| 57 | <h2>流局 · Draw</h2> |
| 58 | {/* Nobody won, so the thing everyone wants to see is who was |
| 59 | there and on what — the same reveal as turning the hands over |
| 60 | at a real table. */} |
| 61 | <div className="ready-list"> |
| 62 | {s.players.map((p, seat) => { |
| 63 | const waiting = game.tenpai(seat as SeatId); |
| 64 | return ( |
| 65 | <div className={`ready-row ${waiting.length ? '' : 'none'}`} key={seat}> |
| 66 | <span className="ready-name">{p.name}</span> |
| 67 | {waiting.length > 0 ? ( |
| 68 | <> |
| 69 | <span className="tag ting">聽 Ready</span> |
| 70 | <span className="ready-tiles"> |
| 71 | {waiting.map((t) => ( |
| 72 | <TileView key={t} tile={t} size="small" /> |
| 73 | ))} |
| 74 | </span> |
| 75 | </> |
| 76 | ) : ( |
| 77 | <span className="ready-no">未聽 · not ready</span> |
| 78 | )} |
| 79 | </div> |
| 80 | ); |
| 81 | })} |
| 82 | </div> |
| 83 | </> |
| 84 | ) : ( |
| 85 | <> |
| 86 | {/* One block per winner — more than one only under 一炮多響. */} |
| 87 | {r.winners.map((w) => ( |
| 88 | <div className="win" key={w.seat}> |
| 89 | <h2> |
| 90 | {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台 |
| 91 | <span className="wt"> {labelOf(w.winTile)}</span> |
| 92 | </h2> |
| 93 | {w.liable !== null && ( |
| 94 | <div className="liable"> |
| 95 | 包牌 · {s.players[w.liable].name} pays the whole hand |
| 96 | </div> |
| 97 | )} |
| 98 | <div className="tai-list"> |
| 99 | {w.items.map((it, i) => ( |
| 100 | <div key={i} className="tai-item"> |
| 101 | <span className="tai-name"> |
| 102 | {it.name} |
| 103 | <em>{it.en}</em> |
| 104 | </span> |
| 105 | <span className="tai-val">{it.tai}</span> |
| 106 | </div> |
| 107 | ))} |
| 108 | </div> |
| 109 | </div> |
| 110 | ))} |
| 111 | {r.winners.length > 1 && ( |
| 112 | <div className="multi">一炮多響 · one discard, {r.winners.length} winners</div> |
| 113 | )} |
| 114 | <div className="deltas"> |
| 115 | {r.deltas.map((d, i) => ( |
| 116 | <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}> |
| 117 | {s.players[i].name} {d > 0 ? `+${d}` : d} |
| 118 | </span> |
| 119 | ))} |
| 120 | </div> |
| 121 | </> |
| 122 | )} |
| 123 | <button className="btn big-btn" onClick={() => game.nextHand()}> |
| 124 | <Bi zh="下一局" en="Next hand" /> |
| 125 | </button> |
| 126 | </div> |
| 127 | )} |
| 128 | |
| 129 | {s.phase === 'gameEnd' && ( |
| 130 | <div className="result"> |
| 131 | <h2>一將終了 · Game over</h2> |
| 132 | <div className="deltas"> |
| 133 | {[...s.players] |
| 134 | .map((p, i) => ({ p, i })) |
| 135 | .sort((a, b) => b.p.score - a.p.score) |
| 136 | .map(({ p, i }) => ( |
| 137 | <span key={i} className={p.score > s.rules.startingScore ? 'up' : p.score < s.rules.startingScore ? 'down' : ''}> |
| 138 | {p.name} {p.score} |
| 139 | </span> |
| 140 | ))} |
| 141 | </div> |
| 142 | <div className="over-buttons"> |
| 143 | <button className="btn big-btn" onClick={() => game.newGame(s.bots)}> |
| 144 | <Bi zh="再來一將" en="New game" /> |
| 145 | </button> |
| 146 | {/* The moment anyone is going to want the names or the stakes changed. */} |
| 147 | <button className="btn ghost big-btn" onClick={onSettings}> |
| 148 | <Bi zh="設定" en="Settings" /> |
| 149 | </button> |
| 150 | </div> |
| 151 | </div> |
| 152 | )} |
| 153 | |
| 154 | <div className="table-controls"> |
| 155 | {undoable && ( |
| 156 | <button className="btn ghost undo" onClick={() => game.undo()}> |
| 157 | <span className="undo-arrow" aria-hidden> |
| 158 | ↺ |
| 159 | </span> |
| 160 | <span className="undo-what"> |
| 161 | <span className="zh">復原 {undoable.zh}</span> |
| 162 | <span className="en">Undo · {undoable.en}</span> |
| 163 | </span> |
| 164 | </button> |
| 165 | )} |
| 166 | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 167 | </div> |
| 168 | </div> |
| 169 | ); |
| 170 | } |