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 { DiceDebug } from './DiceDebug';
7import { useLang, useT } from './lang';
8import { FullscreenToggle } from './FullscreenToggle';
9import { SoundToggle } from './SoundToggle';
10import { TileView } from './TileView';
11import { SEAT_ROT } from './rotation';
12import { WallRing } from './WallRing';
13
14interface 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
28export 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}