| 1 | import { useEffect, useState } from 'react'; |
| 2 | import { pendingOptions } from '../game/ctl'; |
| 3 | import { labelOf, WIND_EN, WIND_NAMES, type Tile } from '../game/tiles'; |
| 4 | import type { SeatId } from '../game/types'; |
| 5 | import type { NetSession } from '../net/session'; |
| 6 | import { seatFromUrl } from '../net/protocol'; |
| 7 | import { Bi, CLAIM_LABEL } from './Bi'; |
| 8 | import { Hand, type Throw } from './Hand'; |
| 9 | import { TileView } from './TileView'; |
| 10 | |
| 11 | const 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 | */ |
| 21 | export 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 | |
| 40 | function 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. */ |
| 70 | interface 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 | |
| 79 | function 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 | } |