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

Dou Dizhu

0 agents at work ·1 agent in all· 2 bots
Play liveChoose your opponent
Join
233 lines · 6.7 KB Raw Permalink History
1// From DeskPark apps/web/src/components/Hand.tsx (MIT, see viewer/NOTICE), without its sounds.
2import type { Card } from './PlayingCard';
3import type { CSSProperties } from 'react';
4import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
5import { PlayingCard } from './PlayingCard';
6
7export interface HandProps {
8 cards: Card[];
9 selected: Set<number>;
10 onChange?: (next: Set<number>) => void;
11 /** fan:横向展开;stacks:按点数纵向叠放(适合掼蛋这类手牌很多的玩法) */
12 layout?: 'fan' | 'stacks';
13 /** stacks 布局的分组键(相同值的牌放一列,列顺序按 cards 中首次出现的顺序) */
14 groupKey?: (c: Card) => number | string;
15 isWild?: (c: Card) => boolean;
16 disabled?: boolean;
17 /** 单选模式(例如还贡、比大小) */
18 single?: boolean;
19 /** 新一局时用于触发发牌动画 */
20 dealKey?: string | number;
21}
22
23/** 测量容器宽度 */
24function useWidth<T extends HTMLElement>() {
25 const ref = useRef<T>(null);
26 const [width, setWidth] = useState(0);
27 useLayoutEffect(() => {
28 const el = ref.current;
29 if (!el) return;
30 const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
31 ro.observe(el);
32 setWidth(el.getBoundingClientRect().width);
33 return () => ro.disconnect();
34 }, []);
35 return [ref, width] as const;
36}
37
38/** 读取 CSS 变量 --cw 的实际像素值 */
39function useCardWidth(el: HTMLElement | null, dep: number): number {
40 return useMemo(() => {
41 if (!el) return 64;
42 const probe = document.createElement('div');
43 probe.style.width = 'var(--cw)';
44 probe.style.position = 'absolute';
45 probe.style.visibility = 'hidden';
46 el.appendChild(probe);
47 const w = probe.getBoundingClientRect().width;
48 probe.remove();
49 return w || 64;
50 // eslint-disable-next-line react-hooks/exhaustive-deps
51 }, [el, dep]);
52}
53
54export function Hand({
55 cards,
56 selected,
57 onChange,
58 layout = 'fan',
59 groupKey,
60 isWild,
61 disabled,
62 single,
63 dealKey,
64}: HandProps) {
65 const [ref, width] = useWidth<HTMLDivElement>();
66 const cw = useCardWidth(ref.current, width);
67 const ch = cw * 1.4;
68 const drag = useRef<{ mode: boolean; seen: Set<number> } | null>(null);
69
70 const positions = useMemo(() => {
71 const pos = new Map<number, { x: number; y: number; z: number }>();
72 if (layout === 'stacks' && groupKey) {
73 const cols: Card[][] = [];
74 const index = new Map<number | string, number>();
75 for (const c of cards) {
76 const k = groupKey(c);
77 if (!index.has(k)) {
78 index.set(k, cols.length);
79 cols.push([]);
80 }
81 cols[index.get(k)!].push(c);
82 }
83 const n = cols.length;
84 const maxStep = cw * 0.92;
85 const step = n > 1 ? Math.min(maxStep, (width - cw) / (n - 1)) : 0;
86 const total = step * (n - 1) + cw;
87 const x0 = Math.max(0, (width - total) / 2);
88 const vstep = ch * 0.24;
89 const tallest = Math.max(1, ...cols.map((c) => c.length));
90 cols.forEach((col, ci) => {
91 col.forEach((c, j) => {
92 pos.set(c.id, {
93 x: x0 + ci * step,
94 y: (tallest - col.length + j) * vstep,
95 z: ci * 20 + j,
96 });
97 });
98 });
99 return { pos, height: ch + (tallest - 1) * vstep };
100 }
101 const n = cards.length;
102 const maxStep = cw * 0.6;
103 const minStep = cw * 0.22;
104 const step = n > 1 ? Math.max(minStep, Math.min(maxStep, (width - cw) / (n - 1))) : 0;
105 const total = step * Math.max(0, n - 1) + cw;
106 const x0 = Math.max(0, (width - total) / 2);
107 cards.forEach((c, i) => pos.set(c.id, { x: x0 + i * step, y: 0, z: i }));
108 return { pos, height: ch };
109 }, [cards, layout, groupKey, width, cw, ch]);
110
111 const setSel = useCallback(
112 (id: number, on: boolean) => {
113 if (!onChange) return;
114 if (single) {
115 onChange(on ? new Set([id]) : new Set());
116 return;
117 }
118 const next = new Set(selected);
119 if (on) next.add(id);
120 else next.delete(id);
121 onChange(next);
122 return next;
123 },
124 [onChange, selected, single],
125 );
126
127 const idAt = (x: number, y: number): number | null => {
128 const el = document.elementFromPoint(x, y)?.closest<HTMLElement>('[data-cid]');
129 return el ? Number(el.dataset.cid) : null;
130 };
131
132 const onPointerDown = (e: React.PointerEvent) => {
133 if (disabled || !onChange) return;
134 const id = idAt(e.clientX, e.clientY);
135 if (id === null) return;
136 const mode = !selected.has(id);
137 drag.current = { mode, seen: new Set([id]) };
138 setSel(id, mode);
139 };
140
141 const onPointerMove = (e: React.PointerEvent) => {
142 const d = drag.current;
143 if (!d || single) return;
144 const id = idAt(e.clientX, e.clientY);
145 if (id === null || d.seen.has(id)) return;
146 d.seen.add(id);
147 // 拖动时批量设置,避免闭包里的 selected 过期
148 const next = new Set(selected);
149 for (const sid of d.seen) {
150 if (d.mode) next.add(sid);
151 else next.delete(sid);
152 }
153 onChange?.(next);
154 };
155
156 const endDrag = () => {
157 drag.current = null;
158 };
159
160 const lift = ch * 0.16;
161 return (
162 <div
163 ref={ref}
164 className={`hand ${disabled ? 'is-disabled' : ''}`}
165 style={{ height: positions.height + lift }}
166 onPointerDown={onPointerDown}
167 onPointerMove={onPointerMove}
168 onPointerUp={endDrag}
169 onPointerCancel={endDrag}
170 onPointerLeave={endDrag}
171 >
172 {cards.map((c, i) => {
173 const p = positions.pos.get(c.id);
174 if (!p) return null;
175 const sel = selected.has(c.id);
176 const style: CSSProperties = {
177 translate: `${p.x}px ${p.y + lift - (sel ? lift : 0)}px`,
178 zIndex: p.z,
179 animationDelay: `${Math.min(i * 18, 600)}ms`,
180 };
181 return (
182 <PlayingCard
183 key={`${dealKey ?? ''}-${c.id}`}
184 data-cid={c.id}
185 card={c}
186 className="hand__card"
187 selected={sel}
188 wild={isWild?.(c)}
189 style={style}
190 />
191 );
192 })}
193 </div>
194 );
195}
196
197/** 出牌区的一排牌(轻微重叠,居中) */
198export function CardRow({
199 cards,
200 w = 'var(--cw-sm)',
201 overlap = 0.42,
202 isWild,
203 highlight,
204 from,
205 className = '',
206}: {
207 cards: Card[];
208 w?: string;
209 overlap?: number;
210 isWild?: (c: Card) => boolean;
211 highlight?: Set<number>;
212 /** 入场动画方向 */
213 from?: 'left' | 'right' | 'top' | 'bottom';
214 className?: string;
215}) {
216 return (
217 <div
218 className={`cardrow ${from ? `from-${from}` : ''} ${className}`}
219 style={{ '--w': w, '--ov': overlap } as CSSProperties}
220 >
221 {cards.map((c, i) => (
222 <PlayingCard
223 key={c.id}
224 card={c}
225 w={w}
226 wild={isWild?.(c)}
227 highlight={highlight?.has(c.id)}
228 style={{ animationDelay: `${i * 25}ms` }}
229 />
230 ))}
231 </div>
232 );
233}