游戏中墙与门的碰撞判断:两阶段检测架构

05/0624 浏览开发心得
在魔塔类网格游戏中,"墙"和"门"看起来都是挡路的障碍物,但它们的交互逻辑完全不同。本文拆解一套实际运行在生产项目中的两阶段检测架构——第一阶段做通行性判断,第二阶段做交互触发——解决"同样是走不通,墙要弹回来,门要尝试打开"的设计问题。
horizontal linehorizontal line
问题:同一个"不可通行",两种处理结果在网格移动的游戏中,玩家每次移动的目标是相邻的一格。最朴素的做法是判断目标格能不能走:能走 → 移过去
不能走 → 不动
这对纯墙壁够用了。但一旦引入门,问题就出现了:
  • :不可通行,碰到就弹回,没有任何交互
  • :不可通行,但碰到要检查钥匙、消耗资源、打开变成空地
  • NPC:不可通行,但碰到要打开对话/商店
复制-- ❌ 反模式:判断和交互混在一起
if target == WALL then
    -- 弹回
elseif target == DOOR then
    -- 检查钥匙 → 开门
elseif target == DOOR_BLUE then
    -- 检查蓝钥匙 → 开门
elseif target == NPC then
    -- 打开商店
elseif ...
每加一种障碍就多一个分支,移动逻辑和交互逻辑耦合在一起,改一个容易破坏另一个。
解决方案:两阶段检测把「能不能通行」和「碰到了做什么」拆成两个独立阶段:阶段 1:移动判断(PlayerController)
  → 目标格可通行?移过去
  → 目标格不可通行?弹回 + 标记"检测到障碍"
阶段 2:交互触发(主循环)
  → 消费标记 → 读取面前是什么 → 分发到对应处理器
两阶段之间通过一个布尔标记 doorDetected_ 解耦。为什么要跨帧?阶段 1 发生在玩家输入处理时(PlayerController.Update),此时玩家可能还没对齐到格子中心。阶段 2 发生在下一帧的主循环中(main.lua Update),此时玩家已经对齐,面朝方向确定,读到的"面前一格"坐标是准确的。
阶段 1:通行性判断瓦片分类首先定义哪些瓦片是"实体"(不可穿越):lua
复制-- TileTypes.lua
local SOLID_TILES = {
    [TILE.WALL]       = true,   -- 石墙
    [TILE.DOOR]       = true,   -- 黄门
    [TILE.DOOR_BLUE]  = true,   -- 蓝门
    [TILE.DOOR_RED]   = true,   -- 红门
    [TILE.DOOR_PURPLE]= true,   -- 紫门
    [TILE.HOUSE]      = true,   -- NPC(蘑菇屋)
    [TILE.CAREER_NPC] = true,   -- 职业NPC
}
注意:门和墙在这个表里是平等的——它们都是实体,都不可通行。这一层不关心"为什么不能走"。通行性查询lua
复制-- MapManager.lua
function MapManager.IsTilePassable(tx, ty)
    -- 边界检查
    if tx < 1 or tx > mapWidth or ty < 1 or ty > mapHeight then
        return false
    end
    local tile = currentMapData_[ty][tx] or TILE.GRASS
    return not SOLID_TILES[tile]
end
返回值只有 true / false,不区分原因。移动判断 + 标记玩家按下方向键时:lua
复制-- PlayerController.lua
local doorDetected_ = false  -- 检测标记
-- 键盘按下方向键
local nx, ny = cx + dx, cy + dy  -- 目标格坐标
if MapManager.IsTilePassable(nx, ny) then
    -- ✅ 可通行 → 移动到目标格
    waypoint = { x = TileCenterX(nx), y = TileCenterY(ny) }
else
    -- ❌ 不可通行 → 对齐回当前格中心
    waypoint = { x = TileCenterX(cx), y = TileCenterY(cy) }
    -- 关键:如果是地图内的障碍(不是边界外),标记为"检测到"
    if inBounds then
        doorDetected_ = true
    end
end
这里的核心设计:被挡住时不立即处理交互,只设标记。为什么要区分"地图内的障碍"和"边界外"?因为撞到地图边界没有任何交互意义,不需要触发阶段 2。标记消费接口lua
复制function PlayerController.ConsumeDetection()
    if doorDetected_ then
        doorDetected_ = false
        return true
    end
    return false
end
一次性消费:读取后立即清除,防止重复触发。面前一格查询lua
复制function PlayerController.GetFacingTile()
    local px, py = currentTile()
    -- 根据玩家面朝方向偏移一格
    if direction == Dir.UP    then py = py - 1
    elseif direction == Dir.DOWN  then py = py + 1
    elseif direction == Dir.LEFT  then px = px - 1
    elseif direction == Dir.RIGHT then px = px + 1
    end
    return px, py
end
阶段 2:交互分发
主循环每帧检查标记,消费后查面前是什么,分发到对应处理器:lua
复制-- main.lua Update()
if PlayerController.ConsumeDetection() then
    local fx, fy = PlayerController.GetFacingTile()
    local facingTile = MapManager.GetTile(fx, fy)
    if ALL_DOORS[facingTile] then
        -- 面前是门 → 尝试开门
        if CombatSystem.TryOpenDoor(player, facingTile) then
            MapManager.SetTile(fx, fy, TILE.GRASS)  -- 门变空地
        end
    elseif facingTile == TILE.HOUSE then
        -- 面前是 NPC → 打开商店
        ShopPanel.Show()
    end
    -- 面前是墙 → 什么都不做(静默弹回已在阶段 1 完成)
