十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

SLG沙盘坐标转换全指南:菱形瓦片与笛卡尔坐标系双向映射

SLG沙盘坐标转换全指南:菱形瓦片与笛卡尔坐标系双向映射 做ROK Like这类SLG第一周基本都会耗在沙盘地图坐标系上。我见过不少服务端同学上来就找客户端要一张美术大图想把格子坐标直接映射成像素坐标最后在技能范围、行军寻路里被菱形的奇偶Tile折腾到怀疑人生。这篇就专门聊菱形瓦片和笛卡尔坐标系之间的转换包含公式推导、C#服务端实现、常见坑位排查以及我扔在仓库里一直在用的调试自检脚本。本文的目标读者是服务端开发尤其是主程或负责大地图模块的人。客户端同学也可以看但服务端才是“权威坐标源”客户端再花哨的显示层最终都要回到服务端这套(col,row)逻辑坐标上。读懂这篇文章你至少能解决三个问题单位在沙盘上移动时位置怎么从格坐标换算成世界坐标玩家点了一个世界坐标点后服务器如何反推出对应格子以及怎么用最小代价做一套调试工具避免每次都在日志里肉眼换算。1. 先搞清楚ROK Like沙盘在服务端意味着什么1.1 大规模同图即时战斗下地图坐标是权威数据ROK Like的核心体验是“一张大图上成百上千玩家实时共存”。玩家主城、资源点、野蛮人营地在逻辑上都落在某个格子上但这个格子不是传统表格里横平竖直的格子而是等距视角的菱形瓦片。服务端不能只在自己内部画个矩形完事因为客户端上报的所有点击坐标、拖拽坐标、行军目标都是连续浮点数。服务端要做的是把这些浮点坐标翻译成唯一确定的格坐标再用格坐标做寻路、范围判定和技能检测。地图一旦大起来就不能用“遍历所有格子”这种方式去判断玩家在哪个格子。一个2000x2000的地图就是400万格如果每次移动都遍历一次性能直接没法看。所以坐标转换不能有歧义必须让服务器在O(1)时间内定位到格子。这也是为什么我不建议服务端直接用美术像素坐标作为主键像素坐标有精度问题、有浮点误差而且后期换美术资源会牵扯到整个存档非常难受。1.2 菱形瓦片不是视觉特效而是一组正交基很多人看到菱形就慌觉得比正方形复杂。其实菱形瓦片在数学上非常简单它本质上就是“普通矩形网格做了一次旋转和缩放”。想象一张普通的二维数组每个格子叫(col,row)。你把这个网格整体绕原点旋转45度再把水平方向拉宽一倍视觉上就变成菱形瓦片。用线性代数的语言说菱形瓦片相当于给矩形网格换了一组基向量col轴方向增长时世界坐标沿右上方向走row轴方向增长时世界坐标沿左上方向走。因此菱形瓦片的世界坐标公式永远是线性变换不存在分支判断。它比六边形简单比矩形多了一点点理解成本但换来的美术质感和斜45度交互体验完全是另一档。服务端要做的不是渲染而是把矩形的逻辑网格和斜45度的显示网格做双向映射。2. 菱形瓦片与笛卡尔坐标转换公式2.1 先约定三个坐标为了避免后面绕晕先把坐标系统一声。逻辑格坐标用(col,row)表示col是列row是行。这是服务端真正的业务主键寻路、建筑、资源点全部用它。世界坐标/笛卡尔坐标用(x,y)表示单位可以是像素也可以是逻辑米、逻辑厘米。服务端通常按逻辑单位算客户端最终再按分辨率缩放。瓦片尺寸一个菱形瓦片的水平对角线叫TileWidth垂直对角线叫TileHeight。美术素材里常见比例是2:1比如64x32像素。半宽半高我会写成HalfTileWidthTileWidth/2HalfTileHeightTileHeight/2。这套约定确定后后续所有公式都围绕这三个量展开。2.2 从格坐标到世界坐标行和列各走各的方向假设玩家站在格子(col,row)的中心那么这个中心的世界坐标是worldX (col - row) * HalfTileWidth worldY (col row) * HalfTileHeight很多第一次接触的人会问为什么x方向是col减rowy方向却是col加row这里可以这样理解当col增加1时世界坐标往右下移动x增加HalfTileWidthy增加HalfTileHeight 当row增加1时世界坐标往左下移动x减少HalfTileWidthy增加HalfTileHeight。把两个方向叠加起来就是col贡献一部分、row贡献一部分。用这种约定菱形瓦片看起来就是两张相互交叉的斜线网格视觉上正好是ROK类沙盘的标准效果。我建议你在引擎里先定义一个归一化版本比如TileWidth2.0fTileHeight1.0f。这样HalfTileWidth1.0fHalfTileHeight0.5f公式里没有像素无关的数字纯逻辑推导方便很多。到了客户端适配美术资源时再把TileWidth替换成实际像素值即可。2.3 从世界坐标到格坐标解二元一次方程组逆变换其实就是解方程。已知x (col - row) * HalfTileWidth y (col row) * HalfTileHeight可以先算两个中间量diff x / HalfTileWidth // 等价于 col - row sum y / HalfTileHeight // 等价于 col row然后得到colFloat (sum diff) / 2 rowFloat (sum - diff) / 2这里colFloat和rowFloat通常是浮点数。如果世界坐标正好落在某个瓦片中心它们就是整数如果落在别处则会出现0.5之类的小数。我们需要四舍五入到最近的整数格。举个例子假设HalfTileWidth1HalfTileHeight0.5玩家世界坐标是(1.5, 1.0)。那么diff1.5sum2.0colFloat1.75rowFloat0.25四舍五入后就是(2,0)。你可以验证一下格子(2,0)的世界坐标正好是(2,1)而(1,1)的世界坐标是(0,1)。离(1.5,1.0)最近的确实是(2,0)符合直觉。2.4 边界归属取整为什么不能无脑用Math.Round逆变换时最坑的四舍五入。C#里的Math.Round默认是银行家舍入0.5可能被舍成0而不是1。格坐标还可能是负数处理起来更麻烦。我常用的策略是半格向上取整也就是“远离0取整”private static int RoundHalfAway(float v) { return (int)(v 0f ? v 0.5f : v - 0.5f); }这样0.5变成1-0.5变成-1逻辑统一。不过你还要面对“多个格子边界点归属谁”的问题。例如某个点正好在两个瓦片中心连线的中点上它可能在两个格子的内部都成立。服务端不能返回两个结果所以要和策划约定边界点归colrow更小的格子或者归更早创建的格主子。这个规则一旦定了尽量不要改否则客户端和服务端结果会对不上。3. 服务端落地C#实现坐标工具类3.1 格坐标的数据结构我建议给格坐标单独做一个struct而不是直接在项目里到处传int col和int row。这样后面扩展属性、写比较逻辑、做哈希查找都方便。public readonly struct TileCoord : IEquatableTileCoord { public readonly int Col; public readonly int Row; public TileCoord(int col, int row) { Col col; Row row; } public bool Equals(TileCoord other) { return Col other.Col Row other.Row; } public override bool Equals(object obj) { return obj is TileCoord other Equals(other); } public override int GetHashCode() { return HashCode.Combine(Col, Row); } public override string ToString() { return $({Col},{Row}); } }为什么用struct因为格坐标是值类型比较快不会产生堆分配。大地图里每帧可能做几万次坐标转换用class会产生大量GC压力。配上GetHashCode后就能直接用DictionaryTileCoord, MapCell存储地图格子数据不用再拼字符串当key。3.2 核心转换代码下面给出一套完整可用的静态工具类包含初始化、正向转换、反向转换和边界判断。这里的Vector2来自System.Numerics实际项目用Unity的Vector2还是自己写的Vec2都可以替换一下就好。using System.Numerics; public static class TileMath { public static float HalfTileWidth { get; private set; } public static float HalfTileHeight { get; private set; } public static void Init(float tileWidth, float tileHeight) { if (tileWidth 0f || tileHeight 0f) throw new ArgumentException(瓦片尺寸必须大于0); HalfTileWidth tileWidth * 0.5f; HalfTileHeight tileHeight * 0.5f; } public static Vector2 TileToWorld(TileCoord tile) { float x (tile.Col - tile.Row) * HalfTileWidth; float y (tile.Col tile.Row) * HalfTileHeight; return new Vector2(x, y); } public static TileCoord WorldToTile(Vector2 world) { float diff world.X / HalfTileWidth; float sum world.Y / HalfTileHeight; float colFloat (sum diff) * 0.5f; float rowFloat (sum - diff) * 0.5f; return new TileCoord(RoundHalfAway(colFloat), RoundHalfAway(rowFloat)); } public static bool IsInsideTile(TileCoord tile, Vector2 world) { Vector2 center TileToWorld(tile); float nx (world.X - center.X) / HalfTileWidth; float ny (world.Y - center.Y) / HalfTileHeight; return MathF.Abs(nx) MathF.Abs(ny) 1f; } private static int RoundHalfAway(float v) { return (int)(v 0f ? v 0.5f : v - 0.5f); } }实际项目里我还会加一个TryGetTile方法。客户端上报一个世界坐标后服务器先算候选格子再检查是否在合法地图范围内最后用IsInsideTile做一次菱形包含判断。这样能过滤掉点歪了的操作也能避免把野外的无效坐标判进某个格子。public bool TryGetTile(Vector2 world, out TileCoord result) { result TileMath.WorldToTile(world); if (!IsInMap(result)) return false; return TileMath.IsInsideTile(result, world); }3.3 邻居和移动判定寻路和行军经常需要拿当前格子的相邻格子。菱形显示虽然变了但逻辑上的邻居关系还是矩形网格的八方向邻居。public static IEnumerableTileCoord GetNeighbors8(TileCoord center) { for (int dc -1; dc 1; dc) { for (int dr -1; dr 1; dr) { if (dc 0 dr 0) continue; int nc center.Col dc; int nr center.Row dr; if (!IsInMap(new TileCoord(nc, nr))) continue; yield return new TileCoord(nc, nr); } } }很多人在这一步又会被显示层带偏。明明是菱形格子为什么邻居还是上下左右加斜角因为服务端存储用的是矩形逻辑网格菱形只是显示。你把整个菱形地图看成一张菱形形状的画布但画布背后的数据网格仍然是矩形。移动耗时计算也用逻辑坐标算例如切比雪夫距离public static int ChebyshevDistance(TileCoord a, TileCoord b) { return Math.Max(Math.Abs(a.Col - b.Col), Math.Abs(a.Row - b.Row)); }如果要精确一些可以计算世界坐标下的欧氏距离但一般ROK Like的寻路代价都按格数算切比雪夫距离够用性能也更好。3.4 调试工具随机点自检和终端坐标输出坐标转换这种基础工具不做自动化测试迟早出事故。我在仓库里放了一个很小的Python脚本专门做随机点回归和终端坐标打印。每次调整瓦片尺寸参数后先跑一遍确认正反转换不会丢精度。import random TILE_W 64.0 TILE_H 32.0 HALF_W TILE_W / 2 HALF_H TILE_H / 2 def tile_to_world(c, r): return (c - r) * HALF_W, (c r) * HALF_H def world_to_tile(x, y): diff x / HALF_W total y / HALF_H cf (total diff) * 0.5 rf (total - diff) * 0.5 return _round_half_away(cf), _round_half_away(rf) def _round_half_away(v): return int(v 0.5) if v 0 else int(v - 0.5) def self_check(count20000): for _ in range(count): c random.randint(-1000, 1000) r random.randint(-1000, 1000) x, y tile_to_world(c, r) c2, r2 world_to_tile(x, y) if (c, r) ! (c2, r2): raise RuntimeError(f不匹配: {(c,r)} - {(x,y)} - {(c2,r2)}) print(f随机点自检通过共 {count} 组) if __name__ __main__: self_check()这个脚本看起来简单但救过我很多次。改公式、改舍入逻辑、换坐标系方向跑一遍就能发现是不是有1格偏移。如果你想要更直观的菱形地图可以在终端里把前几行的世界坐标打出来人工核验“从左上角到右下角”的坐标趋势是否正确。def print_world_grid(cols5, rows5): for r in range(rows): line for c in range(cols): x, y tile_to_world(c, r) line f({c},{r})({x:5.0f},{y:4.0f}) print(line)输出类似(0,0)( 0, 0) (1,0)( 32, 16) (2,0)( 64, 32) ... (0,1)( -32, 16) (1,1)( 0, 32) (2,1)( 32, 48) ...一眼就能看出col增大往右下走row增大往左下走。如果方向反了立即能发现不用等客户端联调。4. 实际项目中常见的坑和排查清单4.1 客户端锚点和服务端中心点错位菱形瓦片图片导入引擎时锚点默认可能在左下角也可能在中心。服务端计算用的是瓦片中心。如果客户端显示角色时使用图片锚点而服务器用中心点就会导致角色站在两个格子边界上抖动。解决方案是服务端回归自己单位位置永远用世界坐标的中心点客户端显示时自己再偏移美术锚点。服务端不要把自己的逻辑去迁就某一张美术图。只要服务器权威坐标是中心点客户端无论怎么缩放画面最终都能换算到同一个格子上。4.2 负数坐标四舍五入不一致地图如果只从(0,0)开始那负数问题不明显。但ROK Like大世界里服务器往往使用“大地图分块”或者“坐标可负”的设定。C#的MathF.Round采用银行家舍入0.5会舍成0-0.5也会舍成0两个不同方向反而产生不对称。我遇到过最诡异的问题同一条行军线路正坐标部分表现正常负坐标部分总是偏一格。最后定位就是舍入函数不一致导致的。我的建议是全局统一使用RoundHalfAway不要让业务代码各自用MathF.Round。4.3 频繁坐标转换导致缓存失效坐标转换看起来简单但一帧内如果调用几十万次float除法也不是免费午餐。服务端通常不会像客户端那样跑渲染循环但SLG的AI检测、行军推进、范围技能都依赖坐标计算高并发时累计开销很可观。我的习惯是逻辑格坐标作为数据主键世界坐标作为移动路径计算结果转换完成后的数据缓存到对象身上不要每次都重新算。世界坐标只在对象创建、外部输入、移动开始/结束时刷新。这样能把转换次数从每帧几十万降到几千性能提升非常明显。4.4 常见问题速查表现象可能原因解决思路点击某个点总是偏一格客户端锚点与服务端中心点不一致统一使用中心点客户端自行偏移负坐标区域行为异常四舍五入不一致使用RoundHalfAway统一取整单位在菱形边界抖动边界归属规则未定策划定死边界归属规则服务器严格遵循寻路路径看起来斜向穿墙邻居关系仍使用矩形八方向但碰撞体没有做等价映射服务端碰撞体也使用同一逻辑格坐标更换美术素材后坐标偏移瓦片TileWidth或TileHeight变化只改Init参数不改存档不存世界坐标世界坐标逆转换得到负数浮点转int后莫名少1直接用(int)强转截断使用半格远离0取整4.5 调试时不要只打坐标日志坐标问题最怕“日志全是坐标人脑懒得算”。所以我强烈建议在调试工具里直接画图。最简单的做法是在本地跑一个Python脚本把当前地图的格坐标逐行打印出来或者用一个小型HTML页面展示菱形网格和坐标标号。有人会觉得服务端做可视化是浪费工时但一个坐标偏移问题在联调阶段可能耗掉一整天而可视化工具体系十分钟就能搭完收益是明显划算的。5. 一点个人建议5.1 千万别把坐标转换结果写进存档我见过一个项目早期图省事把玩家位置直接存成世界坐标的浮点数。后来美术把瓦片尺寸从64x32改成80x40所有玩家位置全部偏移客服反馈堆成山。存档里永远只存逻辑格坐标(col,row)世界坐标任何时候都由TileMath动态换算。这样改美术、改客户端分辨率、改地图大小都不影响存档数据。5.2 先把坐标工具类做成完全可自测的模块再去做业务功能我在第一次做这种地图时是先写业务再补测试结果坐标问题渗透到行军、战斗、建造各个模块排查起来非常痛苦。后来改成第一天就把坐标转换工具类和随机自测脚本写好后面所有业务模块都基于这个工具类开发再也没有出现过“全地图偏移一格”的惨案。这是我个人最想强调的一点SLG沙盘服务端的坐标转换看着像数学题实际上是一个团队协作约定。谁定义规则、谁统一取整、谁提供调试工具比公式本身更重要。公式只要推导一次就能写对但规则如果不落地成代码和测试迟早会在某个加班的深夜爆发。
返回列表