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