下载 App

格子游戏的自动寻路 —— 从"点哪走哪"到处理门、NPC、障碍

精华04/3081 浏览开发交流
在做魔塔的时候,我需要实现一个功能:玩家点击地图上的任意格子,角色就自动走过去。听起来简单,做起来发现要处理的细节比想象中多得多。这篇日记记录我是怎么一步步把寻路系统做出来的。(塔拉拉说自动寻路没有技术性可写东西,所以发到开发日记了。)
horizontal linehorizontal line
TapTap
一、需求分析:我需要什么样的寻路?
我的游戏是经典的格子地图魔塔,地图大小约 13×13 到 50×50 不等。玩家在格子上移动,遇到墙壁、门、怪物等障碍不能通过。操作方式有两种:
  • 键盘/虚拟方向键:一格一格走,按住连续移动
  • 触摸/鼠标点击:点一个格子,角色自动规划路径走过去
二、算法选择:为什么用 BFS 而不是 A*?
寻路算法常见的有三种:算法特点适用场景BFS(广度优先搜索)保证最短路径,逻辑最简单格子地图、移动代价统一A(A-star)*用启发函数加速,大地图更快大型地图、移动代价不同Dijkstra支持不同权重的最短路径有地形代价(沼泽慢、道路快等)我选了 BFS,原因很直接:
  1. 地图小(最大 50×50 = 2500 格),BFS 最坏情况遍历全部格子也就 2500 次,完全不需要优化
  2. 移动代价统一,每走一格的代价都是 1,不存在"走草地慢、走道路快"的区别
  3. 代码最简单,一个队列 + 一个 visited 表就搞定,不需要维护优先队列
三、地图数据结构在写寻路之前,先要搞清楚地图是怎么存的。
3.1 二维数组存储我的地图是一个二维数组,行优先存储:lua
复制-- 地图数据示例(简化版)
local mapData = {
    { 8, 8, 8, 8, 8 },    -- 第1行:全是墙
    { 8, 2, 2, 2, 8 },    -- 第2行:墙-路-路-路-墙
    { 8, 2, 8, 2, 8 },    -- 第3行:中间有堵墙
    { 8, 2, 2, 2, 8 },    -- 第4行
    { 8, 8, 8, 8, 8 },    -- 第5行:全是墙
}
-- 访问方式:mapData[y][x]
-- 注意:Lua 数组从 1 开始!
3.2 瓦片类型与通行性每个数字代表一种瓦片类型,我用一张查找表来判断"这个格子能不能走":lua
复制-- 瓦片类型
local TILE = {
    GRASS = 1,     -- 草地(可通行)
    PATH  = 2,     -- 小路(可通行)
    WALL  = 8,     -- 墙壁(不可通行)
    DOOR  = 11,    -- 黄门(不可通行,但可以开门后变成可通行)
    HOUSE = 6,     -- 蘑菇屋(不可通行)
    -- ... 更多类型
}
-- 不可通行的瓦片集合
local SOLID_TILES = {
    [TILE.WALL]  = true,
    [TILE.DOOR]  = true,
    [TILE.HOUSE] = true,
    -- ...
}
-- 判断通行性:不在 SOLID_TILES 里就能走
function IsTilePassable(tx, ty)
    if tx < 1 or tx > mapWidth or ty < 1 or ty > mapHeight then
        return false  -- 越界不可通行
    end
    local tile = mapData[ty][tx]
    return not SOLID_TILES[tile]
end
这个设计的好处:新增瓦片类型时,只要往 SOLID_TILES 里加一行就行,寻路代码完全不用改。3.3 坐标系统格子游戏有两套坐标要来回转换,一开始搞混了好几次:坐标类型范围说明瓦片索引整数,从 1 开始第几行第几列,用于查地图数据玩家坐标浮点数,0.5 为格子中心用于渲染和平滑移动lua
复制-- 玩家坐标 → 瓦片索引
function PosToTileX(x)
    return math.floor(x) + 1   -- 0.5 → 第1格,1.5 → 第2格