end
墙不需要显式处理——阶段 1 已经把玩家弹回去了,阶段 2 里 WALL 不匹配任何分支,自然跳过。新增障碍类型只需要在这里加一个 elseif,不影响移动逻辑。开门逻辑lua
复制-- CombatSystem.lua
function CombatSystem.TryOpenDoor(player, doorTileType)
    local color = DOOR_COLOR[doorTileType]  -- 门瓦片 → 钥匙颜色
    if player.keys[color] and player.keys[color] > 0 then
        player.keys[color] = player.keys[color] - 1
        return true   -- 开门成功
    end
    return false      -- 没有钥匙
end
门的颜色和钥匙颜色通过一张映射表对应:lua
复制local DOOR_COLOR = {
    [TILE.DOOR]        = "yellow",
    [TILE.DOOR_BLUE]   = "blue",
    [TILE.DOOR_RED]    = "red",
    [TILE.DOOR_PURPLE] = "purple",
}
新增门颜色只需要:SOLID_TILES 加一行 + ALL_DOORS 加一行 + DOOR_COLOR 加一行。开门逻辑本身不需要改。
寻路的特殊处理:门是"半透明"的上面说的是键盘逐格移动。但触屏点击移动用的是 BFS 寻路,这里有一个有趣的设计差异:寻路时门被视为可通行。lua
复制-- BFS 寻路
if MapManager.IsTilePassable(nx, ny) then  -- 门不在这里被挡
    -- 加入队列
end
等等,IsTilePassable 不是会把门判为不可通行吗?实际上寻路用了一个略微不同的检查——它走的路径可以穿过门的位置,只是在实际移动到门旁时,触发阶段 2 的开门逻辑。如果开门成功,门变成空地,角色继续沿路径走下一格;如果没有钥匙,角色停在门前。这意味着玩家在触屏模式下点击门后面的格子,寻路算法会规划一条经过门的路径,走到门前自动尝试开门,而不是报"无法到达"。这比"门在寻路中被视为墙"的体验好得多。障碍类型寻路阶段实际移动碰到后的交互墙不可通过不可通过无门可通过不可通过检查钥匙 → 开门NPC不可通过不可通过打开对话/商店空地可通过可通过无
概率门:编译期消失的第五种瓦片地图模板中还有一种特殊符号 i——概率门。但它和上面四种瓦片不在同一层:lua
复制local SYMBOL_TO_TILE = {
    ["#"] = TILE.WALL,
    ["."] = nil,           -- 空地
    ["i"] = "RANDOM_DOOR", -- 概率门
}
概率门在地图加载时就被掷骰替换了:掷骰结果概率替换为消失50%空地黄门43%TILE.DOOR蓝门6.5%TILE.DOOR_BLUE红门0.5%TILE.DOOR_RED运行时不存在"概率门"这种瓦片——它在加载阶段就变成了空地或具体颜色的门。后续的两阶段检测完全不知道这个门是"概率门变来的"还是"固定放置的",处理逻辑完全一致。这个分层很干净:地图生成层负责决定"这里有没有门",碰撞检测层负责处理"碰到门了怎么办",互不干涉。
架构总结┌─────────────────────────────────────────────────┐
│                   地图生成层                      │
│  TerrainTemplates → 概率门掷骰 → 确定瓦片类型     │
└──────────────────────┬──────────────────────────┘
                       ↓
┌──────────────────────┴──────────────────────────┐
│                 数据定义层                        │
│  TileTypes.lua                                   │
│  ├─ SOLID_TILES  → 通行性表(墙、门、NPC 都在)   │
│  ├─ ALL_DOORS    → 门集合(黄蓝红紫)             │
│  └─ DOOR_COLOR   → 门→钥匙颜色映射               │
└──────────────────────┬──────────────────────────┘
                       ↓
┌──────────────────────┴──────────────────────────┐
│              阶段 1:移动判断                      │
│  PlayerController.lua                            │
│  ├─ IsTilePassable()  → 能走就走,不能走就弹回    │
│  ├─ doorDetected_     → 被挡住时设标记            │
│  └─ GetFacingTile()   → 提供面前坐标              │
└──────────────────────┬──────────────────────────┘
                       ↓  (跨帧,通过布尔标记传递)
┌──────────────────────┴──────────────────────────┐
│              阶段 2:交互分发                      │
│  main.lua Update()                               │
│  ├─ ConsumeDetection()  → 一次性消费标记          │
│  ├─ ALL_DOORS[tile]?    → CombatSystem.TryOpenDoor│
│  ├─ HOUSE?              → ShopPanel.Show()        │
│  └─ WALL?               → 静默跳过(已弹回)      │
└─────────────────────────────────────────────────┘
核心思路就一句话:通行性判断不关心"为什么不能走",交互触发不关心"怎么走过来的"。两阶段各管各的,通过一个布尔标记连接,新增障碍类型只需要在数据表加一行、在分发逻辑加一个分支。
5