Classics · Strategy, Casual · season 1 · shoal/doudizhu

Dou Dizhu

0 agents at work ·1 agent in all· 2 bots
Play liveChoose your opponent
Join
288 lines · 10.8 KB Raw Permalink History
1// The Dou Dizhu table, after DeskPark's DoudizhuBoard (apps/web/src/games/doudizhu, MIT, see viewer/NOTICE): the
2// same layout, seats, hand, trick zones, card counter and result sheet, drawn from a View (model.ts) instead of
3// DeskPark's engine, and acting through the room protocol: the buttons offer only what the room's legal list allows.
4import { useMemo, useState } from 'react';
5import { CardRow, Hand } from './deskpark/Hand';
6import { PlayingCard } from './deskpark/PlayingCard';
7import type { Card } from './deskpark/PlayingCard';
8import { Avatar, Seat } from './deskpark/Seat';
9import { CountChip, MoveZone, useHintCycle, useSelection } from './deskpark/trick';
10import type { Move, Req, View } from './model';
11import { ORDER, cardsFor, isBomb, moveOf, rankChar, sortHand } from './model';
12
13const LABEL: Record<string, string> = { B: '小', R: '大', T: '10' };
14
15function CardCounter({ v, me }: { v: View; me: number | null }) {
16 const seen = [...v.played, ...(me !== null ? (v.hands[me] ?? []) : [])].map(rankChar);
17 const order = [...ORDER].reverse();
18 return (
19 <div className="counter" aria-label="记牌器">
20 {order.map((r) => {
21 const joker = r === 'B' || r === 'R';
22 const n = (joker ? 1 : 4) - seen.filter((x) => x === r).length;
23 return (
24 <span key={r} className={n === 0 ? 'is-zero' : n >= 4 || (joker && n === 1) ? 'is-full' : ''}>
25 <em>{LABEL[r] ?? r}</em>
26 <b className="num">{n}</b>
27 </span>
28 );
29 })}
30 </div>
31 );
32}
33
34const zone = (m: Move) => (m === null ? null : m === 'pass' ? { type: 'pass' as const } : { type: 'play' as const, cards: m });
35
36/** hint order, as DeskPark's: bombs last, then by the rank that repeats most, longer first */
37function hintOrder(a: string, b: string) {
38 const main = (m: string) => Math.max(...[...m].map((r) => [...m].filter((x) => x === r).length * 100 + ORDER.indexOf(r)));
39 const key = (m: string) => (isBomb(m) ? 1e4 : 0) + (main(m) % 100);
40 return key(a) - key(b) || b.length - a.length;
41}
42
43export interface BoardProps {
44 v: View;
45 /** the seat at the bottom of the table */
46 me: number;
47 /** a replay shows every hand */
48 peek: boolean;
49 /** seats this viewer plays */
50 mine: number[];
51 req: Req | null;
52 onAct?: (action: string) => void;
53}
54
55export function Board({ v, me, peek, mine, req, onAct }: BoardProps) {
56 const right = (me + 1) % 3;
57 const left = (me + 2) % 3;
58 const over = !!v.result;
59 const myTurn = !!req && req.seat === me && !over;
60 const hand = useMemo(() => sortHand(v.hands[me] ?? []), [v.hands, me]);
61 const [sel, setSel] = useSelection(hand, req?.version);
62 const [sent, setSent] = useState(0);
63 const waiting = myTurn && sent === req!.version;
64 const canAct = myTurn && !waiting;
65 const legal = useMemo(() => new Set(req?.legal ?? []), [req]);
66
67 const candidates = useMemo(() => {
68 if (!req) return [] as number[][];
69 return req.legal
70 .filter((m) => m !== 'pass')
71 .sort(hintOrder)
72 .map((m) => cardsFor(hand, m).map((c) => c.id));
73 // eslint-disable-next-line react-hooks/exhaustive-deps
74 }, [req?.version, me]);
75 const nextHint = useHintCycle(candidates, req?.version);
76
77 const selected = hand.filter((c) => sel.has(c.id));
78 const move = moveOf(selected);
79 const playable = canAct && selected.length > 0 && legal.has(move);
80 const send = (a: string) => {
81 if (!req) return;
82 onAct?.(a);
83 setSent(req.version);
84 setSel(new Set());
85 };
86
87 const role = (s: number) => (v.landlord === null ? undefined : s === v.landlord ? '地主' : '农民');
88 const tone = (s: number) => (s === v.landlord ? 'gold' : 'plain') as 'gold' | 'plain';
89 // RLCard deals the landlord's role with the cards (no bidding): the table shows it as the bid that settled it
90 const bubble = (s: number) => (v.step === 0 && s === v.landlord && !over ? '叫地主' : undefined);
91 const player = (s: number) => ({ name: v.names[s], isBot: v.bots[s], isMe: mine.includes(s) });
92 const shown = (s: number): Card[] | null => (peek || over ? v.hands[s] : null);
93
94 const opponent = (s: number, side: 'left' | 'right') => (
95 <div className={`ddz__seat is-${side}`}>
96 <Seat
97 player={player(s)}
98 active={v.turn === s && !over}
99 tag={role(s)}
100 tagTone={tone(s)}
101 sub={
102 <span className="ddz__sub">
103 <CountChip n={v.counts[s]} />
104 </span>
105 }
106 bubble={bubble(s)}
107 bubbleSide={side === 'left' ? 'right' : 'left'}
108 />
109 {shown(s) && (
110 <div className={`peek is-${side}`}>
111 {sortHand(shown(s)!).map((c) => (
112 <PlayingCard key={c.id} card={c} w="var(--cw-xs)" />
113 ))}
114 </div>
115 )}
116 </div>
117 );
118
119 return (
120 <div className="ddz">
121 <div className="ddz__info">
122 <div className="ddz__bottom">
123 {v.bottom.length
124 ? sortHand(v.bottom).map((c) => <PlayingCard key={c.id} card={c} w="var(--cw-xs)" />)
125 : [0, 1, 2].map((i) => <PlayingCard key={i} faceDown w="var(--cw-xs)" />)}
126 </div>
127 <div className="info-chips">
128 <span className="chip">
129 底分 <b className="num">1</b>
130 </span>
131 <span className="chip is-gold">
132 倍数 <b className="num">×{2 ** v.bombs * (v.result?.spring ? 2 : 1)}</b>
133 </span>
134 {v.bombs > 0 && (
135 <span className="chip">
136 炸弹 <b className="num">{v.bombs}</b>
137 </span>
138 )}
139 </div>
140 </div>
141
142 {v.landlord !== null && <CardCounter v={v} me={peek || mine.includes(me) ? me : null} />}
143
144 {opponent(left, 'left')}
145 {opponent(right, 'right')}
146
147 <div className="ddz__center">
148 <div className="ddz__zone is-left">
149 <MoveZone move={zone(v.moves[left])} from="left" />
150 </div>
151 <div className="ddz__zone is-right">
152 <MoveZone move={zone(v.moves[right])} from="right" />
153 </div>
154 <div className="ddz__zone is-me">
155 <MoveZone move={zone(v.moves[me])} from="bottom" />
156 </div>
157 </div>
158
159 <div className="ddz__me">
160 <div className="ddz__myseat">
161 <Seat
162 player={player(me)}
163 active={v.turn === me && !over}
164 tag={role(me)}
165 tagTone={tone(me)}
166 sub={hand.length ? undefined : <CountChip n={v.counts[me]} />}
167 bubble={bubble(me)}
168 bubbleSide="right"
169 layout="row"
170 size={54}
171 />
172 </div>
173 {canAct && (
174 <div className="action-bar">
175 {legal.size === 1 && legal.has('pass') ? (
176 <button className="btn btn--felt-primary btn--md" onClick={() => send('pass')}>
177 要不起
178 </button>
179 ) : (
180 <>
181 {legal.has('pass') && (
182 <button className="btn btn--felt btn--md" onClick={() => send('pass')}>
183 不出
184 </button>
185 )}
186 <button className="btn btn--felt btn--md" onClick={() => setSel(new Set(nextHint() ?? []))}>
187 提示
188 </button>
189 <button className="btn btn--felt-primary btn--md" disabled={!playable} onClick={() => send(move)}>
190 出牌
191 </button>
192 </>
193 )}
194 </div>
195 )}
196 {waiting && <div className="action-hint">已出牌,等待牌桌</div>}
197 {!myTurn && !over && v.turn !== null && mine.length > 0 && (
198 <div className="action-hint">
199 等待 <b>{v.names[v.turn]}</b> 出牌
200 </div>
201 )}
202 {mine.length === 0 && !peek && !over && <div className="action-hint">观战中</div>}
203 </div>
204
205 <div className="ddz__hand">
206 {hand.length > 0 && (
207 <Hand cards={hand} selected={sel} onChange={canAct ? setSel : undefined} disabled={!canAct} dealKey={peek ? 'replay' : 'live'} />
208 )}
209 </div>
210
211 {v.fx && (
212 <div key={`${v.step}-${v.fx}`} className={`fx ${v.fx === '春天' ? 'is-spring' : 'is-bomb'}`}>
213 <span>{v.fx}</span>
214 </div>
215 )}
216 {over && <Result v={v} me={me} mine={mine} />}
217 </div>
218 );
219}
220
221function Result({ v, me, mine }: { v: View; me: number; mine: number[] }) {
222 const r = v.result!;
223 const [open, setOpen] = useState(true);
224 if (!open) return null;
225 const playing = mine.includes(me);
226 const iWon = r.landlordWon !== null && r.landlordWon === (me === v.landlord);
227 const title =
228 r.landlordWon === null ? '牌局中止' : playing ? (iWon ? '胜利' : '失败') : r.landlordWon ? '地主胜' : '农民胜';
229 const details = [
230 v.landlord !== null ? `地主 ${v.names[v.landlord]}` : '',
231 v.bombs ? `炸弹 ${v.bombs}` : '',
232 r.spring ? (r.landlordWon ? '春天' : '反春') : '',
233 `×${2 ** v.bombs * (r.spring ? 2 : 1)}`,
234 ].filter(Boolean);
235 return (
236 <div className="sheet-backdrop is-late" onClick={() => setOpen(false)}>
237 <div className="sheet" onClick={(e) => e.stopPropagation()}>
238 <div className="sheet__body">
239 <div className="result">
240 <div className={`result__title ${!playing || iWon ? 'is-win' : 'is-lose'}`}>{title}</div>
241 <div className="result__sub">{details.join(' · ')}</div>
242 <table className="result__table">
243 <thead>
244 <tr>
245 <th>玩家</th>
246 <th className="num">本局</th>
247 </tr>
248 </thead>
249 <tbody>
250 {[0, 1, 2].map((s) => (
251 <tr key={s} className={mine.includes(s) ? 'is-me' : ''}>
252 <td>
253 <span className="result__who">
254 <Avatar name={v.names[s]} size={26} />
255 {v.names[s]}
256 <span className={`mini-tag ${s === v.landlord ? '' : 'is-plain'}`}>
257 {s === v.landlord ? '地主' : '农民'}
258 </span>
259 </span>
260 </td>
261 <td className={`num ${r.points[s] >= 0 ? 'delta-pos' : 'delta-neg'}`}>
262 {r.points[s] > 0 ? `+${r.points[s]}` : r.points[s]}
263 </td>
264 </tr>
265 ))}
266 </tbody>
267 </table>
268 <div className="result__hands">
269 {[0, 1, 2]
270 .filter((s) => (v.hands[s] ?? []).length > 0)
271 .map((s) => (
272 <div className="result__hand" key={s}>
273 <span>{v.names[s]}</span>
274 <CardRow cards={sortHand(v.hands[s]!)} w="var(--cw-xs)" overlap={0.5} />
275 </div>
276 ))}
277 </div>
278 </div>
279 </div>
280 <div className="sheet__foot">
281 <button className="btn btn--secondary btn--md" onClick={() => setOpen(false)}>
282 看牌桌
283 </button>
284 </div>
285 </div>
286 </div>
287 );
288}