下载 App
10/164 浏览案例分享
【案例分享】2D 物理三件套:碰撞层怎么分、Tilemap 的 Physics 层怎么用、PathFollower 挪节点不挪刚体
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:《别停下》开发实录 —— 2D 横版关卡与移动平台
系列:第 21 篇
一句话结论
2D 物理真正会咬人的地方只有三处:碰撞层没分层导致脚底传感器把墙也算成地面、Tilemap 的碰撞体默认挂在整张地图节点上而不是挂在对象上、PathFollower 只挪节点不挪刚体——三件事全都不报错,只是表现得像"物理引擎有点飘"。
我这次的关卡拉了四天,前三天都在调参数,第四天发现参数一个都不用调。
一、先把单位和层定了,再写一行代码
2D 物理的长度单位是米,不是像素。重力加速度写 Vector2(0, -9.81) 是标准地球重力,但横版跳跃游戏会觉得 9.81 太"飘",我在《别停下》里用的是:
physicsWorld_ = scene_:CreateComponent("PhysicsWorld2D")
physicsWorld_.gravity = Vector2(0, -20.0) -- 2D 横版手感,约 2 倍地球重力
physicsWorld_.autoClearForces = true
渲染和物理是两个坐标系。我固定 1 物理单位 = 50 像素,换算就一行:
local PIXELS_PER_UNIT = 50
local function PhysicsToScreen(x, y)
return SCREEN_WIDTH / 2 + x * PIXELS_PER_UNIT,
SCREEN_HEIGHT / 2 - y * PIXELS_PER_UNIT
end
Y 轴要翻转——物理世界 Y 向上,屏幕 Y 向下。符号写反画面就上下颠倒,物理世界却一切正常,你会去怀疑相机。
碰撞层用位(bit)分,别用数字分
Box2D 的过滤是两个条件同时成立才碰撞:
(shapeA.categoryBits & shapeB.maskBits) != 0
且
(shapeB.categoryBits & shapeA.maskBits) != 0
所以 categoryBits 是"我属于哪一组",maskBits 是"我愿意碰哪些组"。我用的分层表:
组:categoryBits 用途 maskBits
地形:1(bit 0) 地面、平台、墙 0xFFFF
玩家本体:2(bit 1) 角色碰撞圆 0xFFFF
脚底传感器:4(bit 2) 只测地面 1
移动平台:8(bit 3) 升降台、巡逻车 2
敌人:16(bit 4) 巡逻怪 1
关键在脚底传感器那一行:maskBits = 1,只跟地形组判定。不分层的话,玩家贴墙时传感器会碰到墙,onGround 一直 true,人能蹬着墙无限跳。
-- 玩家本体:圆形,零摩擦,锁旋转
local bodyShape = playerNode_:CreateComponent("CollisionCircle2D")
bodyShape.radius = 0.5
bodyShape.density = 1.0
bodyShape.friction = 0.0
bodyShape.restitution = 0.0
bodyShape.categoryBits = 2
bodyShape.maskBits = 0xFFFF
-- 脚底传感器:同节点、不同形状、trigger
local footSensor = playerNode_:CreateComponent("CollisionCircle2D")
footSensor.radius = 0.35
footSensor.center = Vector2(0, -0.45)
footSensor.trigger = true
footSensor.categoryBits = 4
footSensor.maskBits = 1
传感器必须是同一个刚体上的第二个形状,不能挂子节点。挂子节点就是另一个刚体,接触事件里的节点对不上,你会收到一堆莫名其妙的 NodeA。
二、地面检测:接触计数,不是布尔
第一次写我用一个 onGround = true/false,结果角色在平台边缘会"抖跳"——一帧接触一帧离开,布尔值来回翻。
改成计数器就稳了。PhysicsBeginContact2D 加一,PhysicsEndContact2D 减一,归零才算离地:
local groundContactCount_ = 0
local onGround_ = false
local function IsGroundOrPlatform(node)
if node == nil then return false end
return node.name == "Ground" or node.name:find("Platform", 1, true) ~= nil
end
local function GetOtherNode(nodeA, nodeB)
if nodeA == playerNode_ then return nodeB end
if nodeB == playerNode_ then return nodeA end
return nil
end
function HandlePhysicsBeginContact(eventType, eventData)
local nodeA = eventData["NodeA"]:GetPtr("Node")
local nodeB = eventData["NodeB"]:GetPtr("Node")
if IsGroundOrPlatform(GetOtherNode(nodeA, nodeB)) then
groundContactCount_ = groundContactCount_ + 1
onGround_ = true
end
end
function HandlePhysicsEndContact(eventType, eventData)
local nodeA = eventData["NodeA"]:GetPtr("Node")
local nodeB = eventData["NodeB"]:GetPtr("Node")
if IsGroundOrPlatform(GetOtherNode(nodeA, nodeB)) then
groundContactCount_ = groundContactCount_ - 1
if groundContactCount_ <= 0 then
groundContactCount_ = 0
onGround_ = false
end
end
end
订阅放在 Start() 里:
SubscribeToEvent("PhysicsBeginContact2D", "HandlePhysicsBeginContact")
SubscribeToEvent("PhysicsEndContact2D", "HandlePhysicsEndContact")
用圆形碰撞体而不是矩形。矩形贴墙滑行时会卡棱角,圆形永远滑得过去。边缘二次弹跳是 Box2D 的正常特性,别去修它。
移动用速度控制,不是力控制。每帧把水平速度写成目标值,垂直速度保持物理算出来的:
local currentVel = playerBody_.linearVelocity
playerBody_.linearVelocity = Vector2(desiredVelX, currentVel.y)
if onGround_ and jumpPressed then
playerBody_.linearVelocity = Vector2(desiredVelX, 12.0)
playerBody_.awake = true -- 关键:设置速度后必须唤醒
end
awake = true 漏了,静止的刚体设了速度也不动。这是 Box2D 的睡眠优化,不是 bug。
三、Tilemap 物理:碰撞体挂在谁身上
TMX 地图里的物理碰撞,靠一个专门的对象层承载。在 Tiled 里建对象层,命名 Physics,往里面画矩形、圆、多边形、折线。每种对象对应一种 Box2D 形状:
TMX 对象类型:生成的形状 常量
Rectangle:CollisionBox2D OT_RECTANGLE
Ellipse:CollisionCircle2D OT_ELLIPSE
Polygon:CollisionPolygon2D OT_POLYGON
Polyline:CollisionChain2D OT_POLYLINE
引擎侧调用长这样:
local TilemapPhysics = require "urhox-libs.Physics2D.TilemapPhysics"
local tileMapNode = scene_:CreateChild("TileMap")
local tileMap = tileMapNode:CreateComponent("TileMap2D")
tileMap.tmxFile = cache:GetResource("TmxFile2D", "Urho2D/Level1.tmx")
TilemapPhysics.AutoCreateFromLayer(tileMapNode, "Physics", {
defaultFriction = 0.8,
bodyType = BT_STATIC,
})
AutoCreateFromLayer 的默认值就是 layerName = "Physics"、defaultFriction = 0.8、bodyType = BT_STATIC。三个都能省,但省掉层名意味着你的 Tiled 层必须正好叫 Physics,差一个字就静默返回 false,地图上没有碰撞,角色直接掉出屏幕。
它内部做四件事:
-- 1. 拿 TileMap2D 的 info(方向、tileWidth 都要用)
local tileMap = tileMapNode:GetComponent("TileMap2D")
info = tileMap.info
-- 2. 整张地图节点上创建/复用一个 RigidBody2D
local body = tileMapNode:GetComponent("RigidBody2D")
if not body then
body = tileMapNode:CreateComponent("RigidBody2D")
body.bodyType = bodyType
end
-- 3. 遍历对象层,逐个建形状
for i = 0, tileMapLayer:GetNumObjects() - 1 do
local obj = tileMapLayer:GetObject(i)
-- 按 objectType 分派到 _CreateBoxShape / _CreateCircleShape / ...
end
-- 4. 从对象自定义属性覆盖物理参数
if obj:HasProperty("Friction") then
shape.friction = tonumber(obj:GetProperty("Friction"))
end
两个索引细节必须记住:GetObject(i) 从 0 开始,GetPoint(i) 也从 0 开始。Lua 习惯从 1 开始,这里不是。写 for i = 1, count 会漏掉第一个对象并且越界拿到 nil。
矩形对象的位置要自己偏移
_CreateBoxShape 里有一行很容易被忽略:
if info.orientation == O_ORTHOGONAL then
shape.center = object.position + size / 2
end
Tiled 给的对象坐标是左上角,CollisionBox2D.center 是相对节点原点的偏移,必须加半个尺寸。少了这一步,所有碰撞体往左下角偏半个身位——地图上的墙看着在右边,碰撞在左边。
等距地图(O_ISOMETRIC)走另一条分支,额外加 shape.angle = 45 和 Vector2(info.tileWidth / 2, 0)。做等距项目时这两个值不要动。
从对象层拿路径
移动平台的路径也用同一张地图画。在 Tiled 里建一个 Moving 对象层,用折线工具画路径,然后把折线转成点数组:
local movingLayer = tileMap:GetLayer("Moving")
for i = 0, movingLayer:GetNumObjects() - 1 do
local object = movingLayer:GetObject(i)
if object.objectType == OT_POLYLINE and object.type == "Platform" then
local path = TilemapPhysics.CreatePathFromObject(object)
CreateMovingPlatform(path, tonumber(object:GetProperty("Speed") or "1.5"))
end
end
CreatePathFromObject 内部还是那个 0 基循环,可以传第二个参数做整体偏移。
四、PathFollower:一个"不碰物理"的移动器
PathFollower 是 ScriptObject,不是组件。挂法:
local PathFollower = require "urhox-libs.Physics2D.PathFollower"
local follower = node:CreateScriptObject(PathFollower)
默认值全在 Start() 里:
属性:默认值 说明
speed:0.8 米/秒
path:{} Vector2 数组
currentPathID:1 目标点索引(Lua 1 基)
loop:false 循环
reverse:true 往返
paused:false 暂停
默认是往返(loop=false, reverse=true),不是循环。要无限绕圈得显式 SetLoop(true)。
三种模式:
模式:loop reverse 轨迹
循环:true — A→B→C→D→A
往返:false true A→B→C→D→C→B→A
单程:false false A→B→C→D,然后 paused = true
SetPath 有个隐含行为:路径点 ≥ 2 个时,currentPathID 被设成 2——它把第一个点当作起始位置,节点先瞬移到 path[1] 再往 path[2] 走。想让平台从当前位置出发,第一个点就写当前位置。
follower:SetPath({
Vector2(0, 0), -- 起始位置,节点会被瞬移到这里
Vector2(6, 0),
Vector2(6, 3),
Vector2(0, 3),
})
follower:SetSpeed(2.0)
follower:SetLoop(true)
follower.onReachWaypoint = function(index) print("到点 " .. index) end
follower.onDirectionChange = function(dir)
sprite.flipX = dir.x < 0
end
Update(timeStep) 每帧算 targetPos - node.position2D,距离 ≤ 0.01 算到点,直接 node.position2D = targetPos 吸附,避免抖动。onDirectionChange 每帧都触发,别在里面做重活。
移动平台必须配 KINEMATIC 刚体
这是最容易漏的一条。PathFollower 只挪节点,它不知道刚体存在。平台只挂静态刚体,玩家站上去不会跟着走。
local body = platformNode:CreateComponent("RigidBody2D")
body.bodyType = BT_KINEMATIC -- 关键
local shape = platformNode:CreateComponent("CollisionBox2D")
shape.size = Vector2(2, 0.5)
shape.friction = 0.8
shape.categoryBits = 8
shape.maskBits = 2
BT_KINEMATIC 的含义是"由代码驱动位置、不接受物理力的刚体"。它把位置变化正确传给接触它的动态刚体,玩家才会被平台"推着走"。用 BT_STATIC 玩家会被平台穿过,用 BT_DYNAMIC 平台自己会掉下去。
五、踩过的坑
坑 1:脚底传感器碰到墙
-- ❌ 错误:传感器不设 mask,碰墙也算地面
local footSensor = playerNode_:CreateComponent("CollisionCircle2D")
footSensor.trigger = true
footSensor.center = Vector2(0, -0.45)
-- categoryBits / maskBits 全是默认值,跟谁都碰
-- ✅ 正确:只跟地形组判定
local footSensor = playerNode_:CreateComponent("CollisionCircle2D")
footSensor.trigger = true
footSensor.center = Vector2(0, -0.45)
footSensor.categoryBits = 4 -- 传感器组
footSensor.maskBits = 1 -- 只碰地形
症状是贴墙无限跳。触发器的 BeginContact 和普通碰撞一样会发出来。
坑 2:用 AABB 查询代替接触事件
-- ❌ 错误:每帧查脚底矩形,边缘处一帧命中一帧落空
local bodies = physicsWorld_:GetRigidBodies(Rect(
playerPos.x - 0.4, playerPos.y - 0.6, 0.8, 0.2))
onGround_ = #bodies > 0
-- ✅ 正确:接触计数 + EndContact 减一
groundContactCount_ = groundContactCount_ + 1 -- BeginContact 里
groundContactCount_ = groundContactCount_ - 1 -- EndContact 里
onGround_ = groundContactCount_ > 0
AABB 查询本身没错,错在拿它当"是否着地"的判据——它查的是这一帧的重叠,而物理步和渲染帧不同步。
坑 3:Tilemap 对象层名字对不上
-- ❌ 错误:Tiled 里层叫 "Collision",代码里写默认值
TilemapPhysics.AutoCreateFromLayer(tileMapNode)
-- 返回 false,日志一条 WARNING,地图上没有碰撞
-- ✅ 正确:名字必须精确匹配,且检查返回值
local ok = TilemapPhysics.AutoCreateFromLayer(tileMapNode, "Collision")
if not ok then
log:Write(LOG_ERROR, "碰撞层创建失败,检查 Tiled 里的层名")
end
AutoCreateFromLayer 返回 false 而不是抛异常。不检查返回值,你只会看到角色掉出屏幕。
坑 4:索引从 0 开始
-- ❌ 错误:Lua 习惯,第一个对象拿不到
for i = 1, layer:GetNumObjects() do
local obj = layer:GetObject(i) -- 漏掉索引 0,收尾还会越界
end
-- ✅ 正确:0 基,和 C++ API 一致
for i = 0, layer:GetNumObjects() - 1 do
local obj = layer:GetObject(i)
end
GetObject、GetPoint 都是 0 基,同一个函数里两种索引习惯混着用最容易出错。
坑 5:移动平台用静态刚体
-- ❌ 错误:静态刚体 + PathFollower,平台动了玩家不动
local body = node:CreateComponent("RigidBody2D")
body.bodyType = BT_STATIC
local follower = node:CreateScriptObject(PathFollower)
follower:SetPath(path)
-- ✅ 正确:KINEMATIC,位置变化会传给动态刚体
local body = node:CreateComponent("RigidBody2D")
body.bodyType = BT_KINEMATIC
local follower = node:CreateScriptObject(PathFollower)
follower:SetPath(path)
表现是玩家站着不动,平台从脚底滑过去。
坑 6:设了速度刚体不动
-- ❌ 错误:静止的刚体进入睡眠,设速度无效
playerBody_.linearVelocity = Vector2(5.0, 12.0)
-- ✅ 正确:设完速度唤醒
playerBody_.linearVelocity = Vector2(5.0, 12.0)
playerBody_.awake = true
只在"从完全静止起步"时会出现。跑动中的角色一直是醒的,所以这个坑往往到跳跃才暴露。
六、适用边界
2D 物理不是"有碰撞就用"。
该用 2D 物理的:
- 角色与地形的连续接触(平台跳跃、爬坡、滑行)
- 物体数量在几十个量级、行为要真实的(堆叠、弹跳、推挤)
- 移动平台、传送带、斜坡,需要"接触面带着物体走"
- 弹道类抛射物,需要重力与碰撞反弹
不该用 2D 物理的:
场景:为什么 该用什么
上百个子弹/弹幕:每个建 RigidBody2D,物理步开销随数量超线性增长 自己维护数组,每帧算位置
UI 元素、纯动画位移:物理是"模拟",不是"指定位置" 直接设 node.position2D
只判断"碰到没":建刚体纯属浪费 距离判定 / AABB 相交
大量同质小怪挤在一起:接触对数量爆炸 自写分离力,或空间网格
需要帧级精确的跳跃手感:物理步与渲染帧不同步,位置有插值误差 自己积分位置
性能量级上我的实测感受:50 个动态刚体以内的 2D 场景,一帧物理步在移动端可以忽略;到 200 个以上同屏互相接触,帧率开始明显掉;500 个以上基本没法跑。这个数跟形状复杂度强相关——圆形最便宜,多边形和链条最贵。
一句话判据:你要的是"模拟结果"还是"我指定的结果"? 要模拟用物理,要精确控制别用。
还有个边界要单独说:Tilemap 碰撞层不要画得太碎。一张 100×100 的地图,用矩形一格一格地画碰撞就是上万个 CollisionBox2D 挂在同一个刚体上,加载会明显卡顿。把连续地形合并成大矩形,一条长墙用一个矩形,别拿十个小方块拼。链条形状(Polyline)比一串矩形便宜,但它没有厚度——高速物体可能穿过去。
七、可迁移的经验
六条坑里,两条是"分层没做",两条是"索引和坐标没对齐",两条是"组件类型选错"。表面上不是一个问题。
它们有一个共同的判断方法:
物理表现"有点像又有点不对"的时候,先问"这个东西的判定依据是哪一层的数据"。
脚底传感器的判定依据是碰撞层,不是形状。Tilemap 碰撞的判定依据是对象坐标加偏移,不是视觉位置。移动平台的判定依据在刚体类型上,跟 PathFollower 有没有在跑没关系。参数全在正确的范围里,只是它们不在同一层。
我现在的排查顺序固定成三步:先把 PhysicsWorld2D 的 DrawDebugGeometry() 打开,看一眼碰撞体的实际位置和形状;再 print 一次接触计数和目标速度;参数留到这两步之后才碰。
前两步能解决我 90% 的问题。参数基本不用调。
-- 调试开关:Z 键切换物理可视化
if input:GetKeyPress(KEY_Z) then
debugDraw_ = not debugDraw_
end
if debugDraw_ and physicsWorld_ ~= nil then
physicsWorld_:DrawDebugGeometry()
end
(完)
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取截图
星火编辑器-新手教程-关于多游戏模式的配置和触发的获取
HELLO 大家好,我是阿木木牧。最近第一版的内部小范围(几人)第一个图的测试已经结束了。 整体游戏以装备数值的崩坏而告终~ (虽然大家都很欢快。。。) 现在准备开始开发下个阶段的内容啦,就是将游戏模式拆分出来。分成单人模式,多人组队模式(合作PVE),多人匹配模式(对抗模式)。那么,怎么配置呢?说来也很简单哈~ 1,在游戏编辑器中,数据编辑器,找到地图设置。 2,编辑地图设置。比如我希望最大数量
14 赞
3 回复
00:11
【个人案例分享】动态注册事件与创建触发器截图
【个人案例分享】动态注册事件与创建触发器
废话阶段: 一般我们刚接触星火的时候,使用触发编辑器里的触发器都是直接通过新建触发器来创建,然后往里面添加预先设置好的事件。 虽然能做出游戏,但这样子的触发器是全局的,后面想添加新的玩法或者内容可能会因为之前的全局触发器导致影响后面的新玩法或者内容。可能会有人想到用打开/关闭触发器或者移除触发器来控制,但这样子仍然不是很方便,所以有没有什么方法可以让某个触发器在必要的时候出现,不必要的时候不在。所
16 赞
3 回复
00:19
如何实现单位的随意拖拽摆放?【星火编辑器教程】截图
如何实现单位的随意拖拽摆放?【星火编辑器教程】
很多小伙伴想做自走棋类的游戏,那么就会涉及到单位的一个最基础的操作,就是选中单位并拖拽和摆放,那如何实现单位的拖拽摆放呢,今天就来教大家如何实现这样的功能。实现效果如下视频所示。 教程开始 第一步: 打开编辑器的触发编辑器,新建一个文件夹(我就叫“拖拽功能”,方便对拖拽功能的逻辑进行归类,如果你不建文件夹也没关系,习惯而已) 第二步: 点击文件夹右边的+号,添加一个变量,我这里叫“变量_目前选中的
19 赞
12 回复
不支持当前设备截图
不支持当前设备
荣耀400pro
2 赞
3 回复
05:19
1截图
1
1.完成后,获得信用点的增益依旧有效,可以卡无限信用点。理论上 2.部分天赋和在一起可以实现永续,那些天赋,我下一个发,因为只能传一个视频。 3.卡,人多点就卡,知道卡死,但是手机不会卡就游戏卡
2 赞
00:18
修复一下这个挂起过。有概率用不了的bug吧截图
修复一下这个挂起过。有概率用不了的bug吧
太绝望了[表情_豹哭][表情_豹哭][表情_豹哭][表情_豹哭]
1 赞
1 回复
无须申请,星火编辑器现已公测开放下载截图
无须申请,星火编辑器现已公测开放下载
—————————————————— 各位关注星火编辑器的小伙伴: 感谢大家一直以来对编辑器支持! 破晓已去,曙光降临! 星火编辑器现已开启公测! · 关于下载和资格申请 现已无需申请,请去下面的官方首页直接下载! 缘起测试是星火编辑器自诞生以来第一次对外测试! 从2022年八月开始到十一月,历时三个月。 缘起之后缘聚测试,历时又七个月! 缘聚之后,破晓降临,历时又十二个月! 曾经微弱的星火现已渐
官方
331 赞
375 回复
00:11
人物有小概率会卡着不往前走截图
人物有小概率会卡着不往前走
3 赞
1 回复