自定义 UI 接入
运行时掌握事实:订阅事件,通过 GCSApi 读取当前状态,再把玩家决策从同一条路送回去。
面向用自己的 HUD、手牌、单位面板和奖励界面替换 demo 战斗 UI 的开发者。GCS 上的自定义 UI 只有一个循环。运行时触发事件,你的 UI 通过 GCSApi 读取当前状态并重新渲染,玩家输入再以 GCSApi 调用的形式送回去。UI 不直接持有玩法状态。
本页代码片段是组件节选,不是完整文件。把它们放进你自己的 presenter 或 MonoBehaviour,并按需要添加 using System;、using System.Collections.Generic; 和 using TinyGiants.GCS.Runtime;。
接入检查清单
- 放置并配置一个
GameCardManager - 用
GCSApi.StartBattle启动战斗 - 在 UI 生命周期方法中订阅运行时事件
- 每次事件触发后,从
GCSApi刷新 UI - 卡牌按钮使用
GCSApi.CanPlayCard和GCSApi.TryPlayCard - 结束回合按钮使用
GCSApi.EndPlayerTurn - 奖励界面使用
GCSApi.ApplyReward或GCSApi.SkipReward - 释放每一个订阅
- 让 demo 专用 view component 保持可选
订阅生命周期
每个 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 label | GCSApi.Phase |
| Turn label | GCSApi.TurnNumber |
| Energy | GCSApi.PlayerEnergy、GCSApi.MaxEnergy |
| End turn button | GCSApi.Phase == BattlePhase.PlayerPhase |
| Battle result overlay | OnBattleEnded 与 BattleEndedEventArgs.Won |
不要从按钮点击推断阶段。一张图可以结束回合、给予额外回合、打开选择,甚至在没有 UI 输入的情况下直接结束战斗。
打开 Tools/TinyGiants/GCS/Game Card Monitor,查看 Battle 标签页。它渲染的正是你的 widget 应该读到的那份运行时状态;如果 Monitor 是对的而你的 HUD 是错的,问题就出在你的刷新路径上,不在战斗里。

手牌 UI
手牌从 GCSApi.Hand() 重建或按需 patch。它返回调用时刻的实时手牌堆,本回合新抽的卡也包含在内。
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);
}
}
点击处理:
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);
}
单位面板
单位面板从运行时单位构建。
| 面板值 | 读取 |
|---|---|
| HP | GCSApi.Hp(unit)、GCSApi.MaxHp(unit) |
| 护甲 | GCSApi.Armor(unit) |
| 存活/死亡 | GCSApi.IsAlive(unit) |
| HP 百分比 | GCSApi.HpPercent(unit) |
| 状态 | GCSApi.StatusesOn(unit) |
| 敌人意图 | GCSApi.PendingIntents(enemy) |
推荐刷新事件:
OnUnitHpChangedOnUnitDiedOnArmorGainedOnArmorLostOnStatusChangedOnStatusTickedOnEnemyUnitIntentChangedOnEnemyPhaseChanged
状态 Widget
状态 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 时,选择节点一律按跳过处理。
玩家选定后 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,也可以把自己的 view model 绑定到事件 payload 里的 unit id。除非你的项目就是在改 demo,否则不要依赖 demo 的 HandController、CardView 或奖励 UI class。
刷新策略
| 策略 | 适用场景 |
|---|---|
| 完整重建 | 手牌、奖励选项、小型状态列表 |
| 按 id patch | 单位面板、意图 widget、飘字目标 |
| 防抖刷新 | 大型自定义战斗日志或分析面板 |
| 立即动画加刷新 | 伤害、治疗、护甲和卡牌移动 |
可见战斗 UI 通常承受得起一次从 GCSApi 发起的完整刷新。只有真实 profiling 显示问题后,再做优化。
常见错误
| 错误 | 更好的做法 |
|---|---|
| 保留一份本地手牌副本并手动修改 | 在卡牌事件后从 GCSApi.Hand() 重建 |
| 只根据能量启用卡牌按钮 | 使用 GCSApi.CanPlayCard(card) |
| 忘记取消订阅 | 保存并释放 IDisposable handle |
| 把事件 payload 当成完整状态 | 用 payload 定位变化对象,再读取当前状态 |
| 生产 UI 依赖 demo UI class | 依赖 GCSApi、事件和你自己的 view class |
| 因为 VFX 或 SFX 资源缺失而阻塞玩法 | 让玩法正常结算,表现层优雅降级 |