ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Cocos Creator实现SLG六边形大地图:从坐标系到寻路

Cocos Creator实现SLG六边形大地图:从坐标系到寻路 1. 项目概述六边形大地图的本质与价值做SLG游戏研发的人多少都研究过《三国志战略版》那种一眼望不到边的六边形大地图。城池、关隘、山脉、河流、平原全部铺在六边形网格上玩家拖动屏幕、缩放视角、点选地块、行军出征手感顺滑且信息密度极高。这套地图系统不仅是美术层面的展示更是整个游戏玩法逻辑的承重墙——地块归属、行军路径、建筑选址、资源产出、战斗判定全部依赖底层的地图数据结构。用Cocos Creator来实现这样一套六边形大地图我做过几次迭代从最初纯美术摆件到后面接玩法逻辑踩了不少坑。这篇文章就把整个实现过程拆开讲清楚适合已经熟悉Cocos Creator基础操作、想挑战SLG大地图的开发者阅读也适合那些把“六边形网格”当成一个神秘黑盒、想弄明白底层原理的人——其实拆穿之后核心就是一套坐标系转换加网格布局算法并不玄乎。先说清楚这套系统到底做什么它能生成指定大小的六边形棋盘支持地块类型定义平地、山地、水域、城池等支持地块状态查询与修改支持玩家点击选中地块、显示地块详情支持行军路径的网格寻路与动画表现。也就是说从“显示一张静态地图”到“地图成为可交互玩法载体”的关键环节全部覆盖到。1.1 为什么选择六边形网格而非四边形网格做SLG大地图第一道选择题就是网格形状。四边形网格开发简单、直觉清晰市面上不少休闲策略游戏用的就是四边形但SLG品类普遍偏爱六边形原因有三个。第一是移动距离的均匀性。六边形的中心到六个邻居中心的距离全部相等而四边形的斜向邻居距离是横向邻居的根号2倍这意味着走斜线比走直线“更远”。在行军、侦察、攻击范围这类需要精确计算格数的玩法里六边形不会出现“同样步数却走出不同距离”的违和感。第二是视觉自然度。六边形拼合之后接近蜂窝结构边界曲折度适中比正方形的硬线条更贴合自然地形。山川河流的走向在六边形地图上表现得更流畅城池地块也更有“疆域感”。第三是策略丰富度。每个格子有六个邻居比四边形的四方向或者带斜角的八方向更容易设计出有层次的战场纵深。三国志战略版里那种“一夫当关”的地形卡位、阵型侧翼、夹击判定六边形的六个方向提供了更细腻的表达空间。如果项目对网格精度要求不高、开发周期极紧四边形当然是性价比之选。但只要想做有深度的SLG玩法六边形网格的投入就值得。2. 坐标系选择与网格生成原理六边形地图第一道坎就是坐标系。很多新手一上来就想着画六个三角形拼成六边形然后靠屏幕坐标硬摆位置结果一拖动地图就乱套。正确做法是先建立干净的逻辑坐标系再做屏幕映射。2.1 三种常见坐标系对比六边形网格常用的坐标系有三种偏移坐标Offset、立方体坐标Cube、轴向坐标Axial。偏移坐标是最接近屏幕直觉的分奇行偏移和偶行偏移两类行号变化时列号需要加个偏移量。存储上就是行列两个整数但做邻居查找、距离计算时数学表达式诡异很容易写出要枚举六种情况的代码。立方体坐标是把六边形网格想象成三维空间里一个平面的投影每个格子用(x, y, z)三个值表示且满足x y z 0的约束。它的最大好处是邻居计算和距离计算极其对称六个方向就是六个固定的三维向量加减法搞定一切。轴向坐标是立方体坐标去掉一维后的简化版保留x和y两个值逻辑上继承对称性存储上只有两个整数是实际开发中使用频率最高的方案。我最终选择的组合是逻辑层用轴向坐标表现层用偏移坐标两者之间做一层转换。这样既保证了玩法算法的干净又不会让美术同学配置地块时对着负数坐标发懵。2.2 轴向坐标到屏幕坐标的数学推导轴向坐标下约定r方向为右下斜向q方向为水平向右不同资料有不同定义自己体系统一就行。一个格子的中心位置可以用两条基向量叠加计算。设定六边形的外接圆半径为size则水平方向的基向量为(size * 1.5, size * 0.866)斜向的基向量为(0, size * 1.732)。其中0.866是cos(30度)的值1.732是根号3的近似值。所以从轴向坐标(q, r)转到屏幕坐标的公式为posX size * 1.5 * q posY size * 1.732 * (r q / 2)注意图示和代码实现中Y轴可能出现方向相反的问题。Cocos Creator的2D坐标系Y轴向上这正好和多数六边形算法教程里的世界坐标系一致。如果你用的是某些默认Y轴向下的引擎或自绘图需要做一次镜像。反过来从屏幕坐标反推轴向坐标是寻路、点击检测等交互操作的基石。公式如下q (sqrt(3)/3 * posX - 1/3 * posY) / size r (2/3 * posY) / size算出来的q和r是浮点数需要四舍五入到最近的整数格。但直接四舍五入在某些边缘点上会选错邻居稳妥做法是把浮点坐标转成立方体坐标再做四舍五入然后修正约束条件。这个细节我在第一版代码里没注意导致点击边界地带时偶发选中错误地块玩家反馈“点不准”后来换成这个方案才稳定。注意无论采用哪种坐标系最重要的是全局统一。混用坐标系而不做转换层最终一定会在寻路算法或地块查询阶段爆发诡异bug。2.3 网格生成的Cocos实现生成一个完整地图本质上就是遍历所有合法坐标为每个格子创建节点并摆放到位。以三国志战略版的经典尺寸为参照一张主地图通常上百列上百行直接生成上万个节点会让场景树爆炸。我在第一版就吃了这个亏场景树几万个节点编辑器都卡。所以正确做法是先按区块加载或者只生成可见区。但为了让读者能先在本地跑通先讲全量生成的实现逻辑后面只有在性能优化部分才展开分区块方案。下面是用TypeScript实现的网格生成代码。这个版本先采用偏移坐标存储因为对美术配置最友好import { _decorator, Component, Node, instantiate, Prefab, Vec3 } from cc; const { ccclass, property } _decorator; ccclass(HexGridGenerator) export class HexGridGenerator extends Component { property(Prefab) hexPrefab: Prefab null; property mapWidth: number 60; property mapHeight: number 40; property hexRadius: number 32; private gridMap: Mapstring, Node new Map(); start() { this.generateGrid(); } generateGrid() { for (let row 0; row this.mapHeight; row) { let offsetX 0; if (row % 2 1) { offsetX this.hexRadius * 0.866; } for (let col 0; col this.mapWidth; col) { const posX col * this.hexRadius * 1.5 offsetX; const posY -row * this.hexRadius * 1.732; const cellNode instantiate(this.hexPrefab); cellNode.setParent(this.node); cellNode.setPosition(new Vec3(posX, posY, 0)); const key ${row}_${col}; this.gridMap.set(key, cellNode); cellNode.name hex_${row}_${col}; } } } }这段代码有个关键点offsetX只在奇数行生效这正是六边形交错排列的核心。如果省略这行六边形会全部对齐成矩形阵列邻居关系直接错乱。第二点是posY用了负号因为Cocos的Y轴向上而我习惯让地图向下延伸。另外有一个容易被忽略的事情——六边形Prefab的锚点必须设置在正中心。Cocos默认精灵锚点是(0.5, 0.5)但如果美术给的六边形资源带边框偏移锚点不居中整张地图的网格定位就会跟着漂移而且越靠边缘越明显。最稳的做法是在资源导入时直接检查锚点位置或是在代码里统一修正子节点的本地偏移。2.4 地块类型的渲染方案大地图的地块不是千篇一律的纯色六边形。有草地、森林、沙漠、山地、水域还有城池、关隘这类特殊建筑地块。直接为每种类型准备一套独立的Prefab是最直观的思路但资源浪费极大——如果地图有十种地块类型每个地块节点都挂一个包含十种素材的脚本内存开销看着就心疼。更合理的方案是地块节点只保留一份基础框架通过更换子节点纹理或SpriteFrame来表现不同地块类型。基础框架包含一个底图Sprite一个可选的地形装饰层一个地块遮罩用于高亮或暗化一个文本Label显示坐标或资源数值。类型切换时只替换SpriteFrame这样地图生成时就可以流畅地批量设置地块属性。地块数据用枚举定义export enum HexTerrainType { Plain 0, Forest 1, Mountain 2, Water 3, City 4, Road 5 }每个地块的逻辑数据挂在ScriptAsset里不挂在运行节点上。这一点非常重要——游戏运行中地块状态会频繁变化比如被攻打后从繁荣变破败如果把状态存在节点组件里遍历查找会很慢而且节点一销毁数据就丢。正确结构是单独维护一个二维网格数据数组节点只是数据的可视化映射。3. 地块数据组织与算法核心地图的网格生成只是第一步真正支撑玩法的是地块数据的组织和相关系列的算法。比如玩家点击一块地要能立刻知道围绕它的六块邻居分别是什么、距离目标地块要经过多少格子、路径怎么走才最优。3.1 基于立方体坐标的邻居与距离算法我之前提到逻辑层推荐轴向坐标但轴向坐标的邻居计算依然有六个方向需要记忆。如果直接使用立方体坐标六个方向就是固定的三维向量const cubeDirections [ { x: 1, y: -1, z: 0 }, { x: 1, y: 0, z: -1 }, { x: 0, y: 1, z: -1 }, { x: -1, y: 1, z: 0 }, { x: -1, y: 0, z: 1 }, { x: 0, y: -1, z: 1 } ];只要把坐标转成立方体形式加一个方向向量再转回来就拿到了某个格子的指定邻居。不需要任何if分支判断奇偶行这是立方体坐标最舒服的地方。距离计算也极简单。两个立方体坐标之差取绝对值的最大值就是网格距离function cubeDistance(a: {x: number, y: number, z: number}, b: {x: number, y: number, z: number}) { return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z)); }比如两个格子的立方体坐标差是(3, 1, 4)那距离就是4格。这比偏移坐标的曼哈顿距离A*变体要清晰得多。3.2 A*寻路在六边形地图上的应用SLG大地图行军必须走可通行地块不能直接直线穿山过河。所以要用A*寻路算法在网格图上搜索路径。A*的估价函数在六边形网格上没有本质区别启发函数直接使用立方体距离也就是之前写的cubeDistance。通行性判断则依赖于地块阻挡表——哪些地形可以走哪些不能走function isWalkable(terrain: HexTerrainType): boolean { return terrain ! HexTerrainType.Water terrain ! HexTerrainType.Mountain; }寻路算法本身用二叉堆做Open List优化。小型地图用线性扫也行但大图上Open List可能堆到几千个节点线性查找的复杂度不可接受。我这里直接用Cocos自带或第三方库的优先队列避免重复造轮子。实现一个简化版A*作为参考function findPath(start: CubeCoord, goal: CubeCoord, walkableSet: Setstring): CubeCoord[] { interface Node { coord: CubeCoord; g: number; h: number; parent: Node | null; } const key (c: CubeCoord) ${c.x}_${c.y}_${c.z}; const openList: Node[] []; const closedSet new Setstring(); const startNode: Node { coord: start, g: 0, h: cubeDistance(start, goal), parent: null }; openList.push(startNode); while (openList.length 0) { openList.sort((a, b) (a.g a.h) - (b.g b.h)); const current openList.shift()!; const currentKey key(current.coord); if (currentKey key(goal)) { const path: CubeCoord[] []; let node: Node | null current; while (node) { path.push(node.coord); node node.parent; } return path.reverse(); } closedSet.add(currentKey); for (const dir of cubeDirections) { const neighborCoord { x: current.coord.x dir.x, y: current.coord.y dir.y, z: current.coord.z dir.z }; const neighborKey key(neighborCoord); if (!walkableSet.has(neighborKey) || closedSet.has(neighborKey)) { continue; } const gScore current.g 1; const existingIndex openList.findIndex(n key(n.coord) neighborKey); if (existingIndex 0) { const existingNode openList[existingIndex]; if (gScore existingNode.g) { existingNode.g gScore; existingNode.parent current; } } else { openList.push({ coord: neighborCoord, g: gScore, h: cubeDistance(neighborCoord, goal), parent: current }); } } } return []; }上面代码为了可读性使用sort作为优先队列真实代码里换二叉堆会快很多。在几百列的地图上一次寻路用二叉堆大概几毫秒用sort大概几十毫秒——单次看都能忍但SLG客户端经常同时触发多个单位的自动寻路差距就体现出来了。3.3 地图坐标转换统一接口我前面说过逻辑层和表现层用不同坐标系这就需要一个转换层统一对外接口。地块数据存取只用偏移坐标(row, col)所有算法函数内部先把偏移转成轴向或立方体算完再转回来。对外统一封装成hexUtils工具模块export function offsetToCube(row: number, col: number): CubeCoord; export function cubeToOffset(cube: CubeCoord): { row: number, col: number }; export function hexDistanceByOffset(row1: number, col1: number, row2: number, col2: number): number; export function getNeighborByOffset(row: number, col: number, direction: number): { row: number, col: number };这样上层业务代码不需要知道内部坐标体系只需要处理row和col两个整数。后续如果要把地图改成四边形的只需要改这个工具模块的内部实现所有业务调用几乎无需变动。我早期没做这一层抽象直接在业务代码里到处用轴向坐标结果美术配置表全用(row, col)两端对接时经常弄混行列顺序修bug修到头大。后来统一规范一切跨层访问必须通过这些接口函数整个项目的地图代码立刻清爽了。4. 交互点击、框选与地图拖动地图生成完毕、算法到位之后就要面对玩家实际手上的操作了。SLG大地图的交互体感直接决定游戏品质如果点击没反应、拖动不跟手、框选误触再好看的地图也很难留住玩家。4.1 点击地块的事件捕获与坐标反算Cocos Creator的节点事件系统里给每个地块节点挂一个点击事件是可以的代价是上万个节点各自监听touch事件性能损耗和事件管理复杂度都会上升。更合理的做法是在地图容器节点上统一监听触摸事件通过触摸位置的屏幕坐标反算地块坐标。核心流程包括触摸事件拿到UI坐标转成世界坐标世界坐标减去地图原点坐标得到相对地图的坐标传入轴向坐标反算函数四舍五入得到地块坐标查找对应地块数据触发选中事件。如果地图节点自身有缩放或平移过这四个步骤里的坐标转换一定要用Cocos的UITransform转换API不能自己拿节点位置做加减否则地图一拖动就点不准。我给地图容器挂一个HexInputHandler组件统一管理点击和拖动ccclass(HexInputHandler) export class HexInputHandler extends Component { property(HexGridGenerator) gridGenerator: HexGridGenerator null; private isDragging false; private dragThreshold 5; private startTouchPos: Vec2 null; onTouchStart(event: EventTouch) { this.startTouchPos event.getUILocation(); this.isDragging false; } onTouchMove(event: EventTouch) { if (!this.startTouchPos) return; const currentPos event.getUILocation(); const delta currentPos.clone().subtract(this.startTouchPos); if (!this.isDragging delta.length() this.dragThreshold) { this.isDragging true; } if (this.isDragging) { this.node.setPosition(this.node.position.clone().add( new Vec3(delta.x, delta.y, 0) )); this.startTouchPos.set(currentPos); } } onTouchEnd(event: EventTouch) { if (!this.isDragging) { const uiPos event.getUILocation(); const localPos this.node.getComponent(UITransform) .convertToNodeSpaceAR(new Vec3(uiPos.x, uiPos.y, 0)); const gridData this.gridGenerator.pixelToHex(localPos.x, localPos.y); if (gridData) { // 触发地块选中事件 this.node.emit(hex-selected, gridData); } } this.isDragging false; this.startTouchPos null; } }关键在于拖动和点击的判断阈值。拖动阈值太小玩家的手指轻微晃动就被当成拖动点选永远点不中阈值太大快速拖动时响应迟滞。经过反复测试5像素是一个比较平衡的值。4.2 六边形地块高亮与选中表现选中地块之后要给玩家明确反馈。三国志战略版的风格是选中地块后边缘亮起金色或白色描边周围地块轻微压暗。我实现的方式是给每个地块节点额外挂一个高亮子节点默认隐藏。选中地块时显示高亮并隐藏上一个选中的高亮。为了避免遍历全地图查找高亮状态我用一个currentSelected字段记录当前选中的地块节点切换时只操作两个节点。另一种常见反馈是路径预览——玩家选中部队再点击目标地块地图上画出可通行的行军路径。表现上就是在地块中心点放置小的圆点或箭头贴图按路径顺序依次摆放。这里有个细节如果路径很长路径节点数量可能上百个每个都是一个Sprite节点数量可观。我的处理是对象池预先创建100个路径节点超过100的路径就用分段箭头表示方向不显示每格路径。4.3 相机缩放与边缘回弹大地图必然要支持缩放。Cocos中缩放相机或缩放地图容器都可以我选择缩放地图容器节点因为相机的正交大小调整会影响所有UI层除非所有UI都挂在独立Canvas上否则容易出现字体模糊和UI错位。缩放的实现逻辑是监听鼠标滚轮或双指缩放手势以当前鼠标位置为缩放中心点使缩放的视觉中心保持在光标下onScroll(event: EventMouse) { const delta event.getScrollY(); const factor delta 0 ? 1.1 : 0.9; const worldPos event.getUILocation(); const localPos this.node.getComponent(UITransform) .convertToNodeSpaceAR(new Vec3(worldPos.x, worldPos.y, 0)); const curScale this.node.scale.x; const newScale Math.min(maxScale, Math.max(minScale, curScale * factor)); const scaleRatio newScale / curScale; const offsetX localPos.x * (1 - scaleRatio); const offsetY localPos.y * (1 - scaleRatio); this.node.setScale(newScale); this.node.setPosition( this.node.position.x offsetX, this.node.position.y offsetY ); }这里有个容易踩的坑localPos必须用缩放前的屏幕坐标转换不能在缩放后重新取节点位置算偏移否则会积累误差导致缩放中心点逐渐漂移。边缘回弹就不赘述了本质是限制地图容器的position范围。注意边界值要根据地图尺寸、容器尺寸和缩放倍数综合计算不能写死。5. 寻路与行军表现的完整串联到这一步核心玩法就可以跑通了。我给一个实际场景玩家点击一个部队驻守的城池地块再点击一个远处目标地块系统判断部队能否到达、计算路径、并让一个行军单位的节点沿路径移动过去。5.1 行军路径计算与坐标系还原部队所在城池记录在地块数据中。当玩家点击目标地块时先用之前封装的距离函数计算两地块之间的网格距离超过部队最大行动力就直接提示“距离过远”。通过A*找到路径后路径上是一串立方体坐标。渲染节点之前要把每个立方体坐标转成偏移坐标再转成屏幕坐标。屏幕坐标转完后用一个Tween按顺序做节点移动。这里有个额外的寻路优化三国志战略版的路径不一定完全按照格心连线走直线而是会尽量贴合“道路”地块因为道路移动消耗更低。这个效果通过给每种地块设置不同移动消耗值、寻路时把gScore累加消耗值来实现。路口、桥梁等地块的消耗为0或0.5平地为1森林为2山地禁止通行。玩家肉眼看到的结果就是部队会自动走大路、绕山林非常符合SLG的直觉。5.2 行军节点移动的实现使用Cocos的tween系统逐个移动节点到路径点。这里不建议用setPosition硬跳加插值因为节点多时tween链式嵌套很难管理取消移动时容易留下半截tween状态。我采用的方案是每个部队节点维护一个待走队列每帧按固定速度移动update(deltaTime: number) { if (this.pathQueue.length 0) return; const target this.pathQueue[0]; const currentPos this.node.position; const targetPos target.position; const dir targetPos.clone().subtract(currentPos); const distance dir.length(); const moveStep this.moveSpeed * deltaTime; if (distance moveStep) { this.node.setPosition(targetPos); this.pathQueue.shift(); if (this.pathQueue.length 0) { this.onArrive(); } return; } dir.normalize(); this.node.setPosition(currentPos.clone().add(dir.multiplyScalar(moveStep))); }移动速度的单位是像素每秒需要根据地格外接圆半径来标定。例如外接圆半径32像素一格的实际宽度约48像素那么部队速度如果定在120像素每秒大约相当于每2.5秒过一格。三国志战略版中部队行军有急行和普通之分按下加速按钮时速度可以翻几倍直接用这个变量控制就行。5.3 军队单位与地块归属逻辑行军节点到达目标地块后需要更新地块归属。地块数据里维护ownerId字段初始为空部队到达后写入部队所属玩家ID。地块归属对城池类型尤为重要因为地图上的资源收益、视野范围、出兵点全部围绕归属判断展开。在我的实现里领土扩张的逻辑简单粗暴部队到达目标地块如果地块属于无主状态且不越过国界限制这块地就归部队所属方如果是敌对方的地则触发战斗流程。要做成三战那种完整的内政玩法还有很远的距离但从地块数据结构来说ownerId字段具备扩展所有玩法的基础能力。5.4 箭头指引与连续行军玩家在三国志战略版中常看到行军路线是带有箭头的虚线路径。实现这个效果的常见方式是沿路径每间隔一段距离放置一个箭头Sprite箭头方向与当前段路径方向一致。方向计算就是当前点到下一个点的向量把Sprite的rotation设置成该向量的夹角即可。多段连续行军——比如玩家设定了途经多个标记点的路线——本质就是把多段路径拼接成一个大队列部队走完一段自动加载下一段。注意每段路径的连接点要消除重复节点避免部队在某格停留一帧造成视觉停顿。6. 性能优化与大地图分块加载策略地图从40x30的小演示图扩到160x120的大世界图节点数量会从1200暴增到19200。全量生成在PC编辑器上还能跑手机上大概率直接闪退。所以性能优化这一步躲不掉得从生成和渲染两个维度入手。6.1 对象池复用地图滚动、地块状态变化时会频繁创建和销毁地块节点或特效节点。Cocos Creator内置的NodePool用起来很简单在初始化时预创建一批节点需要时从池中取出用完归还。它解决的问题是避免每个地块都走一遍instantiate流程。instantiate确实会比直接从池里拿慢一个数量级。在移动端上慢就是卡卡就是玩家流失。6.2 可视区域裁剪大地图实际出现在屏幕上的地块数量很少。以1080p手机屏幕为例六边形半径32像素时屏幕大约只容纳40-50个地块。如果地图有一万多个地块节点而屏幕只显示50个剩下九千多个节点纯属浪费。裁剪逻辑是根据屏幕可视范围的四角坐标反算出覆盖的地块行列范围只实例化这个范围内的地块节点滚动时动态增删边缘节点。这个实现的关键是缩小时能处理跨页加载的连贯性避免地图滚动时出现边缘空白。我实现了一个简化版的分块加载updateVisibleCells() { const visibleRect this.getVisibleHexRange(); const newVisibleKeys new Setstring(); for (let row visibleRect.minRow; row visibleRect.maxRow; row) { for (let col visibleRect.minCol; col visibleRect.maxCol; col) { const key ${row}_${col}; newVisibleKeys.add(key); if (!this.gridMap.has(key)) { this.loadCell(row, col); } } } for (const [key, node] of this.gridMap) { if (!newVisibleKeys.has(key)) { this.gridMap.delete(key); this.recycleCell(key, node); } } }这段代码放在地图容器的update里每帧调用或每若干帧调用。需要注意移动速度极快、或玩家快速缩放时visibleRect的扩张速度要留有余量比如在计算结果上扩展两格以免玩家看到弹现的边缘。6.3 合并渲染批次六边形底图大量使用同一张纹理时可以开启Cocos的自动合图。具体做法是把所有地块底图纹理打包进同一个图集节点渲染使用同一个材质这样Cocos的2D渲染器能自动合并渲染批次。实际效果上40个可见地块如果全部同一纹理同一材质渲染批次可以合并到极低数量。但如果地块类型多样纹理至少五六种批次就会成倍增加。优化方向是按地块类型分组渲染同一类型的相邻单元格天然可以合并批次。另外阴影、高亮、路径箭头这些叠加特效节点尽量使用不加光照的简单Sprite材质避免引入透明排序问题。透明物体的渲染排序会打断合批两三个重叠的透明特效可能就让批次从几十飙升到几百。6.4 懒加载与预加载的配合玩家打开大地图的第一眼需要看到完整地图框架全部地块可以只显示底图色彩不加载详细装饰。当玩家拉近缩放时再动态加载装饰细节层。这种两级加载策略适合地图元素多、文件体量大的情况。我实际用的思路是地块节点只挂一个底图Sprite和地块类型数据装饰元素树、石头、房屋模型由装饰管理器统一管理装饰节点放在一个单独的DecorationLayer容器下只有当地块处于近距离范围内才创建。这样远景时地图干净流畅拉近时才丰富起来视觉体感并不突兀。7. 常见问题与排查技巧实录最终这节说几个真实开发过程中踩过的坑。问题不难但排查过程周折写出来可以帮后来者省几天时间。7.1 坐标错位、格子偏移的排查现象是地图上一列列六边形歪歪扭扭或者部分行列重叠。排查思路先确认Prefab锚点是否居中。锚点偏移时代码里计算的是中心坐标实际渲染的却是偏移后的位置奇数行尤其明显。可以用Cocos编辑器打开六边形Prefab选中根节点看锚点数值。如果锚点正确再查offsetX的计算。奇偶行交替偏移时偏移值必须等于六边形内切圆半径的一半区域也就是size * cos(30度)约0.866倍。我见过有人拿外接圆半径代替内切圆半径做偏移结果偏移量过大格子之间出现明显缝隙或重叠。最后再确认行方向是Y正还是Y负。不同教材里六边形网格Y轴方向定义容易混如果代码里用了别人的坐标转换公式但自己的Y轴是反的整个地图看起来像被压缩或拉伸了。用一对坐标手动计算比如(q2, r3)的中心位置再对比实际节点位置就能快速定位。7.2 寻路卡死或路径不合理的排查A*寻路最怕两种表现。一种是指向目标永远找不到路径表现为队伍一直原地转圈另一种是走出来的路径明显绕路。第一种情况优先检查阻挡表。如果目标地块本身被标成不可通行A*一开始就走不到。三国志战略版里军队不能进入敌方城池所在格这类规则要放在walkableSet判断时统一处理。另外检查起点地块如果起点就是不可通行的同样会导致死循环或空路径。第二种情况大概率是启发函数设置问题。如果h值大于真实距离A*会变成贪心算法容易绕进死胡同。正确做法是h 立方体距离真实距离严格等于立方体距离启发值不会高估。如果用了曼哈顿距离那在六边形上就会高估路径就会变差。我最初犯的就是拿四边形的曼哈顿距离套六边形路径真的歪七扭八。7.3 点击地块不准的排查点击不准通常不是事件问题而是坐标反算的精度问题。先检查反算时用的是不是缩放后的节点坐标。如果地图容器缩放为1.2倍反算公式仍然按照1.0倍计算点击点会集体偏移约20%。oK的指标是第一下点击选中正确地块如果在边缘地块反复测试仍偏移基本就是这个原因。其次是浮点数四舍五入的问题。之前明确说过轴向坐标反算出的浮点数不能直接四舍五入到行列而应该先转立方体坐标再圆整。边缘地带的浮点坐标在几个格子的边界线上直接四舍五入很容易选到错误格子。7.4 大地图滚动掉帧的排查滚动掉帧大概率是节点过多。先看看场景树里总节点数如果超过几千检查可视区域裁剪是否生效。注意一个问题裁剪逻辑不能只在地图停止滚动时执行每帧滚动过程中都要执行否则快速拖动时边缘空白会非常明显。如果裁剪没生效再查渲染批次。打开Cocos的Stats面板看DrawCall数量。如果DrawCall在几百级别多半是透明特效节点或独立纹理造成的。把纹理合进图集、统一材质后DrawCall应该能压到几十。还有一类情况是主线程CPU占用过高这通常来自路径计算或坐标转换里的巨大循环。找到热点函数把sort替换成二叉堆或者对计算结果做缓存重复点击同一目标时不用重新计算。8. 扩展从静态地图到完整SLG玩法框架完成基础六边形地图之后可以往上叠加更多玩法。这个部分给出一些后续扩展的方向不写完整代码但把关键思路摆出来。8.1 视野迷雾系统SLG大地图最大的体验点之一是战争迷雾。玩家只能看到己方城池和部队周围一定半径内的地块详情远方地形模糊或不可见。实现思路是每块地维护一个visibility状态可见、探索过、未探索。地块渲染时根据状态选择不同纹理的遮罩层——未探索的格子只显示深色底图探索过但当前不可见的格子显示灰度地形。视野更新的触发源是城池和部队的视野半径。部队移动时沿路径每一格执行一次“点亮半径范围内地块”的遍历。注意这个遍历要在路径计算之后执行不能在移动过程中逐帧遍历否则每帧都要处理上百个地块的可见性更新帧率会掉。迷雾数据同样放在地块数据数组里不依赖节点。这样当区域裁剪重新加载地块节点时迷雾状态依然正确。8.2 城池建筑与地块绑定城池不是一个简单的地块而是一个包含多个建筑槽的复杂地块。落地做法是地块数据里增加一个cityData字段记录建筑列表、等级、当前升级进度等。地块节点在渲染时根据cityData生成对应的建筑子节点。城池的升级、拆除、重建都通过修改cityData并刷新节点表现来完成渲染层和生产数据层完全解耦。这样城池建筑类型再多地图底层逻辑也不会失控。8.3 多军团协同与战斗表现有多个部队单位后需要一套部队调度系统。每只部队是一个Unit节点记录所属地块位置、目标地块、行军路径、当前状态。多个军团并行行动时更新逻辑循环遍历所有部队节点每个部队独立走自己的路径队列。战斗表现方面两支部队在同一地块相遇时可以播放一个战斗动画。前期最稳的表现方式是生成一个战斗特效Prefab播放完成后销毁本质上不改变地块归属逻辑。等战斗系统成熟了再把战斗结算结果写回地块ownerId。8.4 移动端的多点触控适配SLG大地图在手机上常需要双指缩放和单指拖动的组合手势。Cocos的EventTouch支持多点触控但代码里必须区分主手指和辅助手势。我的做法是触摸开始时记录第一根手指为位移操作检测到第二根手指时停止位移进入双指缩放模式。双指缩放的核心是计算两根手指当前距离与上一帧距离的比值作为缩放因子。当手指抬起一根后另一根手指自动接管位移操作。这个过程如果状态机不清晰很容易出现“手指一拿开地图猛跳一下”的bug用户体验非常糟糕。9. 最终实践建议六边形大地图这套系统所有模块的单点难度都不高真正的复杂度来自模块之间的数据流与状态同步。坐标转换、寻路逻辑、渲染表现、交互判定任何一个环节的数据口径不一致整套系统都会表现出诡异的问题。我个人的经验是项目启动时必须立规矩所有地块数据统一走工具模块接口所有跨层访问统一用偏移坐标算法函数内部才能自由使用立方体坐标。团队协作时代码评审重点盯坐标转换的调用凡是出现手写数学公式替代接口函数的地方一律打回。这套纪律比任何炫酷的算法优化都管用。另外一点不要一开始就追求完全体的大世界地图。先用小尺寸地图把玩法跑通地块类型配齐交互手感调顺再逐步增加地图尺寸和视野迷雾。小地图上暴露的逻辑问题比大地图上隐藏的性能问题更容易定位和修复。如果你正在着手做SLG大地图从这篇文章里的坐标系统开始先跑通网格生成和地块点击高亮然后加寻路和行军最后再做迷雾和分块加载。每一步都有清晰的验证点按这个顺序推进大概率能绕开我当年踩过的那些坑。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表