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