anvilsign in

collin/mahjong

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