anvilsign in

collin/mahjong

1import { useState } from 'react';
2import type { TableCtl } from '../game/ctl';
3import type { Prefs } from '../game/prefs';
4import type { SeatId } from '../game/types';
5import { labelOf } from '../game/tiles';
6import { Bi, BiEm } from './Bi';
7import { DiceDebug } from './DiceDebug';
8import { useLang, useT } from './lang';
9import { FullscreenToggle } from './FullscreenToggle';
10import { SoundToggle } from './SoundToggle';
11import { TileView } from './TileView';
12import { SEAT_ROT } from './rotation';
13import { WallRing } from './WallRing';
14
15interface 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
29export 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 <div className="table-controls">
208 {undoable && (
209 <button className="btn ghost undo" onClick={() => game.undo()}>
210 <span className="undo-arrow" aria-hidden>
211 ↺
212 </span>
213 <span className="undo-what">
214 {lang !== 'en' && (
215 <span className="zh" lang="zh-TW">
216 復原 {undoable.zh}
217 </span>
218 )}
219 {lang !== 'zh' && (
220 <span className="en" lang="en">
221 Undo · {undoable.en}
222 </span>
223 )}
224 </span>
225 </button>
226 )}
227 {/* Only on the layout the address bar actually crowds. */}
228 {compact && <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />}
229 <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
230 </div>
231 </div>
232 );
233}