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