跳到主要内容

自定义 UI 接入

Guide

订阅战斗事件,通过 GCSApi 读取当前状态,再把出牌、回合、选择和奖励操作提交回运行时

上图展示了自定义 UI 与 GCS 之间唯一需要维持的双向关系,运行时用事件通知变化,UI 收到通知后重新读取当前状态,玩家操作再通过 GCSApi 提交回同一场战斗,界面本身不保存另一份权威战斗数据

代码片段只包含与 GCS 交互的组件部分,将它们放入项目的 presenter 或 MonoBehaviour,并按实际使用的类型添加 using System;、using System.Collections.Generic; 和 using TinyGiants.GCS.Runtime;

接入检查清单​

  • 放置并配置一个 GameCardManager

  • 用 GCSApi.StartBattle 启动战斗

  • 在 UI 生命周期方法中订阅运行时事件

  • 每次事件触发后,从 GCSApi 刷新 UI

  • 卡牌按钮使用 GCSApi.CanPlayCard 和 GCSApi.TryPlayCard

  • 使用 TMP Description 时,通过 DescriptionTooltipTrigger 绑定卡牌规则文本

  • 结束回合按钮使用 GCSApi.EndPlayerTurn

  • 奖励界面使用 GCSApi.ApplyReward 或 GCSApi.SkipReward

  • 释放每一个订阅

  • 让 Demo 专用 View 组件保持可替换

订阅生命周期​

每个 UI 对象用一个小的 disposable 列表管理订阅

private readonly List<IDisposable> _subscriptions = new();

void OnEnable()
{
_subscriptions.Add(GCSApi.OnBattleStarted(RefreshAll));
_subscriptions.Add(GCSApi.OnEnergyChanged(_ => RefreshEnergy()));
_subscriptions.Add(GCSApi.OnCardDrawn(_ => RefreshHand()));
_subscriptions.Add(GCSApi.OnCardDiscarded(_ => RefreshHand()));
_subscriptions.Add(GCSApi.OnCardExhausted(_ => RefreshHand()));
_subscriptions.Add(GCSApi.OnUnitHpChanged(_ => RefreshUnits()));
_subscriptions.Add(GCSApi.OnStatusChanged(_ => RefreshStatuses()));
}

void OnDisable()
{
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
}
提示

GameObject 销毁时应释放它持有的订阅,否则陈旧监听器会在重新加载后叠加,造成 UI 重复刷新

战斗 HUD​

高层 HUD 字段从状态刷新:

Widget读取
Phase labelGCSApi.Phase
Turn labelGCSApi.TurnNumber
EnergyGCSApi.PlayerEnergy、GCSApi.MaxEnergy
End turn buttonGCSApi.Phase == BattlePhase.PlayerPhase
Battle result overlayOnBattleEnded 与 BattleEndedEventArgs.Won

阶段应以 GCSApi.Phase 和阶段事件为准,因为行为图可以结束回合、给予额外回合、打开选择,甚至在没有 UI 输入的情况下直接结束战斗

HUD 和游戏对不上时

打开 Game Card Monitor:

Tools > TinyGiants > GCS > Game Card Monitor

查看 Battle 标签页,如果 Monitor 中的战斗状态正确,而 HUD 显示错误,应检查 HUD 的事件订阅和刷新路径

运行中战斗里 Game Card Monitor 的 Battle 标签页,显示自定义 HUD 应当呈现的同一份阶段、回合、玩家和敌人状态

手牌 UI​

手牌可以从 GCSApi.Hand() 完整重建,也可以按实例 ID 局部更新,该方法返回调用时刻的实时手牌堆,本回合新抽的卡也包含在内

void RefreshHand()
{
foreach (var card in GCSApi.Hand())
{
var active = GCSApi.GetActiveCard(card);
var cost = GCSApi.GetEffectiveEnergyCost(card);
var playable = GCSApi.CanPlayCard(card);
RenderCard(card, active, cost, playable);
}
}

绑定卡牌 Description 与 Tooltip​

