ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UE5蓝图实战:从零构建国际象棋规则与状态机

UE5蓝图实战:从零构建国际象棋规则与状态机 第二周结束我的UE5蓝图项目——国际象棋终于从“能摆棋子”推进到了“能按规则走子”。这篇周总结不打算写流水账而是想把这一周真正值得记录的设计取舍、节点组织方式和踩过的坑摊开说清楚。如果你也处在UE5入门阶段想用蓝图做一个小而完整的规则类游戏这篇内容应该能让你少走不少弯路。先交代背景。我第一周把官方第三人称模板拆了一遍熟练了Actor、Component、蓝图通信和简单的变量操作。第二周想找个“比跑酷复杂一点、又不至于碰AI系统”的项目练手最后选了国际象棋。原因很直接棋类游戏的数据结构清晰、规则边界明确而且几乎不用处理物理和动画可以把全部精力压在蓝图逻辑的组织能力上。事实证明这个选择是对的——它逼着我去思考“数据怎么存”“状态怎么切”“逻辑怎么复用”这些纯蓝图节点之外的问题。下面按我实际推进的顺序把这一周的内容分成六块来讲每一块都包含设计思路、实现方案以及我踩过的坑。1. 为什么第二个项目选国际象棋一次有意的“反常识”选型很多人入门UE5做的第二个项目是打砖块、跑酷、简单的FPS射击。这些项目当然好但它们对“数据管理”的要求偏弱。打砖块的核心是碰撞响应和场景里刷Actor跑酷的核心是程序化生成和角色控制它们练的是引擎特性和玩法手感。但如果你像我一样目标是能把脑子里的规则逻辑干净地转译成蓝图国际象棋这种纯桌游逻辑的场景反而是更好的训练场。象棋规则天然就是一个“状态机”的教科书棋盘有固定坐标棋子有类型和阵营每一步移动有合法条件回合在黑白之间切换。这些概念在蓝图里能直接映射成非常基础、非常重要的编程概念。棋盘坐标 → 二维数组的索引或者两个整数变量。棋子类型 → 枚举Pawn、Rook、Knight、Bishop、Queen、King。棋子归属 → 一个布尔变量或阵营枚举。走法合法性 → 分支、循环、数组运算全是蓝图基础节点。回合切换 → 状态机或者枚举变量切换。吃子 → 销毁Actor或把目标Actor存入数组。这套映射关系练的不是某个具体功能而是“把现实规则拆成数据结构”的建模能力。第二周结束回头看我最大的收获不是“我会用蓝图做象棋了”而是“我知道怎么把一个模糊的规则描述拆成可执行的逻辑判断了”。另外还有一层务实考虑国际象棋不需要高频输入不需要物理模拟不需要复杂动画。这意味着在做蓝图的初期阶段你可以完全屏蔽掉“引擎自身的复杂度”把所有精力集中在纯逻辑上。这对打消“UE5这么复杂我是不是学不会”的焦虑特别有帮助。2. 棋盘与棋子的底层设计数据决定上层逻辑的命运这是第二周花时间最多、也最值得讲清楚的部分。很多人一做棋盘就急着拖Actor、摆静态网格体结果做到一半发现走法判断没法写、回合状态乱套。根源在于没有先把“棋盘是什么”这个数据模型定义清楚。2.1 坐标系统从代数坐标到世界坐标的桥接国际象棋的棋盘是8x8坐标用a1到h8表示。我在项目里没有直接用这种字符串而是用两个整数来定位X轴0到7对应a到hY轴0到7对应1到8。这样在蓝图层面上每一个格子就是两个字节的整数判断走法、生成走法列表、做后悔棋都极其方便。关键的一步是“逻辑坐标”和“世界坐标”的换算。棋盘格子的大小我用的是200单位Unreal里默认单位是厘米的话就是2米所以世界坐标的计算逻辑是ActorLocation BoardOrigin FVector(GridX * 200, GridY * 200, 0)。为什么要把这一层换算单独抽出来因为任何棋子的位置最终都要落到世界坐标上而任何逻辑判断又要回到整数坐标上。这个双向换算我写成了两个自定义函数一个叫GridToWorld一个叫WorldToGrid。后者的作用很实际玩家点击棋盘时射线给到你的是一个世界位置你要靠它反推出玩家点了哪个格子才能继续走子逻辑。没有这一层后面的鼠标拾取就是空中楼阁。2.2 数据结构枚举、结构体和“一维数组代替二维数组”棋子类型我用了一个枚举EPieceTypePawn、Rook、Knight、Bishop、Queen、King。每个棋子的核心属性我定义了一个结构体FPieceData里面放了棋子类型、所属阵营用布尔bIsWhite表示、初始坐标两个整数、以及一个“是否已经移动过”的标记——这个标记对兵的第一步和车的王车易位判断很有用。棋盘本身我没有用真正的二维数组而是用了一个长度为64的一维数组。棋盘数组的每个元素是“这个格子上棋子的引用”。为什么用一维一方面UE蓝图的数组是定长数组一维的索引计算足够简单——GridY * 8 GridX就能定位到某个格子另一方面蓝图里的二维数组操作要多套一层循环写起来非常啰嗦能用一维解决的问题没必要自找麻烦。这个数组是整个项目的“唯一事实来源”。所有棋子生成、走法判断、吃子清理都以这个数组为准场景里的Actor只是它的可视化表现。这个思路很重要我后面吃子的功能能做得那么干净就是因为没有让场景Actor直接管理自己的生死而是统一由棋盘数据层来调度。2.3 Actor生成用数据驱动代替手摆棋子最初我打算直接在关卡里拖32个象棋的Actor出来每个手动设置坐标和类型。但拆解需求时就发现这很蠢后期如果有重置棋局、悔棋、换皮肤的需求手摆的方案完全没法维护。所以我在GameMode或者棋盘的BeginPlay里用一个双重循环去生成棋子。外层是行方向先决定生成什么兵种再决定放哪内层用初始化数组去查位置。具体做法是先声明一个长度为64的枚举类型数组并在编辑器里填好每种棋子的布局再用循环遍历数组遇到不是Empty的索引就在对应世界坐标生成对应类型的蓝图。生成方式上我用的是SpawnActor类引用。我建了一个数据资产或直接在蓝图上设置一个映射EPieceType到“对应Actor的类”。这样循环里根据棋子的类型拿到类引用调用SpawnActorFromClass生成后把FPieceData写进棋子的变量。整个过程完全数据驱动以后想换模型、换规则、重置棋局只需要动数据不用动蓝图逻辑。2.4 复制出来的蓝图变量为什么会丢架构层面的认知转变这一周开始时我犯过一个经典错误和热词里那个“复制出来的蓝图变量丢失了”非常契合。我在场景里摆了一个BP_PawnW右键复制出另一个角色结果发现复制出来的那个变量是空的运行时报错。深入查下来才明白UE里复制Actor得到的副本不会复制“指向场景内其他Actor的硬引用”。你手动在关卡里把一个Actor拖进变量槽位这个引用是保存在关卡实例数据里的CtrlC复制出来的新Actor没有这份上下文变量自然就空了。就算你在Outliner里复制它也只知道“变量引用了一个Actor”但不知道引用的是哪一个。这个坑逼着我重新想了一个问题谁应该持有“棋盘数据”这个核心对象答案是不能让每个棋子自己持有整张棋盘的引用而应该让一个全局的唯一对象——我放在了GameMode上——来持有棋盘数组和当前回合状态。棋子需要查数据时通过GetGameMode拿到引用调用公开函数查询。这样既绕开了复制导致的引用丢失问题也让代码的依赖关系变得清晰棋子不存棋盘棋盘管棋子。这个转变让我的蓝图结构一下子清爽了。后来我又把棋子的初始布局抽成了一个DataAsset棋盘在BeginPlay时从资产里读取布局想换皮肤、换初始排列都不用改蓝图逻辑。3. 规则系统走法合法性、吃子和回合控制如何一步步落地数据结构准备好之后最硬核的部分来了让棋子按规则动。这一块是第二周的主体也是网易公开课们不会细讲的逻辑编织过程必须自己啃。3.1 走法合法性把每个棋子的规则翻译成数组运算我写了一个核心函数叫GetValidTargets输入是一个棋子所在格子的坐标输出是一个“可以走到的格子坐标数组”。这个函数分成两步先根据棋子类型生成所有“可能的目标格”再用统一规则过滤掉非法目标。以车Rook为例方向是上、下、左、右四个方向每个方向最多循环7次每次移动一步。循环里每一步先判断目标是否超出棋盘边界如果越界就停止这个方向然后查看棋盘数组对应索引分成三种情况处理空格可以走、己方棋子挡住停止、对方棋子可以吃但要停止继续前进。马Knight就简单很多直接对8个可能的偏移量做坐标变换只要目标在棋盘内、且目标位置不是己方棋子就加入合法列表不需要做路径检测。王King则是8个方向的单步移动额外加一步“这个格子是否处于对方攻击范围内”的判断——我第二周用了一个简化方案不处理连锁送将只禁止王走到当前局面下被对方棋子直接攻击的格子。想完善将杀判断还需要遍历对方所有棋子的合法走法并取并集嵌套比较多留到第三周补。兵Pawn是特例最多的棋子。白兵向Y轴正向走黑兵向Y轴负向走。前进要分两步第一步走一格如果目标格是空的则合法如果是第一次移动可以尝试走两格但必须保证中间那格和终点格都为空。吃子是斜前方一格且目标格必须是对方棋子。升变我做了处理兵走到最底行直接替换成后——实现方式是销毁当前Actor在目标格Spawn一个Queen的蓝图。3.2 递归与路径检测的蓝图实现技巧路径检测的逻辑在蓝图里最容易写得一团乱麻。我试过直接在GetValidTargets里用大量分支结果节点密密麻麻改一个规则要找半天。后来我换了一种组织方式把“沿指定方向探测”抽成一个独立的函数叫ProbeDirection。输入是当前坐标、方向偏移量、所属阵营输出是一个“可走格子的追加数组”。函数内部用WhileLoop循环每一步在数组里查找目标格上是否有棋子再根据棋子阵营决定是追加、阻断还是吃掉并阻断。车、象、后这三个直线行走的棋子全部复用同一个ProbeDirection只是把方向偏移量参数传成不同组合。这样做的收益非常直观删掉重复代码修改规则时只需要改一个地方而且函数的输入输出都明确其他人看蓝图也能一眼看懂“这个函数负责什么”。这也算是我第一次真正体会到“函数封装”在蓝图里的威力。还有一个小技巧判断“某格子是否有棋子”不建议每次都用GetAllActorsOfClass去遍历场景那样性能差而且容易出错。我全程用棋盘数组来查数组索引对应格子元素非空就代表有棋子。查询是O(1)的逻辑也清晰。场景里的Actor只是显示的皮数据数组才是真正的骨。3.3 玩家交互状态机选中、等待目标、移动中、游戏结束交互逻辑我建立了一个简单的状态机枚举叫EGameState值为WaitingSelect、WaitingTarget、Moving、GameOver。为什么要显式用一个枚举因为有了状态点击事件里就能统一处理如果当前是WaitingSelect点击棋盘时先判断点击的是不是己方棋子是则切换为WaitingTarget如果是WaitingTarget点击空格或对方棋子时执行走棋随后进入Moving移动动画播放完再回到WaitingSelect。这个状态机的代码并不复杂但它是整局游戏不“乱套”的关键。之前我写过一版没有状态的点击逻辑结果回合外也能选子、吃子后会连续触发事件各种边界情况把测试弄得很糟。加了状态枚举之后每个分支的判断条件都很窄维护起来轻松很多。回合控制我用了一个布尔或枚举变量CurrentTurn来表示当前行动方。每次成功走子后切换阵营。这里有个容易被忽略的细节判断“点击的棋子属于谁”要和CurrentTurn做比较不是只要点到棋子就能选那样白方就能控制黑棋了。吃子逻辑同样要判断目标格棋子的阵营己方不能吃对方可以吃。3.4 吃子的实现销毁Actor与存档引用吃子我采用的是先在棋盘数组里把目标格置空然后DestroyActor目标棋子再把当前棋子移动到目标位置更新棋盘数组。有个容易忽略的坑是销毁Actor后如果还有别的引用指向它重开游戏或者做撤销功能时会直接崩掉。所以在吃子之前我先把被吃棋子的数据和引用填进了一个“本局历史”数组。以后要做悔棋功能就可以从数组里取回数据在目标格再生成一个棋子。我还在Projectile对我后来发现象棋项目不需要Projectile这里泛指专门管理棋局的Actor里记录每一步的“棋盘快照”走子前保存整局数组的副本这样悔棋时可以直接恢复整个棋盘。用蓝图数组的复制副本只需要调用一个浅拷贝函数成本很低但带来的收益是撤销功能变得极其简单。4. 关卡落地中的UE5细节输入、射线检测、格子和移动反馈规则层跑通后接下来的工作是把逻辑粘到UE5的关卡交互上。这一部分看似“只是把点击和移动做成动画”实际涉及很多UE5特有的坑。4.1 点击输入的两种方式OnClicked还是PlayerController射线我一开始用的是每个棋子Actor上的OnClicked事件往棋子上挂一个BoxCollision然后启用Input。这种方式写起来最快半个上午就能让棋子被点击。但很快发现几个问题One是必须给每个棋子都挂碰撞体和事件绑定Actor一多就乱Two是点击高亮的棋盘格怎么处理总不能给64个格子都挂事件吧。所以我改成了在PlayerController里统一处理重写PlayerController的Tick或者绑定鼠标按键事件调用GetHitResultUnderCursor做射线检测一次性获取鼠标点中的Actor再根据Actor类型分发处理逻辑。棋盘格子和棋子我都统一实现了一个接口比如GetGridCoordinate()射线检出的Actor只要实现了这个接口就能拿到它的坐标。这种做法的优势在于所有输入逻辑集中在一处不依赖每个Actor各自挂事件新增可交互物体只需要实现同一套接口即可。代价是一开始要多写几行射线检测的样板代码但中后期维护起来舒服太多。4.2 棋盘格的高亮Decal还是子Mesh点击选中棋子后要显示它能走的格子。网上常见的做法有两种一种是给每个格子加一个Decal投影动态更改贴图材质另一种是直接改变格子上静态网格体的材质参数。这两种我试下来都有点麻烦Decal需要单独管理生命周期材质参数替换要考虑动态材质实例的创建。我的方案比较保守每个棋盘格BP_Grid里放一个默认隐藏的子StaticMesh专门负责高亮显示。走法计算完成后调用格子上的接口ToggleHighlight(true)把它的Visibility打开并切换高亮材质。取消选择或走子完成后统一关闭所有高亮。这个方案虽然没有Decal那层“贴地感”精致但胜在稳定不需要处理复杂材质对于第二周的学习项目完全够用。4.3 棋子移动的动画Timeline vs Tick插值棋子从起点到终点如果用SetActorLocation瞬间瞬移玩家几乎看不清“它在动”。我一开始图省事用了Tick里每帧执行Lerp的插值方法结果出现两个问题一是棋子移动到一半如果玩家再点一下会和新动画冲突二是所有棋子在Tick里都做插值计算即使它并没有在移动很浪费。后来换成了Timeline。在蓝图里Timeline是一种可以在指定时间内驱动参数变化的结构非常适合做UI或场景移动。我在“执行走子”的函数里新增一个Timeline0到1秒内输出一个插值Alpha用VectorLerp把起点坐标和终点坐标做插值每帧把结果SetActorLocation给棋子。Timeline播放完自动回调我在回调里把GameState切回WaitingSelect这样可以确保“上一个动作完成前不会触发下一个动作”。还有个小细节SetActorLocation时第三个参数bSweep建议设为true。这样棋子移动过程中如果意外碰到其他物体会触发碰撞事件而不是直接穿模。这一点在多人联机或移动端上尤其明显。4.4 音效和简单反馈用细节提升完成度国际象棋的反馈其实不用做花哨的粒子一个轻微的落子音效就能让手感提升一大截。我在“成功走子”和“吃子”各接了一个PlaySound2D节点从资产里拖入声音文件。选中的提示音和将军提示音也可以各备一个。视觉效果上我只做了两层处理选中棋子时把它的Z轴高度略微抬升一点点通过Offset机制不改变逻辑坐标吃子时在被吃棋子位置播放一个很短的Niagara或Cascade粒子然后延迟0.2秒销毁Actor。如果第二周就深入Niagara会消耗太多精力我这里是直接用引擎自带的默认粒子模板只改了生命周期和颜色效果已经够用了。5. 第二周踩坑清单引用丢失、渲染卡顿和缓存配置的真相这一部分是我最想写给后来者看的。网上教程大多展示的是“成功路径”却很少提那些耗费半天时间的隐性坑。5.1 复制Actor导致变量引用的空指针终极解法和心得前面说过复制蓝图变量丢失的问题这里再深入拆一下。UE里一个Actor蓝图里的“硬引用”变量如果指向的是“某个已放置在关卡中的Actor实例”那么这种引用的持有者是关卡关卡实例数据而不是蓝图的类默认值。当你复制这个Actor时复制品不会继承指向关卡内其他Actor的引用关系所以变量为空。真正的解决方案不是找“怎么复制变量引用”而是从设计上避开硬引用。把跨Actor的数据通信尽量放在全局对象上GameMode、GameState、PlayerController或者用接口调用。比如我现在的项目里棋子不直接引用棋盘而是在需要时用GetGameMode获取棋盘对象再调用棋盘的公开函数。变量引用只存在于一个地方复制Actor时不会再丢。5.2 Tick里做位置计算的性能危机UE5蓝图初学者很容易养成“哪不会就在Tick里轮询”的习惯我第二周也没幸免。移动插值放Tick后虽然动画本身没问题但每次走子后会发现整体帧率偶尔掉到二十几帧而且操作还有延迟。原因就是场景里有几十个Actor都在Tick里做数学运算和数组查询即使没有移动需求也在跑。后来我把所有“需要持续更新”的逻辑尽量改成事件驱动把“需要平滑过渡”的逻辑交给Timeline。蓝图不是不能写循环但你要清楚地知道每个节点在每帧的代价。特别是在移动端上做游戏Tick里做大数组遍历是性能毒药。5.3 渲染内存不足和画面卡顿的处理思路第二周跑象棋项目时我碰到过报错“out of video memory”或者“rendering memory不足”。一开始以为是项目太大后来查了引擎文档和网络才知道UE5默认开启的Lumen、Nanite、VirtualShadowMap对硬件要求很高。象棋这种压根没有大型几何体和高精光照需求的项目完全没必要开全套高画质。解决办法是在项目设置里把渲染相关设置手动调低全局光照改用SSGI或者干脆关闭实时GI阴影改用普通Shadow Map抗锯齿保留TSR或FXAA就好。如果还卡可以用命令行参数加一句r.Shadow.Virtual.Enable0临时关闭虚拟阴影马上就能看到帧率变化。做小项目别被默认的高画质吓住学会做“渲染预算控制”也是UE开发的一部分。5.4 缓存配置文件的版本号问题如果你改过项目设置或插件设置却不生效很可能遇到的就是配置缓存的问题。UE在Saved/Config目录下保存了最新的配置缓存有时候版本号不匹配或者缓存损坏导致你改的DefaultEngine.ini没被读取。稳妥的做法是先把编辑器关掉删除项目下的Saved/Config目录别删默认工程配置文件本身然后重新打开编辑器让它按默认配置重新生成。这样能解决大多数“设置改了没用”的怪现象。顺带提醒UE5的缓存版本号一般和引擎版本强相关不同小版本升级后旧缓存可能不兼容。每升一次版本最好清理一次Saved、Intermediate目录。这不是玄学是引擎工程构建机制决定的。5.5 选UE5.1还是5.4我给新手的版本建议热搜词里有人问“先装低版本还是先装高版本”。我的经验是入门阶段请直接安装当前稳定版大版本的最新小版本比如现在的5.3或5.4稳定版而不要去追Preview版。原因有三点第一教程生态集中在新版本上你用老版本看新功能教程会一脸懵第二父子蓝图的C插件兼容性方面新版本能兼容更多第三跨版本升级蓝图项目虽然损失不大但来回横跳修改配置会消耗大量学习精力。重要的是“锁定一个版本踏踏实实用到底”。蓝图语言本身跨版本变化不大你要熟悉的是编辑器交互和节点用法频繁换版本只会让肌肉记忆一直重置。用到第三周后你自然会对版本的差异有判断力那时候再决定要不要升级不迟。6. 第二周结束的反思从“会拖节点”到“学会设计系统”这一周让我对“用蓝图写游戏”这件事有了完全不同的理解。第一周时我还在到处看节点叫什么名字、拖出来怎么连第二周结束后我已经习惯在动手连节点之前先问自己几个问题数据放哪里状态怎么切事件驱动还是轮询接口怎么定义这些思考习惯的养成比学会任何一个具体节点都值钱。还有一个直接的收获棋盘数组GameMode持有数据的设计让我后来想加“悔棋”“重新开始”“暂停”“继续”这些功能时改动量意外的小。因为数据层和表现层分离后UI按钮只需要调用GameMode的函数表现层统一刷新就可以了。我做“重新开始”按钮只花了一个下午这放在第一周几乎是不可想象的。我用的几个核心设计原则值得复盘逻辑坐标系和世界坐标系分离棋盘数组是唯一数据源。交互逻辑通过事件驱动状态机管理全局行为。跨Actor通信优先走GameMode/接口尽量避免硬引用。移动和过渡效果用Timeline避免Tick里盲目计算。下一步的计划是把将死判断补完整加入简单的AI走子用随机或极小极大算法再把对局记录和悔棋功能完善。人工智能这块我打算用C写核心逻辑、蓝图做表现因为计算量一大纯蓝图会很吃力。第三周到了再分享进展。用了一周和国际象棋死磕我最大的感受是UE5入门不一定要先从大世界的游戏类型开始也不一定要一上来就啃C。用蓝图做一个规则清晰的小项目反而能更快地建立“游戏逻辑到底在引擎里长什么样”的心智模型。如果你也正卡在“学了一堆节点不知道做什么”的阶段不妨跟我一样选一个规则明确的小题材把逻辑和状态啃下来你会有质的飞跃。
返回列表