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