| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | import { useGameView } from '../../../src/client/components/GameViewCtx'; |
| 6 | import { useCountdownFraction } from '../../../src/client/hooks/useCountdown'; |
| 7 | import { getCharacterMeta } from '../../../src/engine/data/character-meta'; |
| 8 | import type { GameView, EquipSlot } from '../../../src/engine/types'; |
| 9 | import type { HpChangeNumber } from '../../../src/client/hooks/useAnimationState'; |
| 10 | import type { CSSProperties, ReactNode } from 'react'; |
| 11 | import { portrait, cardArt, FACTION_KEY } from './art'; |
| 12 | import { isRed } from './Card'; |
| 13 | |
| 14 | type Player = GameView['players'][number]; |
| 15 | export const ID_SHORT: Record<string, string> = { 主公: '主', 忠臣: '忠', 反贼: '反', 内奸: '内' }; |
| 16 | export const ID_KEY: Record<string, string> = { 主公: 'zhu', 忠臣: 'zhong', 反贼: 'fan', 内奸: 'nei' }; |
| 17 | export const SLOTS: Array<[EquipSlot, string]> = [ |
| 18 | ['武器', '武'], |
| 19 | ['防具', '防'], |
| 20 | ['防御马', '+1'], |
| 21 | ['进攻马', '-1'], |
| 22 | ['宝物', '宝'], |
| 23 | ]; |
| 24 | const PHASE_SHORT: Record<string, string> = { 准备: '准备', 判定: '判定', 摸牌: '摸牌', 出牌: '出牌', 弃牌: '弃牌', 回合结束: '结束' }; |
| 25 | |
| 26 | export function Timer({ deadline, totalMs }: { deadline: number | null; totalMs?: number }) { |
| 27 | const fill = useCountdownFraction(deadline, totalMs || 15000); |
| 28 | if (deadline == null) return null; |
| 29 | return ( |
| 30 | <div className="nm-timer"> |
| 31 | <div className="nm-timer-fill" ref={fill} /> |
| 32 | </div> |
| 33 | ); |
| 34 | } |
| 35 | |
| 36 | |
| 37 | export function Hp({ hp, max }: { hp: number; max: number }) { |
| 38 | const level = hp > max / 2 ? 'high' : hp > max / 4 && hp > 1 ? 'mid' : 'low'; |
| 39 | if (max > 5) |
| 40 | return ( |
| 41 | <div className={`nm-hp text ${level}`}> |
| 42 | <i className="nm-jade" /> |
| 43 | <span> |
| 44 | {Math.max(hp, 0)}/{max} |
| 45 | </span> |
| 46 | </div> |
| 47 | ); |
| 48 | return ( |
| 49 | <div className={`nm-hp ${level}`}> |
| 50 | {Array.from({ length: max }, (_, i) => ( |
| 51 | <i key={i} className={`nm-jade${i >= Math.max(hp, 0) ? ' lost' : ''}`} /> |
| 52 | ))} |
| 53 | </div> |
| 54 | ); |
| 55 | } |
| 56 | |
| 57 | export function faction(p: Player): string { |
| 58 | return p.faction ?? (p.character ? getCharacterMeta(p.character)?.faction : undefined) ?? '群'; |
| 59 | } |
| 60 | |
| 61 | export const isChained = (p: Player) => p.marks.some((m) => m.id === 'chained'); |
| 62 | export const isFlipped = (p: Player) => (p.tags ?? []).some((t) => t.endsWith('/翻面')); |
| 63 | |
| 64 | |
| 65 | export function seatState(p: Player, extra: Record<string, boolean | undefined>): string { |
| 66 | const f = FACTION_KEY[faction(p)] ?? 'qun'; |
| 67 | const on = { dead: !p.alive, dying: p.alive && p.health <= 0, chained: isChained(p), flipped: isFlipped(p), ...extra }; |
| 68 | return `${f} ${Object.entries(on) |
| 69 | .filter(([, v]) => v) |
| 70 | .map(([k]) => k) |
| 71 | .join(' ')}`; |
| 72 | } |
| 73 | |
| 74 | export function Identity({ p, mine }: { p: Player; mine?: boolean }) { |
| 75 | const id = p.identity; |
| 76 | if (!id && !p.identityHidden && !mine) return null; |
| 77 | return <div className={`nm-identity ${id ? ID_KEY[id] : 'unknown'}`} title={id ?? '身份未知'}>{id ? ID_SHORT[id] ?? id[0] : '?'}</div>; |
| 78 | } |
| 79 | |
| 80 | export function Judges({ p, view }: { p: Player; view: GameView }) { |
| 81 | const ids = p.pendingTricks ?? []; |
| 82 | if (!ids.length) return null; |
| 83 | return ( |
| 84 | <div className="nm-judges"> |
| 85 | {ids.map((id) => { |
| 86 | const c = view.cardMap[id]; |
| 87 | return ( |
| 88 | <span key={id} className="nm-judge" title={c ? `${c.name} ${c.suit}${c.rank}` : id}> |
| 89 | {c && cardArt(c.name) && <i style={{ backgroundImage: `url(${cardArt(c.name)})` }} />} |
| 90 | <b>{(c?.name ?? '?')[0]}</b> |
| 91 | </span> |
| 92 | ); |
| 93 | })} |
| 94 | </div> |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | |
| 99 | export const equipName = (slot: EquipSlot, name: string) => (slot === '防御马' ? `${name}+` : slot === '进攻马' ? `${name}-` : name); |
| 100 | |
| 101 | export function Equips({ p, view }: { p: Player; view: GameView }) { |
| 102 | const rows = SLOTS.filter(([slot]) => p.equipment[slot]); |
| 103 | if (!rows.length) return null; |
| 104 | return ( |
| 105 | <div className="nm-equips"> |
| 106 | {rows.map(([slot, label]) => { |
| 107 | const c = view.cardMap[p.equipment[slot]!]; |
| 108 | return ( |
| 109 | <div key={slot} className="nm-equip" title={c ? `${c.name}(${slot})${c.description ? `\n${c.description}` : ''}` : slot}> |
| 110 | <span className="nm-equip-slot">{label}</span> |
| 111 | <span className="nm-equip-name">{c ? equipName(slot, c.name) : slot}</span> |
| 112 | {c && ( |
| 113 | <span className={`nm-equip-info${isRed(c.suit) ? ' red' : ''}`}> |
| 114 | {c.suit} |
| 115 | {c.rank} |
| 116 | </span> |
| 117 | )} |
| 118 | </div> |
| 119 | ); |
| 120 | })} |
| 121 | </div> |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | export function HpFloat({ change }: { change?: HpChangeNumber }) { |
| 126 | if (!change) return null; |
| 127 | return ( |
| 128 | <div key={change.version} className={`nm-hpfloat ${change.kind}`}> |
| 129 | {change.kind === 'heal' ? `+${change.amount}` : `-${change.amount}`} |
| 130 | </div> |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | |
| 135 | export function Portrait({ p, view, isCurrent, phase, hpChange, children }: { p: Player; view: GameView; isCurrent: boolean; phase: string; hpChange?: HpChangeNumber; children?: ReactNode }) { |
| 136 | const img = portrait(p.character); |
| 137 | const fac = faction(p); |
| 138 | const name = p.character || ''; |
| 139 | return ( |
| 140 | <> |
| 141 | <div className="nm-avatar" style={img ? { backgroundImage: `url(${img})` } : undefined}> |
| 142 | {!img && name && <span className="nm-avatar-none">{name}</span>} |
| 143 | </div> |
| 144 | <div className="nm-frame" /> |
| 145 | {name && <div className={`nm-faction ${FACTION_KEY[fac] ?? 'qun'}`}>{fac}</div>} |
| 146 | {name && <div className={`nm-name${name.length > 4 ? ' long' : ''}`}>{name}</div>} |
| 147 | {name && <Hp hp={p.health} max={p.maxHealth} />} |
| 148 | <div className="nm-count" title={`手牌 ${p.handCount}`}> |
| 149 | {p.handCount} |
| 150 | </div> |
| 151 | {children} |
| 152 | <Judges p={p} view={view} /> |
| 153 | {isChained(p) && <div className="nm-chain" />} |
| 154 | {isFlipped(p) && <div className="nm-flipped">翻面</div>} |
| 155 | {p.alive && p.health <= 0 && <div className="nm-dying">濒死</div>} |
| 156 | {!p.alive && <div className={`nm-dead ${p.identity ? ID_KEY[p.identity] : ''}`}>{p.identity ? <b>{p.identity}</b> : null}阵亡</div>} |
| 157 | {isCurrent && p.alive && <div className="nm-phase-mark">{PHASE_SHORT[phase] ?? phase}</div>} |
| 158 | <HpFloat change={hpChange} /> |
| 159 | </> |
| 160 | ); |
| 161 | } |
| 162 | |
| 163 | export interface SeatProps { |
| 164 | player: Player; |
| 165 | index: number; |
| 166 | style: CSSProperties; |
| 167 | isCurrent: boolean; |
| 168 | phase: string; |
| 169 | needsTarget: boolean; |
| 170 | targetable: boolean; |
| 171 | selected: boolean; |
| 172 | onClick: () => void; |
| 173 | damaged?: number; |
| 174 | healed?: number; |
| 175 | hpChange?: HpChangeNumber; |
| 176 | deadline: number | null; |
| 177 | totalMs?: number; |
| 178 | distance: number | null; |
| 179 | reason: string | null; |
| 180 | |
| 181 | label?: string; |
| 182 | } |
| 183 | |
| 184 | export function Seat(props: SeatProps) { |
| 185 | const { view } = useGameView(); |
| 186 | const { player: p, index, style, isCurrent, phase, needsTarget, targetable, selected, onClick, damaged, healed, hpChange, deadline, totalMs, distance, reason, label = p.name } = props; |
| 187 | const clickable = needsTarget && p.alive && targetable; |
| 188 | const cls = seatState(p, { current: isCurrent, selectable: clickable, selected, unselectable: needsTarget && p.alive && !targetable, damaged: !!damaged, healed: !!healed }); |
| 189 | return ( |
| 190 | <div |
| 191 | key={damaged ? `d${damaged}` : undefined} |
| 192 | className={`nm-seat${needsTarget && clickable ? ' pick' : ''}`} |
| 193 | style={style} |
| 194 | data-seat-index={index} |
| 195 | data-player-name={p.name} |
| 196 | onClick={() => clickable && onClick()} |
| 197 | title={needsTarget && !targetable && reason ? `不可选择:${reason}` : undefined} |
| 198 | > |
| 199 | <div className={`nm-player ${cls}`}> |
| 200 | <Portrait p={p} view={view} isCurrent={isCurrent} phase={phase} hpChange={hpChange}> |
| 201 | <Identity p={p} /> |
| 202 | <Equips p={p} view={view} /> |
| 203 | {needsTarget && distance != null && p.alive && <div className="nm-distance">距{distance}</div>} |
| 204 | </Portrait> |
| 205 | </div> |
| 206 | {label && label !== p.character && <div className="nm-seat-label">{label}</div>} |
| 207 | <Timer deadline={deadline} totalMs={totalMs} /> |
| 208 | </div> |
| 209 | ); |
| 210 | } |