| 1 | |
| 2 | |
| 3 | import { useLayoutEffect, useRef, useState, useEffect } from 'react'; |
| 4 | import type { CenterCard, Line, Word } from './useFx'; |
| 5 | import { Card, type Face } from './Card'; |
| 6 | import { scale, handOrigin } from './flyCards'; |
| 7 | |
| 8 | function center(sel: string): { x: number; y: number } | null { |
| 9 | const r = document.querySelector(sel)?.getBoundingClientRect(); |
| 10 | return r && r.width ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : null; |
| 11 | } |
| 12 | |
| 13 | function Played({ item }: { item: CenterCard }) { |
| 14 | const ref = useRef<HTMLDivElement>(null); |
| 15 | useLayoutEffect(() => { |
| 16 | const el = ref.current; |
| 17 | const from = (item.cardId && handOrigin.get(item.cardId)) || (item.from === null ? center('[data-zone-anchor="deck"]') : center(`[data-seat-index="${item.from}"]`)); |
| 18 | if (!el || !from || !el.animate) return; |
| 19 | const r = el.getBoundingClientRect(); |
| 20 | const s = scale(); |
| 21 | const dx = (from.x - (r.left + r.width / 2)) / s; |
| 22 | const dy = (from.y - (r.top + r.height / 2)) / s; |
| 23 | el.animate([{ transform: `translate(${dx}px, ${dy}px) scale(0.55)`, opacity: 0.4 }, { transform: 'none', opacity: 1 }], { duration: 420, easing: 'cubic-bezier(.2,.7,.3,1)' }); |
| 24 | }, []); |
| 25 | return ( |
| 26 | <div className="nm-played" ref={ref}> |
| 27 | <Card face={item.face} /> |
| 28 | <div className="nm-played-caption">{item.caption}</div> |
| 29 | </div> |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | export function Center({ items, reveal }: { items: CenterCard[]; reveal: { face: Face; caption: string } | null }) { |
| 34 | return ( |
| 35 | <div className="nm-center"> |
| 36 | <div className="nm-played-row"> |
| 37 | {items.map((it) => ( |
| 38 | <Played key={it.id} item={it} /> |
| 39 | ))} |
| 40 | {reveal && ( |
| 41 | <div className="nm-played shown"> |
| 42 | <Card face={reveal.face} /> |
| 43 | <div className="nm-played-caption">{reveal.caption}</div> |
| 44 | </div> |
| 45 | )} |
| 46 | </div> |
| 47 | <div className="nm-discard" data-zone-anchor="discard" /> |
| 48 | </div> |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | |
| 53 | export function Lines({ lines, words }: { lines: Line[]; words: Word[] }) { |
| 54 | const ref = useRef<SVGSVGElement>(null); |
| 55 | const [, redraw] = useState(0); |
| 56 | useEffect(() => redraw((x) => x + 1), [lines, words]); |
| 57 | const svg = ref.current?.getBoundingClientRect(); |
| 58 | const s = scale(); |
| 59 | const at = (i: number) => { |
| 60 | const c = center(`[data-seat-index="${i}"] .nm-avatar`) ?? center(`[data-seat-index="${i}"]`); |
| 61 | return c && svg ? { x: (c.x - svg.left) / s, y: (c.y - svg.top) / s } : null; |
| 62 | }; |
| 63 | return ( |
| 64 | <> |
| 65 | {words.map((w) => { |
| 66 | const c = at(w.seat); |
| 67 | return c ? ( |
| 68 | <div key={w.id} className={`nm-cardeffect ${w.text === '杀' || w.text.endsWith('杀') ? 'sha' : ''}`} style={{ left: c.x, top: c.y }}> |
| 69 | {w.text} |
| 70 | </div> |
| 71 | ) : null; |
| 72 | })} |
| 73 | <svg className="nm-lines" ref={ref}> |
| 74 | {lines.map((l) => { |
| 75 | const a = at(l.from); |
| 76 | const b = at(l.to); |
| 77 | if (!a || !b) return null; |
| 78 | const len = Math.hypot(b.x - a.x, b.y - a.y); |
| 79 | return ( |
| 80 | <g key={l.id} className={`nm-line ${l.kind}`} style={{ ['--len' as string]: `${len}` }}> |
| 81 | <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} strokeDasharray={len} /> |
| 82 | <circle cx={b.x} cy={b.y} r="6" /> |
| 83 | </g> |
| 84 | ); |
| 85 | })} |
| 86 | </svg> |
| 87 | </> |
| 88 | ); |
| 89 | } |