anvilsign in

collin/mahjong

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