在卡牌视图中,将 DescriptionTooltipTrigger 挂载到用于显示 Description 的 GameObject 上,如果该 GameObject 已挂载对应的 TMP_Text 组件,则无需设置 Description Label;只有当文本组件位于其他 GameObject 上时,才需要将对应的 TMP_Text 拖入 Description Label,如果希望 Tooltip 以完整卡面作为定位基准,请将卡面的 RectTransform 拖入 Tooltip Anchor

卡牌视图绑定数据时,调用 Bind,并传入当前使用的 GameCard 定义及其对应的 CardInstance,组件会自动解析文本中的 Token 和格式标记

提示

对于有效引用,这将会隐藏其显示文本中的 @ 前缀,并为其生成相应的 Tooltip

[SerializeField] private DescriptionTooltipTrigger descriptionTooltip;

void BindCard(CardInstance instance)
{
var activeCard = GCSApi.GetActiveCard(instance);
descriptionTooltip.Bind(activeCard, instance);
}

void ReleaseCard()
{
descriptionTooltip.Clear();
}

卡牌视图返回对象池前,应调用 Clear(),清除当前显示的文本和引用元数据,并关闭由该文本触发的 Tooltip

提示

Status 引用仅会从 GameCardManager 当前启用的 Status Database 中解析,因此,所有需要在 Description 中引用的 GameStatus,都必须加入已启用的数据库,Keyword 引用则通过 CardTagRegistry 解析

对于 Canvas UI,需要满足以下条件才能正常接收指针移动事件:Description 文本组件已启用 Raycast Target、场景中存在 EventSystem,并且 Canvas 上挂载了 GraphicRaycaster

世界空间卡牌可以通过 Physics2DRaycaster 和 Collider2D 接收指针事件,如果项目使用自定义输入系统,则需要主动将指针的屏幕坐标和事件相机传给组件:

descriptionTooltip.UpdatePointer(pointerScreenPosition, eventCamera);

由于自定义输入系统不会触发组件的 OnPointerExit,因此还需要在指针离开卡牌时主动调用 HideTooltip(),未设置 Tooltip Anchor 时,组件会依次尝试使用自身的 RectTransform、Collider2D 和相对于指针的位置,并以第一个可用的结果作为 Tooltip 的定位依据

如果奖励界面中不存在实时的 CardInstance,可以直接调用:

descriptionTooltip.Bind(card);

此时,组件会根据基础 GameCard 定义渲染 Description,DescriptionTooltipTrigger 负责渲染 Description、检测 TMP Link 命中以及定位 Tooltip,项目接入时仍需向组件传入当前卡牌数据,并根据不同卡牌 Prefab 的布局,决定是否指定卡面锚点以及采用哪种指针事件触发方式

卡牌视图完成数据绑定后,再将卡牌点击事件接入现有的出牌检查流程

void OnCardClicked(CardInstance card)
{
var active = GCSApi.GetActiveCard(card);
if (GCSApi.RequiresTarget(active))
{
OpenTargetPicker(card);
return;
}

GCSApi.TryPlayCard(card);
}

目标选择完成后:

void OnTargetSelected(CardInstance card, UnitState target)
{
GCSApi.TryPlayCard(card, target);
}

单位面板​

单位面板从运行时单位构建

面板值读取
HPGCSApi.Hp(unit)、GCSApi.MaxHp(unit)
护甲GCSApi.Armor(unit)
存活/死亡GCSApi.IsAlive(unit)
HP 百分比GCSApi.HpPercent(unit)
状态GCSApi.StatusesOn(unit)
敌人意图GCSApi.PendingIntents(enemy)

单位面板可以由以下事件触发刷新:

  • OnUnitHpChanged

  • OnUnitDied

  • OnUnitRevived

  • OnPlayerUnitAdded

  • OnUnitRemoved

  • OnArmorGained

  • OnArmorLost

  • OnStatusChanged

  • OnStatusTicked

  • OnEnemyUnitIntentChanged

  • OnEnemyPhaseChanged

状态控件​

状态 UI 应读取完整的当前状态映射,而不是只用事件 payload 里的值,用 StatusChangedEventArgs.UnitId 定位是哪个单位变了,然后重新读取它身上的全部状态

