// The Dou Dizhu table, after DeskPark's DoudizhuBoard (apps/web/src/games/doudizhu, MIT, see viewer/NOTICE): the // same layout, seats, hand, trick zones, card counter and result sheet, drawn from a View (model.ts) instead of // DeskPark's engine, and acting through the room protocol: the buttons offer only what the room's legal list allows. import { useMemo, useState } from 'react'; import { CardRow, Hand } from './deskpark/Hand'; import { PlayingCard } from './deskpark/PlayingCard'; import type { Card } from './deskpark/PlayingCard'; import { Avatar, Seat } from './deskpark/Seat'; import { CountChip, MoveZone, useHintCycle, useSelection } from './deskpark/trick'; import type { Move, Req, View } from './model'; import { ORDER, cardsFor, isBomb, moveOf, rankChar, sortHand } from './model'; const LABEL: Record = { B: '小', R: '大', T: '10' }; function CardCounter({ v, me }: { v: View; me: number | null }) { const seen = [...v.played, ...(me !== null ? (v.hands[me] ?? []) : [])].map(rankChar); const order = [...ORDER].reverse(); return (
{order.map((r) => { const joker = r === 'B' || r === 'R'; const n = (joker ? 1 : 4) - seen.filter((x) => x === r).length; return ( = 4 || (joker && n === 1) ? 'is-full' : ''}> {LABEL[r] ?? r} {n} ); })}
); } const zone = (m: Move) => (m === null ? null : m === 'pass' ? { type: 'pass' as const } : { type: 'play' as const, cards: m }); /** hint order, as DeskPark's: bombs last, then by the rank that repeats most, longer first */ function hintOrder(a: string, b: string) { const main = (m: string) => Math.max(...[...m].map((r) => [...m].filter((x) => x === r).length * 100 + ORDER.indexOf(r))); const key = (m: string) => (isBomb(m) ? 1e4 : 0) + (main(m) % 100); return key(a) - key(b) || b.length - a.length; } export interface BoardProps { v: View; /** the seat at the bottom of the table */ me: number; /** a replay shows every hand */ peek: boolean; /** seats this viewer plays */ mine: number[]; req: Req | null; onAct?: (action: string) => void; } export function Board({ v, me, peek, mine, req, onAct }: BoardProps) { const right = (me + 1) % 3; const left = (me + 2) % 3; const over = !!v.result; const myTurn = !!req && req.seat === me && !over; const hand = useMemo(() => sortHand(v.hands[me] ?? []), [v.hands, me]); const [sel, setSel] = useSelection(hand, req?.version); const [sent, setSent] = useState(0); const waiting = myTurn && sent === req!.version; const canAct = myTurn && !waiting; const legal = useMemo(() => new Set(req?.legal ?? []), [req]); const candidates = useMemo(() => { if (!req) return [] as number[][]; return req.legal .filter((m) => m !== 'pass') .sort(hintOrder) .map((m) => cardsFor(hand, m).map((c) => c.id)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [req?.version, me]); const nextHint = useHintCycle(candidates, req?.version); const selected = hand.filter((c) => sel.has(c.id)); const move = moveOf(selected); const playable = canAct && selected.length > 0 && legal.has(move); const send = (a: string) => { if (!req) return; onAct?.(a); setSent(req.version); setSel(new Set()); }; const role = (s: number) => (v.landlord === null ? undefined : s === v.landlord ? '地主' : '农民'); const tone = (s: number) => (s === v.landlord ? 'gold' : 'plain') as 'gold' | 'plain'; // RLCard 1.2.0 fixes the landlord seat; do not invent a bidding phase. const bubble = (s: number) => (v.step === 0 && s === v.landlord && !over ? '固定地主' : undefined); const player = (s: number) => ({ name: v.names[s], isBot: v.bots[s], isMe: mine.includes(s) }); const shown = (s: number): Card[] | null => (peek || over ? v.hands[s] : null); const opponent = (s: number, side: 'left' | 'right') => (
} bubble={bubble(s)} bubbleSide={side === 'left' ? 'right' : 'left'} /> {shown(s) && (
{sortHand(shown(s)!).map((c) => ( ))}
)}
); return (
{v.bottom.length ? sortHand(v.bottom).map((c) => ) : [0, 1, 2].map((i) => )}
底分 1 倍数 ×{2 ** v.bombs * (v.result?.spring ? 2 : 1)} {v.bombs > 0 && ( 炸弹 {v.bombs} )}
{v.landlord !== null && } {opponent(left, 'left')} {opponent(right, 'right')}
} bubble={bubble(me)} bubbleSide="right" layout="row" size={54} />
{canAct && (
{legal.size === 1 && legal.has('pass') ? ( ) : ( <> {legal.has('pass') && ( )} )}
)} {waiting &&
已出牌,等待牌桌
} {!myTurn && !over && v.turn !== null && mine.length > 0 && (
等待 {v.names[v.turn]} 出牌
)} {mine.length === 0 && !peek && !over &&
观战中
}
{hand.length > 0 && ( )}
{v.fx && (
{v.fx}
)} {over && }
); } function Result({ v, me, mine }: { v: View; me: number; mine: number[] }) { const r = v.result!; const [open, setOpen] = useState(true); if (!open) return null; const playing = mine.includes(me); const iWon = r.landlordWon !== null && r.landlordWon === (me === v.landlord); const title = r.landlordWon === null ? '牌局中止' : playing ? (iWon ? '胜利' : '失败') : r.landlordWon ? '地主胜' : '农民胜'; const details = [ v.landlord !== null ? `地主 ${v.names[v.landlord]}` : '', v.bombs ? `炸弹 ${v.bombs}` : '', r.spring ? (r.landlordWon ? '春天' : '反春') : '', `×${2 ** v.bombs * (r.spring ? 2 : 1)}`, ].filter(Boolean); return (
setOpen(false)}>
e.stopPropagation()}>
{title}
{details.join(' · ')}
{[0, 1, 2].map((s) => ( ))}
玩家 本局
{v.names[s]} {s === v.landlord ? '地主' : '农民'} = 0 ? 'delta-pos' : 'delta-neg'}`}> {r.points[s] > 0 ? `+${r.points[s]}` : r.points[s]}
{[0, 1, 2] .filter((s) => (v.hands[s] ?? []).length > 0) .map((s) => (
{v.names[s]}
))}
); }