| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | import type { ReactNode } from 'react'; |
| 6 | import type { GameView, Json } from '../../../src/engine/types'; |
| 7 | import type { SkillActionDef } from '../../../src/client/skillActionRegistry'; |
| 8 | import type { HpChangeNumber } from '../../../src/client/hooks/useAnimationState'; |
| 9 | import { useGameView } from '../../../src/client/components/GameViewCtx'; |
| 10 | import { isActiveAction, isFreePlayWindow, skillActionVariants } from '../../../src/client/utils/gameViewHelpers'; |
| 11 | import { EQUIPMENT_SKILL_NAMES } from '../../../src/client/components/gameViewConstants'; |
| 12 | import { LORD_SKILLS } from '../../../src/engine/data/character-meta'; |
| 13 | import { DEFAULT_SKILLS as ENGINE_DEFAULT_SKILLS } from '../../../src/engine/atoms/选将'; |
| 14 | import { getSkillDescription } from '../../../src/engine/skills/lifecycle'; |
| 15 | import { useSkillDescReady } from '../../../src/client/hooks/useSkillDescReady'; |
| 16 | import { displaySkillName } from '../../../src/client/utils/skillDisplay'; |
| 17 | import { Portrait, Identity, SLOTS, seatState, equipName } from './Seat'; |
| 18 | import { isRed } from './Card'; |
| 19 | |
| 20 | const DEFAULT_SKILLS = new Set(ENGINE_DEFAULT_SKILLS); |
| 21 | type Player = GameView['players'][number]; |
| 22 | |
| 23 | interface Props { |
| 24 | player: Player; |
| 25 | index: number; |
| 26 | isCurrent: boolean; |
| 27 | phase: string; |
| 28 | spectating: boolean; |
| 29 | onSkillAction: (action: SkillActionDef, extraParams?: Record<string, Json>) => void; |
| 30 | onEquipClick: (cardId: string) => void; |
| 31 | distCandidates: Set<string> | null; |
| 32 | distSelected: Set<string>; |
| 33 | isDistributeActive: boolean; |
| 34 | targetable: boolean; |
| 35 | selected: boolean; |
| 36 | onTarget: () => void; |
| 37 | damaged?: number; |
| 38 | healed?: number; |
| 39 | hpChange?: HpChangeNumber; |
| 40 | timer: ReactNode; |
| 41 | hand: ReactNode; |
| 42 | } |
| 43 | |
| 44 | export function Me(props: Props) { |
| 45 | const { view, canOperate, skillActions } = useGameView(); |
| 46 | useSkillDescReady(); |
| 47 | const { player: p, index, isCurrent, phase, spectating, onSkillAction, onEquipClick, distCandidates, distSelected, isDistributeActive, targetable, selected, onTarget, damaged, healed, hpChange, timer, hand } = props; |
| 48 | const actionCtx = { view, perspectiveIdx: index }; |
| 49 | |
| 50 | |
| 51 | |
| 52 | const isLord = p.identity === '主公'; |
| 53 | const visible = p.skills.filter((s) => !DEFAULT_SKILLS.has(s) && !EQUIPMENT_SKILL_NAMES.has(s) && (isLord || !LORD_SKILLS.has(s))); |
| 54 | const triggerable = skillActions.filter( |
| 55 | (a) => |
| 56 | a.prompt.type === 'confirm' || |
| 57 | a.prompt.type === 'chooseOption' || |
| 58 | a.prompt.type === 'choosePlayer' || |
| 59 | a.prompt.type === 'selectTarget' || |
| 60 | (a.prompt.type === 'useCardAndTarget' && !!a.transform) || |
| 61 | (a.prompt.type === 'useCard' && !!a.transform) || |
| 62 | a.prompt.type === 'distribute', |
| 63 | ); |
| 64 | const isSkillActive = (a: SkillActionDef) => canOperate && isActiveAction(a, actionCtx); |
| 65 | const inFreePlayWindow = canOperate && isFreePlayWindow({ isMyTurn: isCurrent, phase: view.phase, pending: view.pending }); |
| 66 | const skills = visible.flatMap((s) => { |
| 67 | const btns = triggerable.filter((a) => a.skillId === s); |
| 68 | const desc = getSkillDescription(s) ?? btns[0]?.prompt.title; |
| 69 | const display = displaySkillName(s); |
| 70 | const variants = btns.flatMap((a) => skillActionVariants(a).map((v) => ({ ...v, action: a, label: v.label || (btns.length > 1 ? a.label || a.actionType : '') }))); |
| 71 | const active = variants.filter((v) => isSkillActive(v.action)); |
| 72 | if (active.length) |
| 73 | return active.map((v) => ( |
| 74 | <button key={v.label ? `${s}:${v.label}` : s} className={`nm-skill on${v.action.style === 'danger' ? ' danger' : ''}`} title={v.description ?? desc} onClick={() => onSkillAction(v.action, v.params)}> |
| 75 | {v.label ? (v.label.startsWith(display) ? v.label : `${display}·${v.label}`) : display} |
| 76 | </button> |
| 77 | )); |
| 78 | if (btns[0] && inFreePlayWindow) |
| 79 | return [ |
| 80 | <button key={s} className="nm-skill" disabled title={p.turnUsage?.[`${s}/usedThisTurn`] ? '本回合已发动过(每回合限一次)' : (desc ?? '')}> |
| 81 | {display} |
| 82 | </button>, |
| 83 | ]; |
| 84 | return [ |
| 85 | <span key={s} className="nm-skill passive" title={desc}> |
| 86 | {display} |
| 87 | </span>, |
| 88 | ]; |
| 89 | }); |
| 90 | |
| 91 | |
| 92 | const activeEquip = new Map<string, SkillActionDef>(); |
| 93 | for (const a of skillActions) if (EQUIPMENT_SKILL_NAMES.has(a.skillId) && canOperate && isActiveAction(a, actionCtx)) activeEquip.set(a.skillId, a); |
| 94 | const equips = SLOTS.map(([slot]) => { |
| 95 | const id = p.equipment[slot]; |
| 96 | const c = id ? view.cardMap[id] : undefined; |
| 97 | if (!id) return <div key={slot} className="nm-myequip empty" title={slot} />; |
| 98 | const skill = isDistributeActive ? undefined : activeEquip.get(c?.name ?? ''); |
| 99 | const cand = isDistributeActive && !!distCandidates?.has(id); |
| 100 | const desc = c && EQUIPMENT_SKILL_NAMES.has(c.name) ? getSkillDescription(c.name) : c?.description; |
| 101 | return ( |
| 102 | <div |
| 103 | key={slot} |
| 104 | className={`nm-myequip${skill ? ' usable' : ''}${cand ? ' candidate' : ''}${distSelected.has(id) ? ' picked' : ''}`} |
| 105 | onClick={skill ? () => onSkillAction(skill) : cand ? () => onEquipClick(id) : undefined} |
| 106 | title={`${c?.name ?? id}(${slot})${desc ? `\n${desc}` : ''}`} |
| 107 | > |
| 108 | <span className="nm-equip-name">{c ? equipName(slot, c.name) : id}</span> |
| 109 | {c && ( |
| 110 | <span className={`nm-equip-info${isRed(c.suit) ? ' red' : ''}`}> |
| 111 | {c.suit} |
| 112 | {c.rank} |
| 113 | </span> |
| 114 | )} |
| 115 | </div> |
| 116 | ); |
| 117 | }); |
| 118 | |
| 119 | return ( |
| 120 | <div className={`nm-me${spectating ? ' spectating' : ''}`}> |
| 121 | <div |
| 122 | key={damaged ? `d${damaged}` : undefined} |
| 123 | className={`nm-player mine ${seatState(p, { current: isCurrent, selectable: targetable, selected, damaged: !!damaged, healed: !!healed })}`} |
| 124 | data-seat-index={index} |
| 125 | data-player-name={p.name} |
| 126 | onClick={onTarget} |
| 127 | > |
| 128 | <Portrait p={p} view={view} isCurrent={isCurrent} phase={phase} hpChange={hpChange}> |
| 129 | <Identity p={p} mine /> |
| 130 | </Portrait> |
| 131 | </div> |
| 132 | <div className="nm-myequips">{equips}</div> |
| 133 | <div className="nm-hand"> |
| 134 | {timer} |
| 135 | {hand} |
| 136 | </div> |
| 137 | <div className="nm-skills">{skills}</div> |
| 138 | <div className="nm-me-label">{spectating ? `旁观 · ${p.name}` : p.name}</div> |
| 139 | </div> |
| 140 | ); |
| 141 | } |