资讯详情

Unity战棋地图点击实现:坐标转换、寻路与状态机详解

📅 2026/10/15 9:07:36 | 华诺云谱 👁 阅读
Unity战棋地图点击实现:坐标转换、寻路与状态机详解
1. 战棋地图点击的核心逻辑拆解战棋游戏的地图点击表面上看就是“点一下格子角色走过去”但真正动手做过的人都知道这里面藏着一堆需要提前想清楚的问题。我最早做第一个战棋Demo的时候以为用射线检测打一下地面就完事了结果角色能穿墙走、能走到敌人身上、点击UI还能穿透到地图修修补补折腾了快一周才把逻辑理顺。所以这篇内容我打算把整个地图点击的实现思路从头到尾捋一遍包括坐标系转换、寻路接入、点击合法性判断、UI拦截这些环节尽量让刚接触战棋的朋友少走弯路。先明确一下这个内容适合谁看。如果你已经会用Unity的基础操作知道什么是GameObject、Component、Collider也写过简单的C#脚本那这篇内容基本可以无障碍阅读。如果你连Prefab和Instantiate都还没用过建议先把Unity官方的基础教程过一遍再回来不然有些地方可能会卡住。整篇内容围绕一个核心目标展开让玩家点击地图上的某个格子时系统能正确识别是哪个格子、判断能不能走、然后驱动角色沿路径移动过去同时处理好UI遮挡和边界情况。战棋游戏的地图点击和普通RPG的点地移动有本质区别。普通RPG你点哪角色走哪中间撞到东西就绕一下逻辑相对宽松。战棋不一样它是格子驱动的每个格子有明确的坐标、地形属性、占用状态点击的粒度是“格”而不是“点”。这就意味着你不能简单地把鼠标位置转成世界坐标就完事必须把世界坐标映射到逻辑网格坐标上再基于网格数据做判断。这个映射过程是整个系统的地基地基没打好后面寻路、范围显示、技能释放全都会出问题。我见过不少新手在这一步偷懒直接用物理射线打地面Collider拿到碰撞点坐标后除以格子尺寸取整。这个方法在正方形网格、地面平整、相机角度固定的情况下确实能用但一旦地图有高低差、格子不是正方形、或者相机可以旋转就会开始出各种偏移问题。所以我的建议是从一开始就把逻辑坐标和世界坐标的转换封装成独立的工具方法后面不管地图怎么变只改转换逻辑就行不用动上层业务代码。还有一个容易被忽略的点是点击的“时机”。战棋游戏通常有多个状态等待玩家操作、角色移动中、播放攻击动画、敌方回合、结算界面弹出等等。不同状态下点击地图的响应逻辑完全不同。比如角色正在移动时你再点地图应该被忽略敌方回合时玩家点击不应该有任何反应。如果不在代码里做状态机管理就会出现角色移动途中被新点击打断、或者敌方回合玩家还能操作的情况。这个坑我在早期项目里踩过后来加了一个简单的枚举状态机才解决。总结一下这一节的核心地图点击不是单一功能而是一条从输入到逻辑再到表现的完整链路。链路上的每一环都要考虑清楚尤其是坐标转换和状态管理这两个基础环节它们决定了后面所有功能的稳定性。接下来的章节我会逐个拆解每个环节的具体实现。1.1 为什么选择网格坐标而非物理碰撞点很多人第一反应是用Physics.Raycast打地面拿到hit.point直接用。这个方案的问题在于hit.point是一个连续的世界坐标而战棋需要的是离散的格子索引。从连续到离散的转换如果放在业务层做每个用到点击的地方都要写一遍转换代码重复且容易出错。更合理的做法是让点击检测直接输出格子坐标业务层只跟格子坐标打交道。具体来说我会在场景里维护一个二维数组或者字典来存储格子数据每个格子记录它的逻辑坐标row, col、世界坐标中心点、地形类型、占用单位等信息。点击时先用射线拿到世界坐标然后通过一个WorldToGrid方法把世界坐标转成格子坐标再用这个坐标去查格子数据。WorldToGrid的实现取决于你的网格类型正方形网格最简单直接除以格子边长取整就行六边形网格稍微复杂一点需要做六边形的坐标映射但原理是一样的。这样做的另一个好处是格子的世界坐标中心点是预先算好的角色移动时直接往目标格子的中心点走不需要每次从点击位置反推。移动路径上的每个格子也都有明确的世界坐标寻路算法输出的格子序列可以直接转成世界坐标序列交给移动模块。整个数据流是清晰的点击→世界坐标→格子坐标→格子数据→寻路→世界坐标序列→移动。1.2 点击状态机的设计思路状态机这个东西听起来很工程化但实际做起来可以很简单。我通常用一个枚举加一个当前状态变量就够了不需要上什么复杂的状态机框架。枚举里定义几个核心状态Idle等待玩家操作、UnitMoving单位移动中、UnitActing单位执行动作中、EnemyTurn敌方回合、UILockedUI界面打开中。每次点击地图时先检查当前状态只有Idle状态下才处理地图点击其他状态一律忽略。状态切换的时机也很关键。玩家点击一个可到达的格子后状态从Idle切到UnitMoving然后启动移动协程。移动协程结束时如果这个单位还有行动力切回Idle如果没有了切到UnitActing等待玩家选择结束回合或者执行其他操作。敌方回合开始时切到EnemyTurn敌方所有单位行动完毕后切回Idle。UI界面打开时切到UILocked关闭时切回Idle。这个设计的好处是所有跟点击相关的判断都集中在一个地方不会散落在各个脚本里。你只需要在点击处理的入口处加一个状态检查就能避免绝大多数误操作。而且状态切换的时机是明确的调试的时候只要打印当前状态就能快速定位问题出在哪个环节。2. 坐标转换与格子数据结构的实操细节坐标转换是地图点击的地基这一节我把正方形网格和六边形网格的转换方法都讲一下然后说说格子数据结构该怎么设计。如果你做的是正方形网格的战棋可以只看正方形部分如果你做的是六边形网格的两种都建议看一下因为很多思路是相通的。2.1 正方形网格的坐标转换正方形网格是最简单的假设你的格子边长是cellSize地图原点在(0, 0, 0)那么世界坐标转格子坐标的公式就是int col Mathf.FloorToInt(worldPos.x / cellSize); int row Mathf.FloorToInt(worldPos.z / cellSize);反过来格子坐标转世界坐标中心点Vector3 center new Vector3((col 0.5f) * cellSize, 0, (row 0.5f) * cellSize);这里有几个细节要注意。第一Mathf.FloorToInt和Mathf.RoundToInt的区别。Floor是向下取整对于正坐标来说没问题但如果你的地图原点不在(0,0)或者世界坐标可能是负数就要小心了。比如worldPos.x -0.1cellSize 1FloorToInt(-0.1) -1但实际上这个点应该属于第0列如果第0列的范围是-1到0的话。所以更稳妥的做法是先加上一个偏移量确保所有坐标都是正的或者用Mathf.Floor而不是FloorToInt来处理负数。第二格子的中心点计算。我用的是(col 0.5f) * cellSize这样格子的中心正好在格子内部。如果你直接用col * cellSize那算出来的是格子的左下角角色会走到格子边缘而不是中心。这个细节在视觉上差别很大尤其是格子比较大的时候。第三Y轴的处理。战棋地图通常有高低差但逻辑上我们只关心XZ平面的格子坐标。Y轴的高度可以通过格子数据里的height字段来记录移动时角色的Y坐标根据路径上格子的高度做插值。不要把Y轴混进格子坐标的计算里否则地图一有高低差坐标转换就会出错。2.2 六边形网格的坐标转换六边形网格稍微复杂一点因为六边形不能像正方形那样简单地用行列来索引。常见的做法是用轴向坐标axial coordinates或者立方坐标cube coordinates。轴向坐标用两个轴q和r来表示第三个轴s -q - r。这种表示方法的好处是六边形的六个方向可以用固定的坐标偏移来表示寻路和范围计算都很方便。从世界坐标转轴向坐标的公式取决于你的六边形是平顶还是尖顶以及六边形的尺寸。以尖顶六边形为例假设六边形的外接圆半径是size那么float q (Mathf.Sqrt(3f) / 3f * worldPos.x - 1f / 3f * worldPos.z) / size; float r (2f / 3f * worldPos.z) / size;然后需要对q和r做立方坐标的取整确保转换后的坐标落在正确的六边形内。取整的方法是先把q和r转成立方坐标对三个分量分别取整然后修正偏差最大的那个分量使其满足x y z 0的约束。这个过程有点绕但网上有现成的算法我这里给一个简化版的实现Vector3Int CubeRound(float q, float r) { float x q; float z r; float y -x - z; int rx Mathf.RoundToInt(x); int ry Mathf.RoundToInt(y); int rz Mathf.RoundToInt(z); float dx Mathf.Abs(rx - x); float dy Mathf.Abs(ry - y); float dz Mathf.Abs(rz - z); if (dx dy dx dz) rx -ry - rz; else if (dy dz) ry -rx - rz; else rz -rx - ry; return new Vector3Int(rx, ry, rz); }这段代码看起来有点抽象但实际用起来很稳定。我做过一个六边形战棋的Demo用这套转换方法点击精度基本没有出过错。唯一需要注意的是size参数要和你的六边形Prefab的实际尺寸匹配不然会有偏移。2.3 格子数据结构的设计格子数据我通常用一个类来表示包含以下字段字段名类型说明rowint行索引colint列索引worldPosVector3格子中心的世界坐标terrainTypeenum地形类型平地、森林、山地、水域等moveCostint移动消耗occupantUnit当前占据该格子的单位null表示空isPassablebool是否可通行这个结构看起来简单但每个字段都有讲究。terrainType和moveCost是绑定的不同地形有不同的移动消耗比如平地消耗1森林消耗2山地消耗3水域不可通行。occupant用来判断格子是否被占用寻路时如果目标格子有敌方单位通常不能直接走到那个格子而是走到相邻格子后触发攻击。isPassable是一个综合判断可能同时受地形和占用影响我一般用一个方法动态计算而不是存成固定值因为单位移动后占用状态会变。格子数据的存储方式小地图直接用二维数组就行比如20x20的地图GridCell[,] grid new GridCell[20, 20]。大地图或者不规则地图可以用字典key是(row, col)的元组value是格子数据。字典的好处是支持不规则形状坏处是遍历的时候比数组慢一点。我一般地图不超过50x50就用数组超过就用字典。还有一个细节是格子的可视化。我通常会给每个格子生成一个Quad或者Plane作为点击检测的Collider同时用不同的材质颜色表示地形。点击检测的Collider和视觉表现的Mesh可以分开Collider用一个简单的BoxCollider或者MeshCollider就行不需要跟视觉Mesh完全一致。这样性能更好而且点击检测的精度也够用。3. 点击检测与寻路接入的完整实现这一节我把点击检测到寻路接入的完整流程串一遍包括射线检测、格子坐标转换、点击合法性判断、寻路调用、移动执行这几个环节。每个环节我都会给出具体的代码示例和参数说明你可以直接参考着改。3.1 射线检测与UI拦截点击检测的第一步是发射射线。在Unity里从相机发射射线到鼠标位置的标准写法是Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit, 100f, groundLayer)) { // 处理点击 }这里的groundLayer是一个LayerMask只检测地面层避免射线打到角色、特效或者其他无关物体。我一般会把地面格子的Collider单独放在一个Layer里比如叫“Grid”然后在LayerMask里只勾选这一层。UI拦截是另一个必须处理的问题。如果玩家点击的是UI按钮射线不应该穿透到地图上。Unity的EventSystem提供了一个简单的判断方法if (EventSystem.current.IsPointerOverGameObject()) { return; // 点击在UI上忽略地图点击 }这个方法在PC端没问题但在移动端需要注意IsPointerOverGameObject在触摸输入下可能需要传入fingerId。我一般会封装一个统一的输入检测方法同时处理鼠标和触摸避免平台差异导致的问题。还有一个细节是点击的时机。我通常用Input.GetMouseButtonDown(0)而不是GetMouseButton(0)因为战棋游戏只需要响应点击的瞬间不需要按住持续响应。如果用GetMouseButton玩家按住鼠标移动时会不断触发点击导致角色走一步停一步。这个坑我踩过后来改成GetMouseButtonDown就正常了。3.2 点击合法性判断的完整逻辑拿到格子坐标后不能直接就让角色走过去需要做一系列合法性判断。我通常按以下顺序检查格子是否存在坐标是否在地图范围内格子是否可通行地形是否允许格子是否被占用是否有其他单位格子是否在移动范围内根据当前单位的移动力计算格子是否在寻路可达范围内考虑障碍物后的实际可达性前三条是基础检查后两条是战棋特有的逻辑。移动范围的计算方法是从单位所在格子出发做广度优先搜索BFS在移动力耗尽之前能到达的所有格子就是移动范围。这个范围通常会在玩家选中单位后显示出来用不同颜色标记可到达的格子。BFS的实现思路是维护一个队列初始时把单位所在格子入队然后不断出队检查相邻格子如果相邻格子可通行且移动消耗不超过剩余移动力就入队并记录消耗。为了避免重复访问用一个visited集合记录已经处理过的格子。这个算法的时间复杂度是O(n)n是地图格子数对于战棋游戏的地图规模来说完全够用。QueueGridCell queue new QueueGridCell(); DictionaryGridCell, int costSoFar new DictionaryGridCell, int(); queue.Enqueue(startCell); costSoFar[startCell] 0; while (queue.Count 0) { GridCell current queue.Dequeue(); foreach (GridCell neighbor in GetNeighbors(current)) { int newCost costSoFar[current] neighbor.moveCost; if (newCost movePoints neighbor.isPassable !costSoFar.ContainsKey(neighbor)) { costSoFar[neighbor] newCost; queue.Enqueue(neighbor); } } }这段代码输出的costSoFar字典里key是可到达的格子value是到达该格子的移动消耗。点击时只需要检查目标格子是否在这个字典里就能判断是否可到达。3.3 寻路算法的选择与接入移动范围判断的是“能不能到”寻路解决的是“怎么走”。战棋游戏常用的寻路算法是A*因为它在大多数情况下比BFS快而且能找到最优路径。A*的核心是维护一个开放列表和一个关闭列表每次从开放列表里取f值最小的节点扩展直到找到目标或者开放列表为空。A的启发函数heuristic选择很关键。对于正方形网格曼哈顿距离是一个好的启发函数对于六边形网格六边形的距离公式更合适。启发函数必须满足“不高估实际代价”的条件否则A可能找不到最优路径。我一般用曼哈顿距离简单且够用。int Heuristic(GridCell a, GridCell b) { return Mathf.Abs(a.row - b.row) Mathf.Abs(a.col - b.col); }A*的输出是一个格子序列从起点到终点。这个序列需要转成世界坐标序列然后交给移动模块。移动模块通常用一个协程来实现逐格移动每格移动的时间根据移动速度计算。移动过程中要更新角色的朝向让角色面朝移动方向。移动结束后更新格子的占用状态把原格子的occupant设为null新格子的occupant设为当前单位。这里有一个细节是路径的平滑。如果直接逐格移动角色会走成折线看起来比较生硬。我通常会在路径的拐角处做一点平滑处理比如用贝塞尔曲线或者简单的插值让移动看起来更自然。但这个不是必须的看你的项目需求。3.4 移动执行的协程实现移动执行的协程我一般这样写IEnumerator MoveAlongPath(ListVector3 path, float speed) { foreach (Vector3 target in path) { while (Vector3.Distance(transform.position, target) 0.05f) { transform.position Vector3.MoveTowards(transform.position, target, speed * Time.deltaTime); Vector3 dir (target - transform.position).normalized; if (dir ! Vector3.zero) { transform.rotation Quaternion.LookRotation(dir); } yield return null; } transform.position target; } }这段代码的逻辑是遍历路径上的每个目标点用MoveTowards平滑移动过去同时更新朝向。0.05f是一个阈值避免因为浮点数精度问题导致角色永远到不了目标点。移动速度speed可以根据单位类型设置比如步兵慢一点骑兵快一点。移动过程中要禁用玩家的点击输入这就是前面状态机的作用。状态切到UnitMoving后点击处理入口直接return不会响应新的点击。移动结束后再切回Idle玩家才能继续操作。还有一个细节是移动过程中的动画。如果角色有行走动画需要在移动开始时播放移动结束时停止。这个通过Animator的SetBool或者Play方法来实现具体看你的动画系统怎么设计的。4. 常见问题与排查技巧实录这一节我整理了一些在实际开发中经常遇到的问题和解决方法都是我自己踩过的坑希望能帮你节省一些调试时间。4.1 点击偏移与坐标转换错误点击偏移是最常见的问题表现是点击某个格子但系统识别成了相邻的格子。这个问题通常有几个原因格子尺寸和Collider尺寸不匹配、地图原点偏移没有处理、相机角度导致射线检测偏差。排查方法很简单在点击处理里打印出点击的世界坐标和转换后的格子坐标然后在Scene视图里用Gizmos画出格子的边界对比一下就能看出问题在哪。如果是格子尺寸不匹配检查cellSize和Collider的实际尺寸是否一致如果是原点偏移检查WorldToGrid方法里有没有加上偏移量如果是相机角度问题检查射线的最大距离是否够长以及Collider是否被其他物体遮挡。我遇到过一次比较隐蔽的偏移问题原因是格子的Collider比视觉Mesh大了0.1个单位导致点击格子边缘时射线打到了相邻格子的Collider上。后来把Collider尺寸调成和Mesh一致就解决了。所以建议在制作格子Prefab的时候Collider的尺寸要精确匹配视觉尺寸不要凭感觉调。4.2 寻路失败与路径异常寻路失败的表现是点击了可到达的格子但角色不动或者走到一半停住。常见原因有目标格子被标记为不可通行、寻路算法的启发函数不满足条件、路径上的某个格子被动态占用。排查的时候先在寻路算法里打印开放列表和关闭列表的大小如果开放列表很快就空了说明目标不可达检查目标格子的isPassable和occupant。如果开放列表一直很大但找不到目标可能是启发函数有问题检查Heuristic方法是否高估了实际代价。如果路径找到了但角色走到一半停住检查路径上的格子是否在移动过程中被其他单位占用了这种情况需要在移动前锁定路径或者在移动过程中动态调整。我遇到过一次寻路失败是因为地图边界处理有问题地图边缘的格子没有正确初始化导致寻路算法访问越界。后来在地图初始化的时候加了一个边界检查确保所有格子的邻居都在有效范围内问题就解决了。4.3 UI穿透与输入冲突UI穿透的表现是点击UI按钮时地图上的角色也响应了点击。这个问题用EventSystem.current.IsPointerOverGameObject()就能解决但有几个细节要注意。第一这个方法在Update里调用时如果UI的响应时机和地图点击的响应时机不一致可能会判断错误。我一般把地图点击的处理放在LateUpdate里确保UI先处理完输入。第二移动端的触摸输入需要传入fingerId不能直接用默认参数。第三如果UI上有透明区域IsPointerOverGameObject可能会返回false导致穿透。这种情况需要在UI的Image组件上勾选Raycast Target确保透明区域也能拦截点击。还有一个输入冲突是快捷键和点击的冲突。比如玩家按了数字键选择技能然后又点击了地图这时候应该执行技能而不是移动。这种冲突需要在状态机里增加一个“技能选择中”的状态点击地图时根据当前状态决定是执行技能还是移动。4.4 性能问题与优化建议战棋地图的格子数量可能很多如果每个格子都挂一个脚本性能会受影响。我通常的做法是格子只挂一个Collider点击检测统一由一个管理器处理不需要每个格子单独挂脚本。管理器在初始化时生成所有格子的数据点击时通过坐标转换找到对应的格子数据这样性能好很多。另一个性能问题是射线检测的频率。如果每帧都发射射线在格子很多的时候会有开销。我一般只在鼠标点击的瞬间发射射线不需要每帧检测。如果要做鼠标悬停高亮可以用一个单独的悬停检测频率可以降低到每0.1秒一次不需要每帧。还有一个优化点是格子的渲染。如果格子数量很多可以考虑用GPU Instancing或者合并Mesh来减少Draw Call。但这个属于进阶优化在项目初期不用太在意等性能真的成为瓶颈了再处理。4.5 常见问题速查表问题现象可能原因排查方法解决方案点击偏移格子尺寸不匹配打印世界坐标和格子坐标对比调整cellSize或Collider尺寸点击无响应射线被遮挡检查LayerMask和Collider调整Layer或Collider位置UI穿透未拦截UI点击检查IsPointerOverGameObject在点击处理入口加UI判断寻路失败目标不可达打印开放列表大小检查isPassable和occupant角色走到一半停住路径被动态占用检查路径上的格子状态移动前锁定路径或动态调整移动过程中点击打断状态机未生效打印当前状态在点击入口加状态检查移动动画不播放Animator未触发检查Animator参数在移动协程里控制动画播放格子高亮不消失高亮状态未重置检查高亮逻辑在状态切换时重置高亮这个表格我建议你在开发过程中随时对照遇到问题先查表能省不少时间。当然实际项目中的问题可能更复杂但大多数都能归到这几类里。4.6 一些实操心得最后分享几个我在实际项目中总结的小技巧。第一格子的世界坐标中心点最好在初始化时就计算好并缓存起来不要每次点击都重新计算尤其是六边形网格的坐标转换计算量不小。第二寻路算法的开放列表用优先队列PriorityQueue而不是普通列表性能会好很多尤其是地图大的时候。第三移动协程里加一个超时保护如果角色因为某种原因卡住了超过一定时间自动结束移动避免游戏卡死。第四点击检测的射线距离不要设太大够用就行太大会检测到远处的无关物体。第五测试的时候多试试地图边缘和角落的格子这些地方最容易出边界问题。还有一个经验是战棋游戏的点击逻辑最好和表现逻辑分离。点击处理只负责输出“玩家想移动到哪个格子”具体的移动、动画、音效由单独的模块处理。这样后面要改表现效果的时候不用动点击逻辑改起来更安全。我在早期项目里把点击和移动写在一起后来想加一个移动轨迹特效结果发现要改的地方太多最后不得不重构。所以从一开始就做好分层后面会省很多事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