end
-- 瓦片索引 → 格子中心坐标
function TileCenterX(tileIdx)
    return tileIdx - 0.5        -- 第1格 → 0.5,第2格 → 1.5
end
踩坑记录:最早我忘了 Lua 数组从 1 开始,PosToTileX 写成了 math.floor(x) 没有加 1,导致玩家站在第一列时索引为 0,查表返回 nil,然后程序就崩了。
四、BFS 核心实现4.1 算法思路BFS 的思路很直白:
  1. 从起点开始,把它放进队列
  2. 每次从队列头部取一个格子,检查它的上下左右四个邻居
  3. 邻居没访问过 + 可通行 → 放进队列,记录"它是从哪个格子来的"
  4. 当取出的格子是终点 → 沿着"从哪来"的记录一路回溯,就得到了完整路径
  5. 队列空了还没找到终点 → 无路可达
复制function bfsPath(sx, sy, gx, gy)
    local mapW = GetMapWidth()
    local mapH = GetMapHeight()
    -- 用一维 key 做访问标记,避免二维表的开销
    local function key(x, y) return (y - 1) * mapW + x end
    local visited = {}
    local parent = {}     -- parent[key] = {x, y, parentKey}
    local startK = key(sx, sy)
    visited[startK] = true
    local queue = { { sx, sy } }
    local head = 1        -- 队列头指针(用数组模拟队列)
    local goalK = key(gx, gy)
    local dirs = { {0,-1}, {0,1}, {-1,0}, {1,0} }  -- 上下左右
    while head <= #queue do
        local cur = queue[head]
        head = head + 1
        local cx, cy = cur[1], cur[2]
        local ck = key(cx, cy)
        if ck == goalK then
            -- 找到了!回溯路径
            local path = {}
            local k = goalK
            while k ~= startK do
                local info = parent[k]
                path[#path + 1] = { tx = info[1], ty = info[2] }
                k = info[3]
            end
            -- 反转:回溯是终点→起点,需要反过来
            local n = #path
            for i = 1, math.floor(n / 2) do
                path[i], path[n - i + 1] = path[n - i + 1], path[i]
            end
            return path
        end
        for _, d in ipairs(dirs) do
            local nx, ny = cx + d[1], cy + d[2]
            if nx >= 1 and nx <= mapW and ny >= 1 and ny <= mapH then
                local nk = key(nx, ny)
                if not visited[nk] and IsTilePassable(nx, ny) then
                    visited[nk] = true
                    parent[nk] = { nx, ny, ck }
                    queue[#queue + 1] = { nx, ny }
                end
            end
        end
    end
    return nil  -- 无路可达
end
4.3 几个实现细节为什么用一维 key?local function key(x, y) return (y - 1) * mapW + x end
Lua 的 table 用二维索引(visited[x][y])需要先创建内层 table,而一维 key 只需要一个 table,查找更快。对于 50×50 的地图,key 范围是 1~2500,完全够用。为什么用数组模拟队列?BFS 需要先进先出的队列。Lua 没有内置队列,用 table.remove(queue, 1) 每次删除头部元素的话,所有后面的元素都要前移,复杂度是 O(n)。我的做法是维护一个 head 指针,取元素只是 head = head + 1,O(1) 搞定。虽然"已取出"的元素还留在数组里浪费内存,但对 2500 格的地图来说完全无所谓。路径不含起点:返回的路径是"从起点出发要经过的格子列表",起点本身不包含在内。这样调用方直接遍历路径就是要走的每一步。
五、难点:点击门和 NPC 的特殊处理5.1 问题普通格子的寻路很简单:点哪走哪。但门和 NPC 是不可通行的,BFS 根本找不到路。而且游戏的交互逻辑是:
  • 点击门 → 角色走到门旁边 → 面朝门 → 触发开门检测
  • 点击 NPC → 角色走到 NPC 旁边 → 面朝 NPC → 触发对话
复制-- 玩家点击了一个门
if IsTileDoor(tileX, tileY) then
    -- 先检查是不是已经站在门旁边了
    local dist = math.abs(tileX - curTX) + math.abs(tileY - curTY)
    if dist == 1 then
        -- 已经在旁边 → 直接面朝门,触发开门
        player.direction = calcFaceDir(curTX, curTY, tileX, tileY)
        doorDetected = true
        return
    end
    -- 找门旁边最近的可通行格
    local adjDirs = { {0,-1}, {0,1}, {-1,0}, {1,0} }
    local bestAdj, bestDist = nil, math.huge
    for _, d in ipairs(adjDirs) do
        local ax, ay = tileX + d[1], tileY + d[2]
        if IsTilePassable(ax, ay) then
            local d2 = math.abs(ax - curTX) + math.abs(ay - curTY)
            if d2 < bestDist then
                bestDist = d2
                bestAdj = { x = ax, y = ay }
            end
        end
    end
    if not bestAdj then return end  -- 门四周都走不了
    -- BFS 寻路到这个邻居格
    local path = bfsPath(curTX, curTY, bestAdj.x, bestAdj.y)
    if not path then return end
    -- 记住:到达后要面朝门
    movePath = path
    pendingFaceDir = calcFaceDir(bestAdj.x, bestAdj.y, tileX, tileY)
end
5.3 "到达后面朝目标"的实现走完路径的最后一步时,检查有没有 pendingFaceDir:lua
复制-- 路径走完
if pathIndex > #movePath then
    movePath = nil
    -- 有待转向 → 面朝门/NPC,触发检测
    if pendingFaceDir then
        player.direction = pendingFaceDir
        pendingFaceDir = nil
        doorDetected = true   -- 通知主循环:前方有可交互对象
    end
end
这样主循环在下一帧就会检测到 doorDetected,然后执行开门逻辑(检查钥匙够不够、播放开门动画等)。
六、路径执行:从路径数组到平滑移动BFS 算出来的是一串格子坐标,但玩家不能瞬移到终点,得一格一格走过去。6.1 逐格追踪lua
复制local movePath = nil         -- 完整路径 {{tx,ty}, ...}
local movePathIdx = 0        -- 当前走向第几步
local moveWaypoint = nil     -- 当前目标点(格子中心坐标)
function UpdateMove(dt)
    if not movePath then return end
    -- 需要设下一个 waypoint
    if not moveWaypoint then
        if movePathIdx <= #movePath then
            local wp = movePath[movePathIdx]
            moveWaypoint = {
                x = TileCenterX(wp.tx),
                y = TileCenterY(wp.ty),
            }
            -- 更新朝向
            player.direction = calcDirection(player, moveWaypoint)
        else
            -- 路径走完
            movePath = nil
            return
        end
    end
    -- 向 waypoint 移动
    local dx = moveWaypoint.x - player.x
    local dy = moveWaypoint.y - player.y
    local dist = math.sqrt(dx * dx + dy * dy)
    if dist < 0.05 then
        -- 到达当前 waypoint,对齐到格子中心
        player.x = moveWaypoint.x
        player.y = moveWaypoint.y
        moveWaypoint = nil
        movePathIdx = movePathIdx + 1
    else
        -- 匀速移动
        local step = math.min(speed * dt, dist)
        player.x = player.x + (dx / dist) * step
        player.y = player.y + (dy / dist) * step
    end
end
6.2 为什么要"对齐到格子中心"?到达 waypoint 时我强制把坐标设成格子中心(player.x = moveWaypoint.x),而不是让浮点数自然累积。原因:浮点运算有精度误差,走 10 格后可能偏移了 0.001,看起来没什么,但做碰撞检测时 PosToTileX 可能算出错误的格子索引。每步对齐一次就不会有这个问题。
七、两套输入共存游戏同时支持键盘和触摸,两套输入必须互相配合:lua
复制-- 键盘按下 → 立即清除触摸寻路
if keyboardDir ~= NONE then
    movePath = nil
    moveWaypoint = nil
end
-- 触摸点击 → 重新计算路径(覆盖之前的路径)
if touchClicked then
    movePath = bfsPath(...)
    movePathIdx = 1
end
规则:键盘优先级高于触摸。玩家正在自动寻路,中途按了方向键,就切换成手动控制,寻路路径立刻丢弃。还有一个容易忽略的细节:切层后冻结输入。玩家上楼梯切换到新楼层时,如果方向键还按着,角色会立刻往之前的方向走一格。我的做法是切层后设一个 inputFrozen 标记,等玩家松开所有方向键再解冻。
八、总结:格子寻路的清单如果你也在做格子游戏的自动寻路,这是我踩完坑之后总结的清单:步骤要点1. 地图数据二维数组存储,用查找表判断通行性2. 坐标转换瓦片索引(整数,从 1 开始)和玩家坐标(浮点数)要分清3. 寻路算法小地图用 BFS 就够了(保证最短路径,代码简单)4. 不可通行目标门/NPC → 寻路到旁边最近的可通行格 + 记录到达后的朝向5. 路径执行逐格追踪,每步对齐到格子中心,避免浮点漂移6. 输入优先级键盘覆盖触摸,切层后冻结输入格子寻路本身不难,BFS 几十行就能写完。真正花时间的是处理各种交互场景——点击门怎么办、点击不可达的地方怎么办、走到一半切换输入怎么办。这些边界情况才是寻路系统里最多的工作量。
以上内容基于魔塔:逃离乐园的实际开发记录。
猜你想搜
taptap 制造格子游戏自动寻路
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
68 赞
21 回复
01:27
《大二暑假,我用AI做了一款属于自己的游戏》截图
《大二暑假,我用AI做了一款属于自己的游戏》
本人自制游戏现已加入taptap封闭测试(仅适配安卓机型)欢迎大家前来尝试(八月十号可以正式开始游玩) 如果有建议或者bug反馈可以加入qq群928501443 #发现好游戏 #游戏日常 #浅评一下 #今天游戏圈发生了啥
3 赞
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
37 赞
6 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
123 赞
44 回复
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
3 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
00:26
塔啦啦科研:三渲二(风格1)截图
塔啦啦科研:三渲二(风格1)
最近在研究像素风格的三渲二,一直想做小丑牌那种风格,但是塔啦啦一直做不出来,发现是有一部分的功能做不了,只能实现简单的材质制作和描边,因此,我从零直接搭建了一整套引擎基础。目前进度可以看视频。 @嗒啦啦 什么时候出shader 评论区随意提问 #TapTap制造 #TapTap制造社区 #TapTap制造
4 赞
5 回复
嗯,真的,随便了(并不是什么下台阶)截图
嗯,真的,随便了(并不是什么下台阶)
只是觉得,为以前这样让朋友推荐这个平台的我,感到。。。无所谓了。你们喜欢吧。
1 赞
3 回复
永恒虚空持续开发日记1——关于游戏中的新名词:场频截图
永恒虚空持续开发日记1——关于游戏中的新名词:场频
写在前边:首先感谢TapTap制造版主大大@汪崽 对于作品潜力的肯定,给与了我积分计划二的权限[表情_不好意思]。这边也在加紧开发改进各种功能中,期望尽快给大家带来成熟的版本。[表情_猫咪举手] 大家好,我是永恒虚空的制作人黑猫不详,大家叫我黑猫就好。从今天开始我将以后置开发日记的形式给大家分享我在TapTap制造中制作的游戏永恒虚空的开发过程,新人希望大家多多关照! 今天先给大家介绍我作品中最重
投票
3 赞
【Tap make 塔拉拉之立项干货】萌新看过来!截图
【Tap make 塔拉拉之立项干货】萌新看过来!
题外话:本人也是tap make 使用者之一。塔啦啦忠实信徒、天哥忠实拥护者、八年游戏策划经验的低级策划。但是!我已经做出来一款5000+热度、且稳定可更新的框架、并且还在持续产出更多游戏内容的橘猫! 这几天正好在开新坑。我是3月初加入的tapmake大家庭。两周时间于3月25日将这个作品上架。当时的全凭感觉摸索。如今,自己也是一个有自己项目的小菜鸡了。今天,就来分享一下,如何高效的制作一款高大上
投票精华
68 赞
24 回复