anvilsign in

collin/mahjong

1import { useEffect, useState } from 'react';
2import { pendingOptions } from '../game/ctl';
3import type { Prefs } from '../game/prefs';
4import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles';
5import type { SeatId } from '../game/types';
6import type { NetSession } from '../net/session';
7import { seatFromUrl } from '../net/protocol';
8import { Bi, BiEm, BiText, CLAIM_LABEL } from './Bi';
9import { useLang, useT } from './lang';
10import { FullscreenToggle } from './FullscreenToggle';
11import { Hand, type Throw } from './Hand';
12import { SoundToggle } from './SoundToggle';
13import { TurnClock, turnId } from './TurnClock';
14import { TileView } from './TileView';
15
16const SEATS: SeatId[] = [0, 1, 2, 3];
17
18interface CtlProps {
19 session: NetSession;
20 /** For the fullscreen button — a phone's address bar is a sixth of the
21 * height this screen has to hold a hand in. */
22 prefs: Prefs;
23 onPrefs: (p: Prefs) => void;
24}
25
26/**
27 * A hand held on a phone: just your own tiles, held in landscape, with the
28 * table doing the table's share of the talking. What it keeps of the full
29 * table is the things that are yours — the buttons a claim window owes you,
30 * the throw, and your voice. Flick a tile up off the top of the phone and it
31 * leaves your hand here, crosses the wire, and comes flying in from your edge
32 * of the shared screen.
33 *
34 * Your voice, because a phone is in somebody's hand and a hand is in the room:
35 * with sound on, this phone is what says 碰 when you call one, and the table
36 * stops saying it for you. Mute the phone and the table takes the job back —
37 * see `net/session.ts` for how it is told.
38 */
39export function Controller({ session, prefs, onPrefs }: CtlProps) {
40 const seat = session.mySeat;
41 if (seat === null) return <SeatPick session={session} prefs={prefs} onPrefs={onPrefs} />;
42 return <SeatStrip session={session} seat={seat} prefs={prefs} onPrefs={onPrefs} />;
43}
44
45/**
46 * A phone with nothing to hold: it is in a room, and the room has no table in
47 * it.
48 *
49 * Which is a real thing that happens rather than an error — the laptop closed
50 * its lid, the server restarted and the room came back empty, or somebody was
51 * sent the link before the tiles were dealt. From the phone all three look the
52 * same and none of them is anything it can fix, so it says which of the two
53 * situations it is in and waits, rather than offering to deal a table of its
54 * own on a screen that could never show one.
55 *
56 * The room code is on it because that is the one thing worth reading out loud
57 * to whoever is holding the laptop.
58 */
59export function Adrift({ session }: { session: NetSession }) {
60 const t = useT();
61 const lang = useLang();
62 return (
63 <div className="ctl ctl-adrift">
64 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
65 <p className="sub">{session.room.code}</p>
66 {session.err ? (
67 <>
68 <p className="adrift-line">{t('連線中斷', 'Connection lost', ' · ')}</p>
69 <p className="hint">
70 <BiText
71 zh="正在重新連線,回來的時候你還坐在原位。"
72 en="Reconnecting — your seat is still yours when it comes back."
73 />
74 </p>
75 </>
76 ) : (
77 <>
78 <p className="adrift-line">{t('這桌還沒開', 'No table here yet', ' · ')}</p>
79 <p className="hint">
80 <BiText
81 zh="這個房間裡還沒有牌局。等開桌的那台按下開始,這頁就會自己接上。"
82 en="Nothing has been dealt in this room. When whoever has the table starts a game, this page joins it by itself."
83 />
84 </p>
85 </>
86 )}
87 <p className="hint">
88 {t(`${session.playerCount} 人在房`, `${session.playerCount} here`, ' · ')}
89 </p>
90 </div>
91 );
92}
93
94/**
95 * Which hand you are taking. Shown to any device that has walked into a room
96 * and is not the table it was opened around — a phone off a QR, a friend off
97 * the invite link.
98 *
99 * Every seat is offered, not only the empty ones. A hand the computer is
100 * playing is the ordinary way in — that is what the three chairs nobody has
101 * taken are — and a hand somebody else is already holding can be sat down at
102 * too, which is how a pair play one hand off two devices.
103 */
104export function SeatPick({ session, prefs, onPrefs }: CtlProps) {
105 const t = useT();
106 const lang = useLang();
107 const seats = session.seats;
108 const s = session.table.state;
109
110 // A seat's own QR carries which hand it was stuck to, so scanning it lands
111 // on that chair without anybody having to say which one they are in. The
112 // table's shared QR names none, and then the choice is made here.
113 const [asked, setAsked] = useState(false);
114 const want = seatFromUrl();
115 useEffect(() => {
116 if (asked || want === null) return;
117 setAsked(true);
118 session.claimSeat(want);
119 }, [session, want, asked]);
120
121 return (
122 <div className="ctl ctl-pick">
123 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
124 <p className="sub">
125 {asked
126 ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}`
127 : t('選一家', 'pick a hand', ' · ')}
128 </p>
129 <div className="pick-grid">
130 {SEATS.map((i) => {
131 const held = seats[i].ids.length;
132 const wind = (i - s.dealer + 4) % 4;
133 return (
134 <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}>
135 {t(WIND_NAMES[wind], WIND_EN[wind])}
136 <em>
137 {held > 0
138 ? `${seats[i].name} — ${t('一起打', 'join this hand')}`
139 : s.bots[i]
140 ? `${t('電腦代打中', 'the computer has it')} — ${t('接手', 'take over')}`
141 : seats[i].name || t('在桌上', 'played at the table', ' · ')}
142 </em>
143 </button>
144 );
145 })}
146 </div>
147 <div className="pick-extras">
148 {/* Turning it on here is also the tap that wakes the phone's audio, so
149 it can start doing the talking for this seat straight away. */}
150 <SoundToggle prefs={prefs} onPrefs={onPrefs} />
151 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
152 </div>
153 </div>
154 );
155}
156
157/** A tile in the fingers, drawn by us once it has left the row. */
158interface HeldTile {
159 tile: Tile;
160 x: number;
161 y: number;
162 size: number;
163 /** Set when it has been let go of: it flies up off the screen and is gone. */
164 flung: boolean;
165}
166
167function SeatStrip({ session, seat, prefs, onPrefs }: CtlProps & { seat: SeatId }) {
168 const t = useT();
169 const table = session.table;
170 const s = table.state;
171 const p = s.players[seat];
172 const [sel, setSel] = useState<number | null>(null);
173 const [held, setHeld] = useState<HeldTile | null>(null);
174
175 const myTurn = s.phase === 'turn' && s.turn === seat;
176 const opts = pendingOptions(s, seat);
177 const canProceed = table.nextDrawer() === seat;
178 const canHu = myTurn && table.canSelfDrawWin(seat);
179 const concealedKongs = myTurn ? table.concealedKongs(seat) : [];
180 const addedKongs = myTurn ? table.addedKongs(seat) : [];
181 const tenpai = s.phase !== 'handEnd' && s.phase !== 'gameEnd' ? table.tenpai(seat) : [];
182 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
183 const selTile = sel === null ? null : p.hand[sel] ?? null;
184 const seatWind = (seat - s.dealer + 4) % 4;
185 const r = s.result;
186
187 useEffect(() => {
188 setSel(null);
189 }, [myTurn, s.handNumber, p.hand.length]);
190
191 const doDiscard = (i: number) => {
192 table.discard(seat, p.hand[i]);
193 setSel(null);
194 };
195
196 // The flick. `Hand` decides when a drag has become a throw; all that is left
197 // here is to say it in the seat's own frame — how far along the edge it left
198 // from, and how fast it was going up and across — and let it go.
199 const fling = (i: number, t: Throw) => {
200 const tile = p.hand[i];
201 if (tile === undefined) return setHeld(null);
202 table.discard(seat, tile, {
203 u: t.x / window.innerWidth,
204 vx: t.vx,
205 // Up off the top of the phone is into the table.
206 vy: -t.vy,
207 spin: t.spin,
208 });
209 setSel(null);
210 setHeld((h) => (h ? { ...h, x: t.x, y: t.y, flung: true } : null));
211 };
212
213 const carry = {
214 onPickUp: (i: number, x: number, y: number, size: number) =>
215 setHeld({ tile: p.hand[i], x, y, size, flung: false }),
216 onMoveTo: (x: number, y: number) => setHeld((h) => (h && !h.flung ? { ...h, x, y } : h)),
217 onThrow: fling,
218 onCancel: () => setHeld(null),
219 };
220
221 const status = myTurn
222 ? { zh: '輪到你', en: 'your turn', hot: true }
223 : opts
224 ? { zh: '要叫牌嗎?', en: 'claim it?', hot: true }
225 : {
226 zh: `等 ${s.players[s.turn].name}`,
227 en: `waiting for ${s.players[s.turn].name}`,
228 hot: false,
229 };
230
231 /**
232 * Four people at one table and one screen in the middle of it: the phone in
233 * a pocket, or face down beside a drink, is the one that has not noticed the
234 * game has come round. So when it does, this phone says so with everything
235 * it has — it goes gold edge to edge, and it buzzes once.
236 *
237 * The buzz is per turn, not per render: the string changes when the table
238 * moves, and only then. Phones that will not vibrate (a desk browser, one
239 * that has not been tapped yet) simply do not, and the gold is still there.
240 */
241 const calling = myTurn ? 'turn' : opts ? 'claim' : null;
242 const callKey = calling && `${calling}:${turnId(s)}`;
243 useEffect(() => {
244 if (!callKey) return;
245 navigator.vibrate?.(callKey.startsWith('turn') ? [90, 70, 90] : [40, 60, 40]);
246 }, [callKey]);
247
248 return (
249 <div className={`ctl ${calling ? `ctl-${calling}` : ''} ${session.err ? 'ctl-cut' : ''}`}>
250 {/* The wire has gone. Said across the top of the hand rather than in a
251 corner, because everything below it has quietly stopped working: a
252 call tapped now is dropped on the floor, and a hand that looks live
253 and does nothing is worse than one that says why. The room reconnects
254 on its own and this clears when it does. */}
255 {session.err && (
256 <div className="ctl-cut-bar">
257 <BiEm zh="連線中斷,重新連線中" en="Connection lost — reconnecting" />
258 </div>
259 )}
260 {/* The same seven seconds the table is showing everyone else, on the
261 screen of the person they are all waiting for. */}
262 {myTurn && <TurnClock key={turnId(s)} />}
263 <div className="ctl-top">
264 <span className="wind">
265 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
266 </span>
267 <span className="pname">{p.name}</span>
268 {seat === s.dealer && <span className="tag">{t('莊', 'Dealer')}</span>}
269 {tenpai.length > 0 && (
270 <span className="tag ting">
271 {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')}
272 </span>
273 )}
274 <span className={`ctl-status ${status.hot ? 'hot' : ''}`}>
275 <BiEm zh={status.zh} en={status.en} />
276 </span>
277 {s.lastDiscard && s.phase !== 'handEnd' && (
278 <span className="ctl-last">
279 {s.players[s.lastDiscard.from].name} {t('打', 'threw')}
280 <TileView tile={s.lastDiscard.tile} size="small" />
281 </span>
282 )}
283 <span className="chips">{p.score}</span>
284 <button className="btn ghost sort" onClick={() => table.sortHand(seat)}>
285 {t('理牌', 'Sort')}
286 </button>
287 {/* This phone's own mute — and, at a party table, whether your calls
288 come out of your hand or out of the screen in the middle. */}
289 <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
290 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />
291 </div>
292
293 <div className="ctl-actions">
294 {opts?.map((o, i) => (
295 <button
296 key={i}
297 className={`btn claim ${o.type}`}
298 onClick={() => table.respond(seat, { type: o.type, with: o.with })}
299 >
300 <Bi zh={CLAIM_LABEL[o.type][0]} en={CLAIM_LABEL[o.type][1]} sub={o.with && o.with.map(labelOf).join(' ')} />
301 </button>
302 ))}
303 {opts && !canProceed && (
304 <button className="btn pass" onClick={() => table.respond(seat, 'pass')}>
305 <Bi zh="過" en="Pass" />
306 </button>
307 )}
308 {canProceed && (
309 <button className={`btn proceed ${opts ? 'pass' : ''}`} onClick={() => table.resolveNow()}>
310 <Bi
311 zh={s.phase === 'robkong' ? '繼續' : opts ? '過.摸牌' : '摸牌'}
312 en={s.phase === 'robkong' ? 'Continue' : opts ? 'Pass & draw' : 'Draw'}
313 />
314 </button>
315 )}
316 {canHu && (
317 <button className="btn claim hu" onClick={() => table.declareSelfDraw(seat)}>
318 <Bi zh="自摸" en="Self-draw" />
319 </button>
320 )}
321 {concealedKongs.map((t) => (
322 <button key={`ak${t}`} className="btn claim kong" onClick={() => table.declareConcealedKong(seat, t)}>
323 <Bi zh="暗槓" en="Kong" sub={labelOf(t)} />
324 </button>
325 ))}
326 {addedKongs.map((t) => (
327 <button key={`gk${t}`} className="btn claim kong" onClick={() => table.declareAddedKong(seat, t)}>
328 <Bi zh="加槓" en="Kong" sub={labelOf(t)} />
329 </button>
330 ))}
331 {selTile !== null && sel !== null && myTurn && (
332 <button className="btn discard" onClick={() => doDiscard(sel)}>
333 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
334 </button>
335 )}
336 {myTurn && (
337 <span className="ctl-hint">{t('往上甩出去', 'flick a tile up to throw it', ' · ')}</span>
338 )}
339 </div>
340
341 <div className="ctl-hand">
342 <Hand
343 tiles={p.hand}
344 drawnIndex={drawnIndex}
345 selected={sel}
346 canDiscard={myTurn}
347 onSelect={setSel}
348 onDiscard={doDiscard}
349 onMove={(from, to) => table.reorderHand(seat, from, to)}
350 carry={carry}
351 />
352 </div>
353
354 {/* The tile out of the row, following the finger — and, once flung, on
355 its way up off the screen to reappear on the common one. */}
356 {held && (
357 <div
358 className={`ctl-held ${held.flung ? 'flung' : ''}`}
359 style={{ left: held.x, top: held.y, width: held.size, height: held.size * 1.375 }}
360 onAnimationEnd={() => setHeld(null)}
361 >
362 <TileView tile={held.tile} />
363 </div>
364 )}
365
366 {r && s.phase === 'handEnd' && (
367 <div className="ctl-result">
368 {r.drawGame ? (
369 <h2>{t('流局', 'Draw', ' · ')}</h2>
370 ) : (
371 r.winners.map((w) => (
372 <h2 key={w.seat}>
373 {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '}
374 · {w.tai} {t('台', 'tai')}
375 </h2>
376 ))
377 )}
378 <div className="deltas">
379 {r.deltas.map((d, i) => (
380 <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}>
381 {s.players[i].name} {d > 0 ? `+${d}` : d}
382 </span>
383 ))}
384 </div>
385 <button className="btn big-btn" onClick={() => table.nextHand()}>
386 <Bi zh="下一局" en="Next hand" />
387 </button>
388 </div>
389 )}
390
391 {s.phase === 'gameEnd' && (
392 <div className="ctl-result">
393 <h2>{t('一將終了', 'Game over', ' · ')}</h2>
394 <div className="deltas">
395 {s.players.map((q, i) => (
396 <span key={i}>
397 {q.name} {q.score}
398 </span>
399 ))}
400 </div>
401 <button className="btn big-btn" onClick={() => table.newGame(s.bots)}>
402 <Bi zh="再來一將" en="New game" />
403 </button>
404 </div>
405 )}
406 </div>
407 );
408}