Unity UGUI 程序化网格 10:状态驱动的技能范围指示器
三种网格一份状态
前九篇已经分别验证矩形、圆环和扇形。本文不再发明网格算法,而是解决真实实战中的所有权问题:谁接收输入,谁保存一次完整显示状态,谁把游戏单位换成 UI 单位,谁只负责生成 Mesh。核心是一个状态枚举、一个只读快照和一个组合控制器。
// Assets/OnPopulateMeshBook/Chapters/Chapter10_SkillRange/
// Runtime/SkillRangeIndicator.cs · 类型、字段与绑定
using OnPopulateMeshBook.Chapter04;
using OnPopulateMeshBook.Chapter08;
using OnPopulateMeshBook.Chapter09;
using UnityEngine;
namespace OnPopulateMeshBook.Chapter10
{
public enum SkillAimPhase
{
Hidden,
Aiming
}
public readonly struct SkillRangeViewState
{
public readonly bool Visible;
public readonly float Radius;
public readonly float InnerRadius;
public readonly float DirectionDegrees;
public readonly float SweepDegrees;
public SkillRangeViewState(
bool visible,
float radius,
float innerRadius,
float directionDegrees,
float sweepDegrees)
{
Visible = visible;
Radius = radius;
InnerRadius = innerRadius;
DirectionDegrees = directionDegrees;
SweepDegrees = sweepDegrees;
}
}
}
控制器继续使用同一文件中的上述类型;下段给出组件字段、观察状态与三项子组件绑定。
namespace OnPopulateMeshBook.Chapter10
{
public sealed class SkillRangeIndicator : MonoBehaviour
{
[SerializeField] private SectorGraphic sector;
[SerializeField] private RingGraphic boundary;
[SerializeField] private RectQuadGraphic directionLine;
[SerializeField, Min(0.01f)] private float pixelsPerUnit = 32f;
public SkillAimPhase Phase { get; private set; } = SkillAimPhase.Hidden;
public SkillRangeViewState Current { get; private set; }
public int AppliedStateCount { get; private set; }
private bool hasState;
public void Bind(
SectorGraphic sectorGraphic,
RingGraphic ringGraphic,
RectQuadGraphic lineGraphic)
{
sector = sectorGraphic;
boundary = ringGraphic;
directionLine = lineGraphic;
}
同一个 Prefab 要表达圆形、环形和扇形范围,并让三个子网格只接收一份固定快照。本文沿用正 X 轴零度、逆时针为正、局部二维坐标、每次完整重建和参数改变标记顶点脏等系列合同,同时增加重复快照不再下发的状态边界。
第 10 篇的重点不是把三个 Graphic 摆在一起,而是保证组合后仍只有一个显示状态所有者。若扇形、边界环和方向线各自保存方向与半径,任意一次漏写都会产生相互矛盾的画面;若它们都由同一快照下发,三个子组件只是同一状态的不同投影。实战验收因此同时检查状态、换算结果、网格数量与隐藏生命周期。
读者可以从空 Canvas 复建 Prefab:创建中心对齐的根对象,依次创建 Sector、Boundary 和 DirectionLine 三个子对象,分别挂载前三篇已经验证的组件,再把引用拖入 Indicator。根对象不挂 Graphic,避免产生第四份无意义网格;它只承担组合、可见性和状态换算。
技能范围场景
固定场景使用半径 5、内半径 1、方向约 28.811 度、夹角 70 度和每游戏单位 32 个 UI 单位。组合控制器先换算出外半径 160、内半径 32,再设置扇形、三像素边界环和长度 160 的方向线。



Prefab 根对象尺寸为 420 × 420,挂载 SkillRangeIndicator,并绑定三个子对象:Sector 使用第 9 篇 SectorGraphic,Boundary 使用第 8 篇 RingGraphic,DirectionLine 使用第 4 篇 RectQuadGraphic。三者共用同一局部原点,避免各自保存位置偏移。
复建时先只启用 Sector,应用圆形快照并确认范围面;再启用 Boundary,确认外边界与范围面共享半径;最后启用 DirectionLine,检查其左端落在根原点。分阶段加入子对象能把绑定遗漏、半径换算和 Pivot 错误分开。三个子对象一次全部配置后才观察结果,出现偏差时很难判断是谁破坏了共同中心。
可见画面还要和层级顺序一起检查。范围面使用半透明色,边界环与方向线使用更高不透明度;它们仍然是三个独立 CanvasRenderer,Prefab 的子对象顺序决定覆盖关系。当前证据只验证固定层级的结果,不声称任意材质、遮罩或父 Canvas 排序都等价。
固定制品不是“约一百多个点”的估计,而是三份真实 Mesh 的求和:扇形 22 个顶点、60 个索引;边界环 98 个顶点、288 个索引;方向线 4 个顶点、6 个索引;合计必须是 124 个顶点和 354 个索引。
{
"fixedInput": "radius=5 inner=1 direction=28.811 sweep=70 ppu=32",
"derived": "outer=160 inner=32 start=-6.189 boundaryInner=157",
"meshes": [
{ "component": "SectorGraphic", "vertices": 22, "indices": 60 },
{ "component": "RingGraphic", "vertices": 98, "indices": 288 },
{ "component": "RectQuadGraphic", "vertices": 4, "indices": 6 }
],
"total": { "vertices": 124, "indices": 354 }
}
扇形在这个输入下是带内半径的环形扇区。完整圆分段数为 48,70 度覆盖 ceil(48×70/360)=10 步,因此顶点数 2×(10+1)=22,索引数 6×10=60。边界环 48 段,数量为 2×(48+1)=98 与 6×48=288。方向线沿用固定四边形 4/6。三项相加与制品完全一致。
显示快照
本文新增“显示快照”:一次完整描述可见性、半径、内半径、中心方向和夹角的不可分数据。它不是玩法技能对象,也不包含目标、冷却、伤害或碰撞;这些状态属于游戏系统。快照只回答当前指示器应该显示什么。
SkillRangeIndicator 是显示快照的所有者和下发边界。BeginAim 建立可见快照,UpdateDirection 只在 Aiming 阶段且方向非零时产生新快照,CancelAim 与 ConfirmAim 都回到 Hidden。网格子组件不读取输入,也不互相修改。
“数据所有者”在这里指有权决定快照何时变化的一层。演示场景中是 SkillRangeDemoInput 产生交互,Indicator 接收并保存显示快照;正式游戏中,玩法控制器可以替代演示输入,但 Indicator 与三个网格组件无需变化。谁拥有技能能否施放、目标是否合法以及确认后发送什么命令,仍由玩法层决定。
快照使用只读字段,构造后不能逐项修改。这样一次 Apply 看到的是半径、内半径、方向、夹角和可见性的一致组合,不会出现先改方向、下一帧才改夹角的半完成状态。它不是线程同步机制,而是本地 API 的最小原子语义:调用者必须先准备完整状态,再下发一次。
// SkillRangeIndicator.cs · 瞄准生命周期
public void BeginAim(
float radius, float innerRadius, float sweepDegrees)
{
Phase = SkillAimPhase.Aiming;
Apply(new SkillRangeViewState(
true, radius, innerRadius, 0f, sweepDegrees));
}
public void UpdateDirection(Vector2 localDirection)
{
if (Phase != SkillAimPhase.Aiming ||
localDirection.sqrMagnitude == 0f) return;
var degrees = Mathf.Atan2(
localDirection.y,
localDirection.x) * Mathf.Rad2Deg;
Apply(new SkillRangeViewState(
true,
Current.Radius,
Current.InnerRadius,
degrees,
Current.SweepDegrees));
}
public void CancelAim()
{
Phase = SkillAimPhase.Hidden;
Apply(new SkillRangeViewState(
false,
Current.Radius,
Current.InnerRadius,
Current.DirectionDegrees,
Current.SweepDegrees));
}
public void ConfirmAim() => CancelAim();
正常时序为按下开始瞄准、拖动更新方向、松开确认并隐藏。失败时序有两个明确分支:非 Aiming 阶段的方向输入被忽略,零向量无法定义角度也被忽略。控制器不会为缺失方向猜一个随机角度;初始 BeginAim 明确使用 0 度,第一次有效拖动再更新。
取消与确认当前都只结束显示,这是有意边界。确认技能通常还要校验目标、扣除资源并发送玩法命令,这些动作没有出现在当前类型和测试中,不能塞入显示组件。外部系统可在调用 ConfirmAim 前后执行自己的命令,而 Prefab 只保证最终进入 Hidden。
同一个快照合同可以表达三类范围。圆形是 InnerRadius=0、SweepDegrees=360;环形是正内半径与 360 度;扇形是小于 360 度的夹角。Prefab 结构不变,变化只来自快照字段,因此复用不需要复制三套控制器。
状态下发
Apply 先比较当前快照。五个字段均相同时立即返回,不增加 AppliedStateCount,也不重复写入子组件。这个幂等门禁放在组合层,而不是分别要求三个 Graphic 推测整体状态是否改变。
浮点字段使用 Mathf.Approximately 比较,而可见性使用精确布尔比较。幂等判断的目的不是模糊玩法数值,而是避免同一显示值在输入采样中反复下发。若产品要求更明确的量化精度,应在玩法到显示快照的边界定义单位和步长;当前代码只采用 Unity 的近似比较,并据此记录实测计数。
// SkillRangeIndicator.cs · Apply
public void Apply(SkillRangeViewState state)
{
if (hasState &&
Current.Visible == state.Visible &&
Mathf.Approximately(Current.Radius, state.Radius) &&
Mathf.Approximately(Current.InnerRadius, state.InnerRadius) &&
Mathf.Approximately(
Current.DirectionDegrees, state.DirectionDegrees) &&
Mathf.Approximately(
Current.SweepDegrees, state.SweepDegrees))
return;
hasState = true;
AppliedStateCount++;
Current = state;
var safeRadius = Mathf.Max(0f, state.Radius);
var radiusPixels = safeRadius * pixelsPerUnit;
var innerPixels = Mathf.Clamp(
state.InnerRadius, 0f, safeRadius) * pixelsPerUnit;
gameObject.SetActive(state.Visible);
if (!state.Visible) return;
sector.OuterRadius = radiusPixels;
sector.InnerRadius = innerPixels;
sector.SweepAngle = state.SweepDegrees;
sector.StartAngle =
state.DirectionDegrees - state.SweepDegrees * 0.5f;
boundary.OuterRadius = radiusPixels;
boundary.InnerRadius =
Mathf.Max(0f, radiusPixels - 3f);
var lineRect = directionLine.rectTransform;
lineRect.sizeDelta = new Vector2(radiusPixels, 3f);
lineRect.pivot = new Vector2(0f, 0.5f);
lineRect.localRotation = Quaternion.Euler(
0f, 0f, state.DirectionDegrees);
}
}
}
下发顺序先更新 Current,再把游戏半径归一为非负的 safeRadius;内半径随后被夹在零到这个安全半径之间。这个顺序保证负游戏半径不会形成反向的 Clamp 区间。隐藏时根对象关闭,方法不继续写子组件。显示时,扇形获得有效区间,边界环固定为三像素厚,方向线从本地原点沿中心方向延伸。
负半径不会向下游产生负坐标尺度,过大的内半径也不会穿过外边界。这里选择夹紧显示值,而不是让整个 Prefab 空白,因为快照还承担方向线和边界显示;具体技能是否允许这种输入仍应由上层拒绝。显示层的恢复策略是生成有界画面,玩法层的恢复策略是修正无效配置,二者不能混为同一规则。
中心方向到起始角仍复用第 9 篇公式:start=direction-sweep/2。固定输入得到 28.811-35=-6.189 度。方向线直接旋转到 28.811 度,因此扇形中线与方向线使用同一快照,不会出现两个状态所有者。
AppliedStateCount 不是帧计数,也不是网格重建次数;它只记录有多少份不同快照通过了组合层。PlayMode 测试连续应用同一个圆形快照两次,计数仍为 1。这项证据锁定的是幂等语义,不宣称 Canvas 一定只执行一次内部重建。
一次有效快照会分别调用多个子组件属性,每项属性各自标记顶点脏。因此 AppliedStateCount=1 也不能推导“只产生一次 SetVerticesDirty”。组合层计数与 UGUI 重建次数属于不同观察尺度;若后续需要分析批处理或重建成本,必须用 Profiler 建立新证据,而不是复用这个业务计数。
输入与 Prefab
SkillRangeDemoInput 只负责把指针事件转换为本地二维方向。按下时调用 BeginAim 并立即更新方向,拖动继续更新,松开调用 ConfirmAim。屏幕点通过 RectTransformUtility.ScreenPointToLocalPointInRectangle 转到输入平面的局部点,再交给控制器。
// Runtime/SkillRangeDemoInput.cs · 事件到局部方向
public void OnPointerDown(PointerEventData eventData)
{
indicator.BeginAim(radius, innerRadius, sweepDegrees);
UpdateDirection(eventData);
}
private void UpdateDirection(PointerEventData eventData)
{
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
inputPlane, eventData.position,
eventData.pressEventCamera, out var localPoint))
return;
indicator.UpdateDirection(localPoint);
}
演示输入是可替换边界。正式项目可以由摇杆、键盘、触屏或游戏状态机产生方向,只要最终提供局部二维向量和显示快照,就不需要修改任何 OnPopulateMesh。这正是把输入移出绘制组件的收益。
拖动和松开的当前实现分别转发 UpdateDirection 与 ConfirmAim,没有加入死区、吸附角和按键取消。这些都属于输入策略,不是网格功能。若手柄摇杆需要死区,应在产生局部方向前处理;若技能需要八方向吸附,应在构造快照前量化方向。保持这一边界后,鼠标演示代码不会成为所有设备的强制依赖。
Prefab 保存三项子组件引用和 pixelsPerUnit=32。方向线 Pivot 固定为 (0,0.5),所以改变宽度时左端保持在原点;若使用中心 Pivot,长度会向中心两侧展开,与技能发射方向不符。三子对象 RectTransform 共享中心位置与零旋转基准,控制器只旋转方向线自身。
pixelsPerUnit 是本卷唯一显式单位换算旋钮。它让游戏半径 5 映射为本地 160,而不是假定游戏单位天然等于屏幕像素。真实项目可依据相机、地图缩放或 UI 设计更新这项比例,但本卷没有定义世界坐标投影;改变比例后必须重新验证三个子组件仍使用同一换算。
组合验收
PlayMode 包含两条测试。第一条从代码创建根对象与三个真实子组件,依次验证重复圆形快照、负半径归零、环形、扇形瞄准和隐藏;第二条建立独立输入平面,真实调用按下、拖动、松开处理器,并确认隐藏后的 Indicator 能被下一次按下重新激活。
// Tests/PlayMode/SkillRangeIndicatorTests.cs · 输入生命周期断言
input.OnPointerDown(EventAt(
eventSystem, inputRect, Vector2.right * 100f));
input.OnDrag(EventAt(
eventSystem, inputRect, Vector2.up * 100f));
Assert.That(
indicator.Current.DirectionDegrees,
Is.EqualTo(90f).Within(0.001f));
input.OnPointerUp(EventAt(
eventSystem, inputRect, Vector2.up * 100f));
Assert.That(root.activeSelf, Is.False);
input.OnPointerDown(EventAt(
eventSystem, inputRect, Vector2.right * 100f));
Assert.That(root.activeSelf, Is.True);
"$UNITY_EDITOR" \
-batchmode -nographics \
-projectPath "$PROJECT_ROOT/.tmp/unity-onpopulate-mesh-lab" \
-runTests -testPlatform PlayMode \
-testResults "$ARTIFACTS/playmode-results.xml" \
-logFile "$ARTIFACTS/playmode.log"
echo "process_exit_code=$?"
process_exit_code=0
total=2 passed=2 failed=0 skipped=0 duration=0.0366424s
AimLifecycle_UpdatesDirectionThenHides=Passed duration=0.026356s
negativeRadius outer=0 inner=0 aimingAppliedStateCount=5
DemoInput_DrivesAimAndCanReactivateHiddenIndicator=Passed duration=0.003488s
pointerUp active=false
secondPointerDown active=true phase=Aiming
证据证明组合器能够切换三种几何语义、抑制相同快照、把非法半径收敛到空范围,并由真实指针处理器更新方向、隐藏及再次激活根对象。它没有验证真实设备输入延迟、世界坐标投影、技能命中或批量指示器性能,因此这些内容不能从两个 PlayMode 用例外推。
测试中的圆形与环形步骤共享同一 Prefab:圆形快照内半径为零、夹角为 360;随后应用正内半径的 360 度快照,Sector 转为环形扇区;最后 BeginAim 使用 60 度进入锥形瞄准。输入测试再从场景边界驱动相同组合器,证明演示代码不是只存在于正文中的未执行示例。
运行截图、制品 JSON 与 PlayMode 测试回答不同问题。截图证明 Prefab 层级的实际组合结果,JSON证明固定参数生成 124/354,测试证明状态转换和相同快照幂等。只有三者同时成立,才能说“实战闭环可复现”;任何一项都不能代替另外两项。
生命周期故障
第一个错误是每帧无条件重复调用 Apply,同时删除快照比较。画面可能相同,但 AppliedStateCount 会从 1 增长到 2,三个子组件反复接收相同参数并标记顶点脏。恢复位置是组合层的幂等比较,不是让每个子网格分别猜测整份状态。
重复快照:AppliedStateCount 应保持 1
方向 90、夹角 60:StartAngle 应为 60
第二个错误把 DirectionDegrees 直接赋给 sector.StartAngle。方向 90、夹角 60 时,扇形区间会变成 90 到 150 度,而方向线仍朝 90 度,二者失去共同中线。修复是继续使用 direction-sweep/2,并让方向线与扇形读取同一快照。
第三个错误让 SectorGraphic 自己读取鼠标。这样 Prefab 同时拥有输入设备、瞄准阶段和网格,PlayMode 测试无法用固定向量复现,摇杆版本还要复制绘制代码。正确边界是输入层只产生局部方向,Indicator 负责状态换算,Graphic 只生成几何。
第四个错误是三个子对象尺寸或中心不一致。即使半径字段相同,父子变换也会让方向线、扇形和边界在画面中错位。恢复顺序是先把三个 RectTransform 放回共同中心与统一缩放,再检查 Indicator 参数,不能通过给某一个网格增加位置补偿掩盖 Prefab 层级错误。
系列落点
ConfirmAim 与 CancelAim 都只结束显示,因为技能提交、目标合法性和网络命令属于玩法系统。Indicator 保存的是显示快照,不是完整技能状态;输入层也只提供局部方向,不接触 VertexHelper。
十篇从零顶点推进到三组件 124/354,始终遵守同一个原则:每个 Graphic 清空并提交当前完整几何,外部控制器通过参数和脏标记改变显示。矩形提供方向线,圆环提供边界,扇形提供有效范围,第 10 篇只组合已验证能力,没有建立通用网格框架。
当前系列不处理世界坐标投影、几何命中、Mask、Shader、GC 和 Canvas 批处理。这些问题需要新的状态所有者、失败路径与测试证据,应作为后续独立系列,而不是继续塞进技能范围组件。