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