// Another player's seat, as 无名杀's portrait card: the general's 立绘 filling a framed card in the faction's colour, // the name in vertical brush script, the faction badge, the identity at the top right (主公 shown, others ? until // revealed), HP as 勾玉, the hand count, equipment as text rows along the bottom, the judgement area's tricks as small // marks, 横置 chains, 翻面, dying and dead, the current turn's glow and its phase, and target highlighting. import { useGameView } from '../../../src/client/components/GameViewCtx'; import { useCountdownFraction } from '../../../src/client/hooks/useCountdown'; import { getCharacterMeta } from '../../../src/engine/data/character-meta'; import type { GameView, EquipSlot } from '../../../src/engine/types'; import type { HpChangeNumber } from '../../../src/client/hooks/useAnimationState'; import type { CSSProperties, ReactNode } from 'react'; import { portrait, cardArt, FACTION_KEY } from './art'; import { isRed } from './Card'; type Player = GameView['players'][number]; export const ID_SHORT: Record = { 主公: '主', 忠臣: '忠', 反贼: '反', 内奸: '内' }; export const ID_KEY: Record = { 主公: 'zhu', 忠臣: 'zhong', 反贼: 'fan', 内奸: 'nei' }; export const SLOTS: Array<[EquipSlot, string]> = [ ['武器', '武'], ['防具', '防'], ['防御马', '+1'], ['进攻马', '-1'], ['宝物', '宝'], ]; const PHASE_SHORT: Record = { 准备: '准备', 判定: '判定', 摸牌: '摸牌', 出牌: '出牌', 弃牌: '弃牌', 回合结束: '结束' }; export function Timer({ deadline, totalMs }: { deadline: number | null; totalMs?: number }) { const fill = useCountdownFraction(deadline, totalMs || 15000); if (deadline == null) return null; return (
); } /** HP as 无名杀 draws it: one 勾玉 per point, lost ones dark; green, then yellow, then red as it falls; a number past 5. */ export function Hp({ hp, max }: { hp: number; max: number }) { const level = hp > max / 2 ? 'high' : hp > max / 4 && hp > 1 ? 'mid' : 'low'; if (max > 5) return (
{Math.max(hp, 0)}/{max}
); return (
{Array.from({ length: max }, (_, i) => ( = Math.max(hp, 0) ? ' lost' : ''}`} /> ))}
); } export function faction(p: Player): string { return p.faction ?? (p.character ? getCharacterMeta(p.character)?.faction : undefined) ?? '群'; } export const isChained = (p: Player) => p.marks.some((m) => m.id === 'chained'); export const isFlipped = (p: Player) => (p.tags ?? []).some((t) => t.endsWith('/翻面')); /** The flags every seat card shows the same way, mine or not. */ export function seatState(p: Player, extra: Record): string { const f = FACTION_KEY[faction(p)] ?? 'qun'; const on = { dead: !p.alive, dying: p.alive && p.health <= 0, chained: isChained(p), flipped: isFlipped(p), ...extra }; return `${f} ${Object.entries(on) .filter(([, v]) => v) .map(([k]) => k) .join(' ')}`; } export function Identity({ p, mine }: { p: Player; mine?: boolean }) { const id = p.identity; if (!id && !p.identityHidden && !mine) return null; return
{id ? ID_SHORT[id] ?? id[0] : '?'}
; } export function Judges({ p, view }: { p: Player; view: GameView }) { const ids = p.pendingTricks ?? []; if (!ids.length) return null; return (
{ids.map((id) => { const c = view.cardMap[id]; return ( {c && cardArt(c.name) && } {(c?.name ?? '?')[0]} ); })}
); } /** A horse's name as 无名杀 writes it: +1 horses with +, -1 horses with -. */ export const equipName = (slot: EquipSlot, name: string) => (slot === '防御马' ? `${name}+` : slot === '进攻马' ? `${name}-` : name); export function Equips({ p, view }: { p: Player; view: GameView }) { const rows = SLOTS.filter(([slot]) => p.equipment[slot]); if (!rows.length) return null; return (
{rows.map(([slot, label]) => { const c = view.cardMap[p.equipment[slot]!]; return (
{label} {c ? equipName(slot, c.name) : slot} {c && ( {c.suit} {c.rank} )}
); })}
); } export function HpFloat({ change }: { change?: HpChangeNumber }) { if (!change) return null; return (
{change.kind === 'heal' ? `+${change.amount}` : `-${change.amount}`}
); } /** The 立绘 and what lies on it, shared by the others' seats and mine. */ export function Portrait({ p, view, isCurrent, phase, hpChange, children }: { p: Player; view: GameView; isCurrent: boolean; phase: string; hpChange?: HpChangeNumber; children?: ReactNode }) { const img = portrait(p.character); const fac = faction(p); const name = p.character || ''; return ( <>
{!img && name && {name}}
{name &&
{fac}
} {name &&
4 ? ' long' : ''}`}>{name}
} {name && }
{p.handCount}
{children} {isChained(p) &&
} {isFlipped(p) &&
翻面
} {p.alive && p.health <= 0 &&
濒死
} {!p.alive &&
{p.identity ? {p.identity} : null}阵亡
} {isCurrent && p.alive &&
{PHASE_SHORT[phase] ?? phase}
} ); } export interface SeatProps { player: Player; index: number; style: CSSProperties; isCurrent: boolean; phase: string; needsTarget: boolean; targetable: boolean; selected: boolean; onClick: () => void; damaged?: number; healed?: number; hpChange?: HpChangeNumber; deadline: number | null; totalMs?: number; distance: number | null; reason: string | null; /** Who sits there (a route, a bot, a person), when it is not just the general's name. */ label?: string; } export function Seat(props: SeatProps) { const { view } = useGameView(); const { player: p, index, style, isCurrent, phase, needsTarget, targetable, selected, onClick, damaged, healed, hpChange, deadline, totalMs, distance, reason, label = p.name } = props; const clickable = needsTarget && p.alive && targetable; const cls = seatState(p, { current: isCurrent, selectable: clickable, selected, unselectable: needsTarget && p.alive && !targetable, damaged: !!damaged, healed: !!healed }); return (
clickable && onClick()} title={needsTarget && !targetable && reason ? `不可选择:${reason}` : undefined} >
{needsTarget && distance != null && p.alive &&
距{distance}
}
{label && label !== p.character &&
{label}
}
); }