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, 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 * The phone at the party table: just your own tiles, held in landscape, with
28 * the common screen doing the table's share of the talking. What it keeps of
29 * the full table is the things that are yours — the buttons a claim window
30 * owes you, the throw, and your voice. Flick a tile up off the top of the
31 * phone and it leaves your hand here, crosses the wire, and comes flying in
32 * from your edge of the shared screen.
33 *
34 * Your voice, because the five devices at a party table are all in the same
35 * room: with sound on, this phone is what says 碰 when you call one, and the
36 * common screen stops saying it for you. Mute the phone and the table takes
37 * the job back — see `net/session.ts` for how it is told.
38 */
39export function Controller({ session, prefs, onPrefs }: CtlProps) {
40 const seat = session.mySeat;
41
42 // The QR carries which seat it was stuck to; a phone that arrived some other
43 // way — or lost the parameter on the way in — picks one by hand below.
44 const [asked, setAsked] = useState(false);
45 useEffect(() => {
46 if (asked || seat !== null) return;
47 const want = seatFromUrl();
48 if (want !== null) {
49 setAsked(true);
50 session.claimSeat(want);
51 }
52 }, [session, seat, asked]);
53
54 if (seat === null) {
55 return <SeatPick session={session} joining={asked} prefs={prefs} onPrefs={onPrefs} />;
56 }
57 return <SeatStrip session={session} seat={seat} prefs={prefs} onPrefs={onPrefs} />;
58}
59
60function SeatPick({ session, joining, prefs, onPrefs }: CtlProps & { joining?: boolean }) {
61 const t = useT();
62 const lang = useLang();
63 const seats = session.seats;
64 const s = session.table.state;
65 return (
66 <div className="ctl ctl-pick">
67 <h1>{lang === 'en' ? 'Taiwanese Mahjong' : '台灣麻將'}</h1>
68 <p className="sub">
69 {joining
70 ? `${t('入座中…', 'taking your seat')} — ${t('或先選別的', 'or pick another')}`
71 : t('選一個座位', 'pick your seat', ' · ')}
72 </p>
73 <div className="pick-grid">
74 {SEATS.map((i) => {
75 const held = seats[i].ids.length;
76 const wind = (i - s.dealer + 4) % 4;
77 return (
78 <button key={i} className="btn big-btn" onClick={() => session.claimSeat(i)}>
79 {t(WIND_NAMES[wind], WIND_EN[wind])}
80 <em>
81 {held > 0
82 ? `${seats[i].name} — ${t('一起打', 'join this hand')}`
83 : seats[i].name || t('空位', 'open')}
84 </em>
85 </button>
86 );
87 })}
88 </div>
89 <div className="pick-extras">
90 {/* Turning it on here is also the tap that wakes the phone's audio, so
91 it can start doing the talking for this seat straight away. */}
92 <SoundToggle prefs={prefs} onPrefs={onPrefs} />
93 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} />
94 </div>
95 </div>
96 );
97}
98
99/** A tile in the fingers, drawn by us once it has left the row. */
100interface HeldTile {
101 tile: Tile;
102 x: number;
103 y: number;
104 size: number;
105 /** Set when it has been let go of: it flies up off the screen and is gone. */
106 flung: boolean;
107}
108
109function SeatStrip({ session, seat, prefs, onPrefs }: CtlProps & { seat: SeatId }) {
110 const t = useT();
111 const table = session.table;
112 const s = table.state;
113 const p = s.players[seat];
114 const [sel, setSel] = useState<number | null>(null);
115 const [held, setHeld] = useState<HeldTile | null>(null);
116
117 const myTurn = s.phase === 'turn' && s.turn === seat;
118 const opts = pendingOptions(s, seat);
119 const canProceed = table.nextDrawer() === seat;
120 const canHu = myTurn && table.canSelfDrawWin(seat);
121 const concealedKongs = myTurn ? table.concealedKongs(seat) : [];
122 const addedKongs = myTurn ? table.addedKongs(seat) : [];
123 const tenpai = s.phase !== 'handEnd' && s.phase !== 'gameEnd' ? table.tenpai(seat) : [];
124 const drawnIndex = myTurn && s.drawnSeparate && s.drawnTile !== null ? p.hand.length - 1 : null;
125 const selTile = sel === null ? null : p.hand[sel] ?? null;
126 const seatWind = (seat - s.dealer + 4) % 4;
127 const r = s.result;
128
129 useEffect(() => {
130 setSel(null);
131 }, [myTurn, s.handNumber, p.hand.length]);
132
133 const doDiscard = (i: number) => {
134 table.discard(seat, p.hand[i]);
135 setSel(null);
136 };
137
138 // The flick. `Hand` decides when a drag has become a throw; all that is left
139 // here is to say it in the seat's own frame — how far along the edge it left
140 // from, and how fast it was going up and across — and let it go.
141 const fling = (i: number, t: Throw) => {
142 const tile = p.hand[i];
143 if (tile === undefined) return setHeld(null);
144 table.discard(seat, tile, {
145 u: t.x / window.innerWidth,
146 vx: t.vx,
147 // Up off the top of the phone is into the table.
148 vy: -t.vy,
149 spin: t.spin,
150 });
151 setSel(null);
152 setHeld((h) => (h ? { ...h, x: t.x, y: t.y, flung: true } : null));
153 };
154
155 const carry = {
156 onPickUp: (i: number, x: number, y: number, size: number) =>
157 setHeld({ tile: p.hand[i], x, y, size, flung: false }),
158 onMoveTo: (x: number, y: number) => setHeld((h) => (h && !h.flung ? { ...h, x, y } : h)),
159 onThrow: fling,
160 onCancel: () => setHeld(null),
161 };
162
163 const status = myTurn
164 ? { zh: '輪到你', en: 'your turn', hot: true }
165 : opts
166 ? { zh: '要叫牌嗎?', en: 'claim it?', hot: true }
167 : {
168 zh: `等 ${s.players[s.turn].name}`,
169 en: `waiting for ${s.players[s.turn].name}`,
170 hot: false,
171 };
172
173 /**
174 * Four people at one table and one screen in the middle of it: the phone in
175 * a pocket, or face down beside a drink, is the one that has not noticed the
176 * game has come round. So when it does, this phone says so with everything
177 * it has — it goes gold edge to edge, and it buzzes once.
178 *
179 * The buzz is per turn, not per render: the string changes when the table
180 * moves, and only then. Phones that will not vibrate (a desk browser, one
181 * that has not been tapped yet) simply do not, and the gold is still there.
182 */
183 const calling = myTurn ? 'turn' : opts ? 'claim' : null;
184 const callKey = calling && `${calling}:${turnId(s)}`;
185 useEffect(() => {
186 if (!callKey) return;
187 navigator.vibrate?.(callKey.startsWith('turn') ? [90, 70, 90] : [40, 60, 40]);
188 }, [callKey]);
189
190 return (
191 <div className={`ctl ${calling ? `ctl-${calling}` : ''}`}>
192 {/* The same seven seconds the table is showing everyone else, on the
193 screen of the person they are all waiting for. */}
194 {myTurn && <TurnClock key={turnId(s)} />}
195 <div className="ctl-top">
196 <span className="wind">
197 <BiEm zh={WIND_NAMES[seatWind]} en={WIND_EN[seatWind]} />
198 </span>
199 <span className="pname">{p.name}</span>
200 {seat === s.dealer && <span className="tag">{t('莊', 'Dealer')}</span>}
201 {tenpai.length > 0 && (
202 <span className="tag ting">
203 {t('聽', 'Ready')} {tenpai.map(labelOf).join('·')}
204 </span>
205 )}
206 <span className={`ctl-status ${status.hot ? 'hot' : ''}`}>
207 <BiEm zh={status.zh} en={status.en} />
208 </span>
209 {s.lastDiscard && s.phase !== 'handEnd' && (
210 <span className="ctl-last">
211 {s.players[s.lastDiscard.from].name} {t('打', 'threw')}
212 <TileView tile={s.lastDiscard.tile} size="small" />
213 </span>
214 )}
215 <span className="chips">{p.score}</span>
216 <button className="btn ghost sort" onClick={() => table.sortHand(seat)}>
217 {t('理牌', 'Sort')}
218 </button>
219 {/* This phone's own mute — and, at a party table, whether your calls
220 come out of your hand or out of the screen in the middle. */}
221 <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
222 <FullscreenToggle prefs={prefs} onPrefs={onPrefs} compact />
223 </div>
224
225 <div className="ctl-actions">
226 {opts?.map((o, i) => (
227 <button
228 key={i}
229 className={`btn claim ${o.type}`}
230 onClick={() => table.respond(seat, { type: o.type, with: o.with })}
231 >
232 <Bi zh={CLAIM_LABEL[o.type][0]} en={CLAIM_LABEL[o.type][1]} sub={o.with && o.with.map(labelOf).join(' ')} />
233 </button>
234 ))}
235 {opts && !canProceed && (
236 <button className="btn pass" onClick={() => table.respond(seat, 'pass')}>
237 <Bi zh="過" en="Pass" />
238 </button>
239 )}
240 {canProceed && (
241 <button className={`btn proceed ${opts ? 'pass' : ''}`} onClick={() => table.resolveNow()}>
242 <Bi
243 zh={s.phase === 'robkong' ? '繼續' : opts ? '過.摸牌' : '摸牌'}
244 en={s.phase === 'robkong' ? 'Continue' : opts ? 'Pass & draw' : 'Draw'}
245 />
246 </button>
247 )}
248 {canHu && (
249 <button className="btn claim hu" onClick={() => table.declareSelfDraw(seat)}>
250 <Bi zh="自摸" en="Self-draw" />
251 </button>
252 )}
253 {concealedKongs.map((t) => (
254 <button key={`ak${t}`} className="btn claim kong" onClick={() => table.declareConcealedKong(seat, t)}>
255 <Bi zh="暗槓" en="Kong" sub={labelOf(t)} />
256 </button>
257 ))}
258 {addedKongs.map((t) => (
259 <button key={`gk${t}`} className="btn claim kong" onClick={() => table.declareAddedKong(seat, t)}>
260 <Bi zh="加槓" en="Kong" sub={labelOf(t)} />
261 </button>
262 ))}
263 {selTile !== null && sel !== null && myTurn && (
264 <button className="btn discard" onClick={() => doDiscard(sel)}>
265 <Bi zh="打出" en="Discard" sub={labelOf(selTile)} />
266 </button>
267 )}
268 {myTurn && (
269 <span className="ctl-hint">{t('往上甩出去', 'flick a tile up to throw it', ' · ')}</span>
270 )}
271 </div>
272
273 <div className="ctl-hand">
274 <Hand
275 tiles={p.hand}
276 drawnIndex={drawnIndex}
277 selected={sel}
278 canDiscard={myTurn}
279 onSelect={setSel}
280 onDiscard={doDiscard}
281 onMove={(from, to) => table.reorderHand(seat, from, to)}
282 carry={carry}
283 />
284 </div>
285
286 {/* The tile out of the row, following the finger — and, once flung, on
287 its way up off the screen to reappear on the common one. */}
288 {held && (
289 <div
290 className={`ctl-held ${held.flung ? 'flung' : ''}`}
291 style={{ left: held.x, top: held.y, width: held.size, height: held.size * 1.375 }}
292 onAnimationEnd={() => setHeld(null)}
293 >
294 <TileView tile={held.tile} />
295 </div>
296 )}
297
298 {r && s.phase === 'handEnd' && (
299 <div className="ctl-result">
300 {r.drawGame ? (
301 <h2>{t('流局', 'Draw', ' · ')}</h2>
302 ) : (
303 r.winners.map((w) => (
304 <h2 key={w.seat}>
305 {s.players[w.seat].name} {w.selfDraw ? t('自摸', 'self-draw') : t('胡牌', 'wins')}{' '}
306 · {w.tai} {t('台', 'tai')}
307 </h2>
308 ))
309 )}
310 <div className="deltas">
311 {r.deltas.map((d, i) => (
312 <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}>
313 {s.players[i].name} {d > 0 ? `+${d}` : d}
314 </span>
315 ))}
316 </div>
317 <button className="btn big-btn" onClick={() => table.nextHand()}>
318 <Bi zh="下一局" en="Next hand" />
319 </button>
320 </div>
321 )}
322
323 {s.phase === 'gameEnd' && (
324 <div className="ctl-result">
325 <h2>{t('一將終了', 'Game over', ' · ')}</h2>
326 <div className="deltas">
327 {s.players.map((q, i) => (
328 <span key={i}>
329 {q.name} {q.score}
330 </span>
331 ))}
332 </div>
333 <button className="btn big-btn" onClick={() => table.newGame(s.bots)}>
334 <Bi zh="再來一將" en="New game" />
335 </button>
336 </div>
337 )}
338 </div>
339 );
340}