| 1 | import type { TableCtl } from '../game/ctl'; |
| 2 | import type { Prefs } from '../game/prefs'; |
| 3 | import type { SeatId } from '../game/types'; |
| 4 | import { labelOf } from '../game/tiles'; |
| 5 | import { Bi, BiEm } from './Bi'; |
| 6 | import { DiceDebug } from './DiceDebug'; |
| 7 | import { useLang, useT } from './lang'; |
| 8 | import { FullscreenToggle } from './FullscreenToggle'; |
| 9 | import { SoundToggle } from './SoundToggle'; |
| 10 | import { TileView } from './TileView'; |
| 11 | import { SEAT_ROT } from './rotation'; |
| 12 | import { WallRing } from './WallRing'; |
| 13 | |
| 14 | interface Props { |
| 15 | game: TableCtl; |
| 16 | prefs: Prefs; |
| 17 | /** Phone-sized table: the centre becomes a single bar between the seats. */ |
| 18 | compact?: boolean; |
| 19 | /** The seat drawn at the bottom of this screen — 0 except online, where the |
| 20 | * table is turned so it is your own. */ |
| 21 | viewSeat?: SeatId; |
| 22 | /** Online: the one person reading this screen, whatever the bots are doing. */ |
| 23 | preferSeat?: SeatId | null; |
| 24 | onPrefs: (p: Prefs) => void; |
| 25 | onSettings: () => void; |
| 26 | } |
| 27 | |
| 28 | export function Center({ game, prefs, compact, viewSeat = 0, preferSeat = null, onPrefs, onSettings }: Props) { |
| 29 | const t = useT(); |
| 30 | const lang = useLang(); |
| 31 | const s = game.state; |
| 32 | const r = s.result; |
| 33 | // Point the live readout at whoever can move the game along. |
| 34 | const pending = (Object.keys(s.options) as unknown as number[]) |
| 35 | .map(Number) |
| 36 | .filter((k) => s.responses[k as SeatId] === undefined) as SeatId[]; |
| 37 | // Against the computer there is only one person reading this, and they are |
| 38 | // sitting at the bottom of the screen. |
| 39 | const human = s.bots.indexOf(false); |
| 40 | const facing = |
| 41 | preferSeat ?? (human >= 0 && game.hasBots ? (human as SeatId) : game.nextDrawer() ?? s.turn); |
| 42 | // Deliberately not per-seat: whoever spots the mis-tap should be able to |
| 43 | // reach it, not just the player who made it. |
| 44 | const undoable = s.phase === 'gameEnd' ? null : game.undoable(); |
| 45 | |
| 46 | return ( |
| 47 | <div className="center"> |
| 48 | {/* The wall square wants the whole middle of the table, which a phone does |
| 49 | not have to give — there it shrinks to the one number it was telling |
| 50 | you anyway. */} |
| 51 | {s.phase !== 'gameEnd' && |
| 52 | (compact ? ( |
| 53 | <div className="wall-pill"> |
| 54 | <b>{game.wallLeft}</b> |
| 55 | <em>{t('剩牌', 'left')}</em> |
| 56 | </div> |
| 57 | ) : ( |
| 58 | <WallRing state={s} /> |
| 59 | ))} |
| 60 | |
| 61 | <div |
| 62 | className="facing" |
| 63 | style={ |
| 64 | compact |
| 65 | ? undefined |
| 66 | : { transform: `rotate(${SEAT_ROT[(((facing - viewSeat + 4) % 4) as SeatId)]}deg)` } |
| 67 | } |
| 68 | > |
| 69 | {/* The tile just thrown is out there in the middle, lit, where everyone |
| 70 | can see it — so saying it again here would only cover the pile. The |
| 71 | phone has no pile, and still needs telling. */} |
| 72 | {compact && s.lastDiscard && s.phase !== 'handEnd' && ( |
| 73 | <div className="last-discard"> |
| 74 | <div className="ld-label"> |
| 75 | {s.players[s.lastDiscard.from].name} {t('打', 'discarded')} |
| 76 | </div> |
| 77 | <TileView tile={s.lastDiscard.tile} size="hand" highlight /> |
| 78 | </div> |
| 79 | )} |
| 80 | |
| 81 | {s.phase === 'claims' && ( |
| 82 | <div className="prompt"> |
| 83 | {pending.length} {t('家可吃碰', 'may claim')} |
| 84 | </div> |
| 85 | )} |
| 86 | {s.phase === 'robkong' && ( |
| 87 | <div className="prompt warn">{t('搶槓機會', 'Rob the kong?', ' · ')}</div> |
| 88 | )} |
| 89 | </div> |
| 90 | |
| 91 | {r && s.phase !== 'gameEnd' && ( |
| 92 | <div className="result"> |
| 93 | {r.drawGame ? ( |
| 94 | <> |
| 95 | <h2>{t('流局', 'Draw', ' · ')}</h2> |
| 96 | {/* Nobody won, so the thing everyone wants to see is who was |
| 97 | there and on what — the same reveal as turning the hands over |
| 98 | at a real table. */} |
| 99 | <div className="ready-list"> |
| 100 | {s.players.map((p, seat) => { |
| 101 | const waiting = game.tenpai(seat as SeatId); |
| 102 | return ( |
| 103 | <div className={`ready-row ${waiting.length ? '' : 'none'}`} key={seat}> |
| 104 | <span className="ready-name">{p.name}</span> |
| 105 | {waiting.length > 0 ? ( |
| 106 | <> |
| 107 | <span className="tag ting">{t('聽', 'Ready')}</span> |
| 108 | <span className="ready-tiles"> |
| 109 | {waiting.map((t) => ( |
| 110 | <TileView key={t} tile={t} size="small" /> |
| 111 | ))} |
| 112 | </span> |
| 113 | </> |
| 114 | ) : ( |
| 115 | <span className="ready-no">{t('未聽', 'not ready', ' · ')}</span> |
| 116 | )} |
| 117 | </div> |
| 118 | ); |
| 119 | })} |
| 120 | </div> |
| 121 | </> |
| 122 | ) : ( |
| 123 | <> |
| 124 | {/* One block per winner — more than one only under 一炮多響. */} |
| 125 | {r.winners.map((w) => ( |
| 126 | <div className="win" key={w.seat}> |
| 127 | <h2> |
| 128 | {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '} |
| 129 | · {w.tai} {t('台', 'tai')} |
| 130 | <span className="wt"> {labelOf(w.winTile)}</span> |
| 131 | </h2> |
| 132 | {w.liable !== null && ( |
| 133 | <div className="liable"> |
| 134 | {t('包牌', 'Liability')} · {s.players[w.liable].name}{' '} |
| 135 | {t('一人付全部', 'pays the whole hand')} |
| 136 | </div> |
| 137 | )} |
| 138 | <div className="tai-list"> |
| 139 | {w.items.map((it, i) => ( |
| 140 | <div key={i} className="tai-item"> |
| 141 | <span className="tai-name"> |
| 142 | <BiEm zh={it.name} en={it.en} /> |
| 143 | </span> |
| 144 | <span className="tai-val">{it.tai}</span> |
| 145 | </div> |
| 146 | ))} |
| 147 | </div> |
| 148 | </div> |
| 149 | ))} |
| 150 | {r.winners.length > 1 && ( |
| 151 | <div className="multi"> |
| 152 | {t('一炮多響', `one discard, ${r.winners.length} winners`, ' · ')} |
| 153 | </div> |
| 154 | )} |
| 155 | <div className="deltas"> |
| 156 | {r.deltas.map((d, i) => ( |
| 157 | <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}> |
| 158 | {s.players[i].name} {d > 0 ? `+${d}` : d} |
| 159 | </span> |
| 160 | ))} |
| 161 | </div> |
| 162 | </> |
| 163 | )} |
| 164 | <button className="btn big-btn" onClick={() => game.nextHand()}> |
| 165 | <Bi zh="下一局" en="Next hand" /> |
| 166 | </button> |
| 167 | </div> |
| 168 | )} |
| 169 | |
| 170 | {s.phase === 'gameEnd' && ( |
| 171 | <div className="result"> |
| 172 | <h2>{t('一將終了', 'Game over', ' · ')}</h2> |
| 173 | <div className="deltas"> |
| 174 | {[...s.players] |
| 175 | .map((p, i) => ({ p, i })) |
| 176 | .sort((a, b) => b.p.score - a.p.score) |
| 177 | .map(({ p, i }) => ( |
| 178 | <span key={i} className={p.score > s.rules.startingScore ? 'up' : p.score < s.rules.startingScore ? 'down' : ''}> |
| 179 | {p.name} {p.score} |
| 180 | </span> |
| 181 | ))} |
| 182 | </div> |
| 183 | <div className="over-buttons"> |
| 184 | <button className="btn big-btn" onClick={() => game.newGame(s.bots)}> |
| 185 | <Bi zh="再來一將" en="New game" /> |
| 186 | </button> |
| 187 | {/* The moment anyone is going to want the names or the stakes changed. */} |
| 188 | <button className="btn ghost big-btn" onClick={onSettings}> |
| 189 | <Bi zh="設定" en="Settings" /> |
| 190 | </button> |
| 191 | </div> |
| 192 | </div> |
| 193 | )} |
| 194 | |
| 195 | {/* Dev only, and only where there is a square to look at: the dice decide |
| 196 | where the wall is broken, and that is a thing you want to be able to |
| 197 | step through rather than wait for. */} |
| 198 | {import.meta.env.DEV && !compact && s.phase !== 'gameEnd' && <DiceDebug game={game} />} |
| 199 | |
| 200 | <div className="table-controls"> |
| 201 | {undoable && ( |
| 202 | <button className="btn ghost undo" onClick={() => game.undo()}> |
| 203 | <span className="undo-arrow" aria-hidden> |
| 204 | ↺ |
| 205 | </span> |
| 206 | <span className="undo-what"> |
| 207 | {lang !== 'en' && ( |
| 208 | <span className="zh" lang="zh-TW"> |
| 209 | 復原 {undoable.zh} |
| 210 | </span> |
| 211 | )} |
| 212 | {lang !== 'zh' && ( |
| 213 | <span className="en" lang="en"> |
| 214 | Undo · {undoable.en} |
| 215 | </span> |
| 216 | )} |
| 217 | </span> |
| 218 | </button> |
| 219 | )} |
| 220 | {/* Only on the layout the address bar actually crowds. */} |
| 221 | {compact && <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />} |
| 222 | <SoundToggle prefs={prefs} onPrefs={onPrefs} compact /> |
| 223 | </div> |
| 224 | </div> |
| 225 | ); |
| 226 | } |