anvilsign in

collin/mahjong

1import type { Game } from '../game/engine';
2import type { Prefs } from '../game/prefs';
3import type { SeatId } from '../game/types';
4import { labelOf } from '../game/tiles';
5import { Bi } from './Bi';
6import { SoundToggle } from './SoundToggle';
7import { TileView } from './TileView';
8import { SEAT_ROT } from './rotation';
9import { WallRing } from './WallRing';
10
11interface Props {
12 game: Game;
13 prefs: Prefs;
14 onPrefs: (p: Prefs) => void;
15 onSettings: () => void;
16}
17
18export function Center({ game, prefs, onPrefs, onSettings }: Props) {
19 const s = game.state;
20 const r = s.result;
21 // Point the live readout at whoever can move the game along.
22 const pending = (Object.keys(s.options) as unknown as number[])
23 .map(Number)
24 .filter((k) => s.responses[k as SeatId] === undefined) as SeatId[];
25 // Against the computer there is only one person reading this, and they are
26 // sitting at the bottom of the screen.
27 const human = s.bots.indexOf(false);
28 const facing = human >= 0 && game.hasBots ? (human as SeatId) : game.nextDrawer() ?? s.turn;
29 // Deliberately not per-seat: whoever spots the mis-tap should be able to
30 // reach it, not just the player who made it.
31 const undoable = s.phase === 'gameEnd' ? null : game.undoable();
32
33 return (
34 <div className="center">
35 {s.phase !== 'gameEnd' && <WallRing state={s} />}
36
37 <div className="facing" style={{ transform: `rotate(${SEAT_ROT[facing]}deg)` }}>
38 {s.lastDiscard && s.phase !== 'handEnd' && (
39 <div className="last-discard">
40 <div className="ld-label">{s.players[s.lastDiscard.from].name} 打 discarded</div>
41 <TileView tile={s.lastDiscard.tile} size="hand" highlight />
42 </div>
43 )}
44
45 {s.phase === 'claims' && (
46 <div className="prompt">
47 {pending.length} 家可吃碰 · {pending.length} may claim
48 </div>
49 )}
50 {s.phase === 'robkong' && <div className="prompt warn">搶槓機會 · Rob the kong?</div>}
51 </div>
52
53 {r && s.phase !== 'gameEnd' && (
54 <div className="result">
55 {r.drawGame ? (
56 <>
57 <h2>流局 · Draw</h2>
58 {/* Nobody won, so the thing everyone wants to see is who was
59 there and on what — the same reveal as turning the hands over
60 at a real table. */}
61 <div className="ready-list">
62 {s.players.map((p, seat) => {
63 const waiting = game.tenpai(seat as SeatId);
64 return (
65 <div className={`ready-row ${waiting.length ? '' : 'none'}`} key={seat}>
66 <span className="ready-name">{p.name}</span>
67 {waiting.length > 0 ? (
68 <>
69 <span className="tag ting">聽 Ready</span>
70 <span className="ready-tiles">
71 {waiting.map((t) => (
72 <TileView key={t} tile={t} size="small" />
73 ))}
74 </span>
75 </>
76 ) : (
77 <span className="ready-no">未聽 · not ready</span>
78 )}
79 </div>
80 );
81 })}
82 </div>
83 </>
84 ) : (
85 <>
86 {/* One block per winner — more than one only under 一炮多響. */}
87 {r.winners.map((w) => (
88 <div className="win" key={w.seat}>
89 <h2>
90 {s.players[w.seat].name} {w.selfDraw ? '自摸' : '胡牌'} · {w.tai} 台
91 <span className="wt"> {labelOf(w.winTile)}</span>
92 </h2>
93 {w.liable !== null && (
94 <div className="liable">
95 包牌 · {s.players[w.liable].name} pays the whole hand
96 </div>
97 )}
98 <div className="tai-list">
99 {w.items.map((it, i) => (
100 <div key={i} className="tai-item">
101 <span className="tai-name">
102 {it.name}
103 <em>{it.en}</em>
104 </span>
105 <span className="tai-val">{it.tai}</span>
106 </div>
107 ))}
108 </div>
109 </div>
110 ))}
111 {r.winners.length > 1 && (
112 <div className="multi">一炮多響 · one discard, {r.winners.length} winners</div>
113 )}
114 <div className="deltas">
115 {r.deltas.map((d, i) => (
116 <span key={i} className={d > 0 ? 'up' : d < 0 ? 'down' : ''}>
117 {s.players[i].name} {d > 0 ? `+${d}` : d}
118 </span>
119 ))}
120 </div>
121 </>
122 )}
123 <button className="btn big-btn" onClick={() => game.nextHand()}>
124 <Bi zh="下一局" en="Next hand" />
125 </button>
126 </div>
127 )}
128
129 {s.phase === 'gameEnd' && (
130 <div className="result">
131 <h2>一將終了 · Game over</h2>
132 <div className="deltas">
133 {[...s.players]
134 .map((p, i) => ({ p, i }))
135 .sort((a, b) => b.p.score - a.p.score)
136 .map(({ p, i }) => (
137 <span key={i} className={p.score > s.rules.startingScore ? 'up' : p.score < s.rules.startingScore ? 'down' : ''}>
138 {p.name} {p.score}
139 </span>
140 ))}
141 </div>
142 <div className="over-buttons">
143 <button className="btn big-btn" onClick={() => game.newGame(s.bots)}>
144 <Bi zh="再來一將" en="New game" />
145 </button>
146 {/* The moment anyone is going to want the names or the stakes changed. */}
147 <button className="btn ghost big-btn" onClick={onSettings}>
148 <Bi zh="設定" en="Settings" />
149 </button>
150 </div>
151 </div>
152 )}
153
154 <div className="table-controls">
155 {undoable && (
156 <button className="btn ghost undo" onClick={() => game.undo()}>
157 <span className="undo-arrow" aria-hidden>
158 ↺
159 </span>
160 <span className="undo-what">
161 <span className="zh">復原 {undoable.zh}</span>
162 <span className="en">Undo · {undoable.en}</span>
163 </span>
164 </button>
165 )}
166 <SoundToggle prefs={prefs} onPrefs={onPrefs} compact />
167 </div>
168 </div>
169 );
170}