Play liveChoose your opponent
Join
210 lines · 8.6 KB Raw Permalink History
1// Another player's seat, as 无名杀's portrait card: the general's 立绘 filling a framed card in the faction's colour,
2// the name in vertical brush script, the faction badge, the identity at the top right (主公 shown, others ? until
3// revealed), HP as 勾玉, the hand count, equipment as text rows along the bottom, the judgement area's tricks as small
4// marks, 横置 chains, 翻面, dying and dead, the current turn's glow and its phase, and target highlighting.
5import { useGameView } from '../../../src/client/components/GameViewCtx';
6import { useCountdownFraction } from '../../../src/client/hooks/useCountdown';
7import { getCharacterMeta } from '../../../src/engine/data/character-meta';
8import type { GameView, EquipSlot } from '../../../src/engine/types';
9import type { HpChangeNumber } from '../../../src/client/hooks/useAnimationState';
10import type { CSSProperties, ReactNode } from 'react';
11import { portrait, cardArt, FACTION_KEY } from './art';
12import { isRed } from './Card';
13
14type Player = GameView['players'][number];
15export const ID_SHORT: Record<string, string> = { 主公: '主', 忠臣: '忠', 反贼: '反', 内奸: '内' };
16export const ID_KEY: Record<string, string> = { 主公: 'zhu', 忠臣: 'zhong', 反贼: 'fan', 内奸: 'nei' };
17export const SLOTS: Array<[EquipSlot, string]> = [
18 ['武器', '武'],
19 ['防具', '防'],
20 ['防御马', '+1'],
21 ['进攻马', '-1'],
22 ['宝物', '宝'],
23];
24const PHASE_SHORT: Record<string, string> = { 准备: '准备', 判定: '判定', 摸牌: '摸牌', 出牌: '出牌', 弃牌: '弃牌', 回合结束: '结束' };
25
26export 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/** HP as 无名杀 draws it: one 勾玉 per point, lost ones dark; green, then yellow, then red as it falls; a number past 5. */
37export 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
57export function faction(p: Player): string {
58 return p.faction ?? (p.character ? getCharacterMeta(p.character)?.faction : undefined) ?? '群';
59}
60
61export const isChained = (p: Player) => p.marks.some((m) => m.id === 'chained');
62export const isFlipped = (p: Player) => (p.tags ?? []).some((t) => t.endsWith('/翻面'));
63
64/** The flags every seat card shows the same way, mine or not. */
65export 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
74export 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
80export 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/** A horse's name as 无名杀 writes it: +1 horses with +, -1 horses with -. */
99export const equipName = (slot: EquipSlot, name: string) => (slot === '防御马' ? `${name}+` : slot === '进攻马' ? `${name}-` : name);
100
101export 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
125export 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/** The 立绘 and what lies on it, shared by the others' seats and mine. */
135export 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
163export 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 /** Who sits there (a route, a bot, a person), when it is not just the general's name. */
181 label?: string;
182}
183
184export 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}