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