anvilsign in

collin/mahjong

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