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