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