void RefreshStatusList(UnitState unit)
{
foreach (var pair in GCSApi.StatusesOn(unit))
{
var status = pair.Key;
var stacks = pair.Value;
RenderStatus(status.Icon, status.DisplayName, stacks, status.ShowStackCount, status.IsDebuff);
}
}

敌人意图​

意图 UI 可以只显示当前顶部意图,也可以显示完整的待执行列表

void RenderIntent(UnitState enemy)
{
var tag = GCSApi.CurrentIntentTag(enemy);
var value = GCSApi.CurrentIntentValue(enemy);
var pending = GCSApi.PendingIntents(enemy);
RenderIntentIcons(tag, value, pending);
}
提示

请在 OnEnemyUnitIntentChanged 触发时刷新,敌人阶段变化后也要刷新

选择 UI​

Choice 节点要求玩家选择卡牌、单位或状态时,GCS 触发 OnEffectChoiceOffered

Payload 字段用途
PromptText选择面板的标题文案
Candidates要渲染的选项
AllowSkip是否显示跳过按钮

选择面板需要实现 IChoicePresenter,打开选择时,运行时会传入提示文本、候选项、minPicks、maxPicks、onPicked 与 onSkipped,面板根据选择数量限制启用或禁用确认按钮,并通过对应回调返回原始候选对象,场景中没有 IChoicePresenter 或候选列表为空时,Choice 节点会按跳过处理,玩家选定后 GCS 会先保存选择结果,再通过 OnEffectChoiceSelected 提供全部已选索引与候选对象,显式或自动跳过都会触发 OnEffectChoiceSkipped

提示

GCSApi.IsWaitingForChoice 为 true 期间,应禁用普通卡牌按钮

奖励 UI​

战斗胜利后,GCS 可能触发 OnBattleRewardOffered

private void ShowReward(BattleRewardOfferedEventArgs args)
{
foreach (var card in args.CardCandidates)
{
RenderRewardCard(card);
}
}

public void PickReward(GameCard card)
{
GCSApi.ApplyReward(RewardChoice.Card, card);
}

public void SkipReward()
{
GCSApi.SkipReward();
}

RewardChoice 还有 Energy 和 Cost 两个选项,选它们时卡牌参数传 null,应用或跳过都会结算奖励环节并触发 OnBattleEnded,用 GCSApi.IsWaitingForReward 判断奖励控件是否应该可见

飘字与表现层​

自定义飘字订阅 FloatingTextSystem.Requested,处理函数会收到一个 FloatingTextRequest

void OnEnable()
{
FloatingTextSystem.Requested += ShowFloatingText;
}

void OnDisable()
{
FloatingTextSystem.Requested -= ShowFloatingText;
}

内置 UnitView 会按 UnitId 注册自身,使 FlowGraph 表现节点能够找到目标视图,自定义单位视觉则把自己的 View Model 绑定到事件 Payload 中的 Unit ID,Demo 的 HandController、CardView 和奖励 UI 类只服务于示例场景,正式项目应绑定稳定的运行时事件与 ID

刷新策略​

策略适用场景
完整重建手牌、奖励选项、小型状态列表
按 ID 局部更新单位面板、意图控件、飘字目标
防抖刷新大型自定义战斗日志或分析面板
立即动画加刷新伤害、治疗、护甲和卡牌移动

可见战斗 UI 通常可以直接从 GCSApi 完整刷新,只有性能分析确认这里形成实际瓶颈后,再改为局部更新或防抖

常见错误​

错误更好的做法
保留一份本地手牌副本并手动修改在卡牌事件后从 GCSApi.Hand() 重建
只根据能量启用卡牌按钮使用 GCSApi.CanPlayCard(card)
忘记取消订阅保存并释放 IDisposable 句柄
把事件 Payload 当成完整状态用 Payload 定位变化对象,再读取当前状态
正式 UI 依赖 Demo UI 类依赖 GCSApi、事件和项目自己的 View 类
因为 VFX 或 SFX 资源缺失而阻塞玩法让玩法正常结算,缺少资源时跳过对应表现