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

资讯详情

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

2D开发实战:从基准选取到碰撞检测与物理回滚

2D开发实战:从基准选取到碰撞检测与物理回滚

1. “终于是2D的了”:2D技术为什么依然值得投入

1.1 标题里的梗与技术现实

“大型纪录片《终于是2D的了,就只冲这一点也要狠狠支持》持续为您播出!”这个标题在游戏玩家圈里经常出现,往往是某个系列作品从 3D 回归 2D,或者某个新作终于采用了 2D 画风时,玩家发出的“肯定”之声。它带着调侃,也带着真实期待:2D 不是落后,而是一种更清晰、更风格化、更能聚焦玩法本身的表达方式。

这个道理放在开发者视角同样成立。很多项目在立项时盲目追求 3D,结果资产制作成本翻倍、渲染优化困难、玩法反而被镜头控制淹没。后来团队重新评估,选择 2D 方案,项目才“活过来”。所以“终于是 2D 的了”不只是玩家的呐喊,也是一种技术上的“返璞归真”。本文就用一篇完整实战教程,带你从坐标系、碰撞检测、物理回滚到工程规范,把 2D 开发的全流程走一遍。

1.2 2D 开发的应用全景

2D 技术并不仅限于游戏。我们日常能接触到的场景至少包括:

  • 2D 游戏:平台跳跃、像素 RPG、塔防、卡牌、休闲小游戏。
  • 地图与图表系统:CAD 制图、室内设计、数据可视化、地理信息系统。
  • 机器视觉:工业 2D 缺陷检测、医学图像分割、OCR 文本区域定位。
  • Web 交互:Canvas 动画、H5 小游戏、低代码绘图工具。

从搜索引擎的热词也能看出,2D 相关需求非常密集:“2D图纸基准选取”“Unity 2D碰撞检测”“简单2D我的世界”“Missformer:2D医学图像分割”等,都是开发者真实会搜索的问题。本文会覆盖其中几个核心点,并给出可复现的代码和工程建议。

1.3 本文你将学到什么

读完本文,你可以掌握:

  • 2D 开发中坐标系与“基准选取”的核心思想。
  • 用 HTML5 Canvas 实现一个简易 2D 方块世界,并手动编写 AABB 碰撞检测。
  • 在 Unity 中正确配置 2D 碰撞体,区分碰撞事件和触发事件。
  • 理解 Godot 2D 物理跨平台回滚时“回滚不干净”的常见原因和解决思路。
  • 面对 2D 项目时,应该遵循的命名、基准、性能和异常处理规范。

说明一下,本文示例以“能跑起来”为目标,不会依赖某个特定版本的高级 API,但你需要根据自己本地的引擎版本做少量适配。

2. 2D 开发第一课:坐标系与基准选取

2.1 屏幕坐标、世界坐标与局部坐标

很多新手做 2D 开发时,第一个翻车点就是坐标系混乱。

在网页 Canvas 中,坐标系默认是“左上角为原点,x 轴向右,y 轴向下”。这一点和数学课上常见的坐标系不同,很多刚上手的朋友画图时发现图形上下颠倒,就是这个原因。

在 Unity 中,2D 场景虽然看起来是平面,但依然存在一个三维世界坐标,只不过 Unity 会把镜头放在 z 轴负方向,让所有物体沿 xOy 平面排布。Sprite 的每一张图片都有自己的局部坐标,局部坐标的原点由 Sprite 的 Pivot 决定,默认是图片中心。

在 Godot 中,2D 节点Node2D也有自己的局部坐标,子节点在父节点坐标系中定位。每个节点的 Position、Scale、Rotation 都是相对于父节点的。

这里先记住一个结论:做 2D 开发前,先确认三个坐标系分别是什么,再确认一个对象在每个坐标系中的原点位于哪里。

2.2 基准选取决定了后续对齐与碰撞

热搜词里有一个很务实的问题:2D 图纸基准选取。

在机械制图或 CAD 图纸中,基准是用来标注尺寸、公差和对准关系的参考点或参考线。选错了基准,图纸看起来尺寸都对,但加工出来的零件无法装配。

这个思想在 2D 游戏开发中完全一致。我把“基准”理解为一个 Sprite 或 Collider 的“锚点中心”。举例来说:

  • 一个角色角色的左手持武器,如果武器 Sprite 的 Pivot 设置在中心,那么武器旋转时,枪口位置会偏离预期,子弹发射点就会偏。
  • 一个平台跳跃游戏的 BoxCollider2D 的 Offset 如果和 Sprite 图片中的实际方块区域不一致,玩家会“隔空碰撞”或“踩在空气上”。
  • 一张 2D 地图在 Canvas 中绘制时,如果瓦片地图的原点不在左上角,坐标换算就会整体偏移。

因此,基准选取不是美术问题,而是逻辑问题。开发者在创建 2D 资产时,就应该明确:

  • 图片的锚点放在哪里?
  • 碰撞体的偏移对齐哪个点?
  • 地图原点是左上角还是左下角?
  • 旋转中心是否与视觉重心一致?

2.3 最小示例:在 Canvas 中设置基准点

下面用 Canvas 展示一个最简单的基准点效果。我们绘制一个方块,并让它按不同基准点旋转。

<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>2D 基准点示例</title> <style> canvas { border: 1px solid #ccc; display: block; margin: 16px auto; } </style> </head> <body> <canvas id="canvas" width="300" height="200"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 左侧:以左上角为基准点绘制并旋转 ctx.save(); ctx.translate(50, 50); ctx.fillStyle = '#4CAF50'; ctx.fillRect(0, 0, 60, 60); ctx.strokeStyle = '#333'; ctx.strokeRect(0, 0, 60, 60); ctx.rotate(Math.PI / 8); ctx.globalAlpha = 0.5; ctx.fillStyle = '#FF9800'; ctx.fillRect(0, 0, 60, 60); ctx.globalAlpha = 1; ctx.restore(); // 右侧:以中心为基准点绘制并旋转 ctx.save(); ctx.translate(200, 80); ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); ctx.fillStyle = '#E91E63'; ctx.fill(); ctx.stroke(); ctx.fillStyle = '#2196F3'; ctx.fillRect(-30, -30, 60, 60); ctx.strokeStyle = '#333'; ctx.strokeRect(-30, -30, 60, 60); ctx.rotate(-Math.PI / 8); ctx.globalAlpha = 0.5; ctx.fillStyle = '#FF9800'; ctx.fillRect(-30, -30, 60, 60); ctx.globalAlpha = 1; ctx.restore(); </script> </body> </html>

在这段代码中,左侧代码以(50, 50)为局部坐标系原点,方块在 x 和 y 上都是正方向绘制,所以旋转时绕“左上角”发生;右侧代码把原点放在(200, 80),绘制时记录方块中心为局部(0, 0),所以旋转时绕“中心”发生。

从这个小例子可以看出:修改基准点,就是修改绘制和旋转的参照位置。后续所有 2D 对象的碰撞、旋转、拖拽,都遵循同样的逻辑。

3. 环境准备与版本说明

3.1 浏览器端:无需安装

Canvas 和 JavaScript 部分不需要额外安装环境。你只需要一个现代浏览器,例如 Chrome、Edge、Firefox。推荐使用 VS Code 写代码,在浏览器中打开 HTML 文件即可运行。如果你需要加载本地图片,建议用python -m http.server或 VS Code 的 Live Server 起一个本地静态服务,避免浏览器对file://协议的限制。

python -m http.server 8080

然后在浏览器中访问http://localhost:8080/index.html。

3.2 Unity 2D 项目环境

Unity 2D 开发需要安装 Unity Hub 和编辑器。建议使用近期的 LTS(长期支持)版本,例如 2021 LTS 或 2022 LTS。打开 Unity Hub 后,创建项目时选择 “2D Core” 模板。需要注意:

  • 2D 模板默认使用 Sprite Renderer 支持。
  • 相机默认正交投影(Orthographic),而不是透视投影(Perspective)。
  • 素材导入时,如果图片不是 Sprite 类型,需要把 Texture Type 设置为 Sprite(2D and UI),否则无法拖入场景。

版本号不用纠结,本文示例使用的基础 API 在各版本中保持一致。

3.3 Godot 2D 项目环境

Godot 4.x 是目前的主流版本。从官网下载安装后,创建项目时选择 2D Scene 即可。Godot 自带强大的 2D 物理引擎,但有一个老问题:同一套输入回放逻辑在不同的操作系统或硬件平台上,物理模拟结果可能不一致,导致回滚不干净。这是很多网络同步游戏开发者的痛点,第 6 节会详细讲。

4. 实战:用 Canvas 做一个简易 2D 方块世界

4.1 需求拆解

热搜词中有一个“简单2d我的世界”,我们把它改造成适合教程的案例:实现一个由方块组成的 2D 小世界。

需求如下:

  • 画一个 8×6 的方块网格。
  • 利用键盘方向键控制一个小人移动。
  • 小人不能穿过方块(AABB 碰撞检测)。
  • 鼠标点击一个方块时,可以切换这个方块的“可用/不可用”状态。

从功能拆分来看,整个项目包括三个核心模块:

  1. 地图数据:用二维数组存储每个格子是否可通行。
  2. 渲染模块:遍历二维数组,把每个格子绘制到 Canvas 上。
  3. 角色模块:根据键盘输入更新位置,然后与地图做碰撞检测。

4.2 HTML 与 CSS 骨架

先创建一个index.html文件。

<!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>简单 2D 方块世界</title> <style> body { background: #222; color: #eee; font-family: "Microsoft YaHei", sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; } #gameContainer { background: #1e1e1e; padding: 16px; border-radius: 8px; } canvas { display: block; background: #000; } .tips { margin-top: 8px; font-size: 14px; color: #aaa; text-align: center; } </style> </head> <body> <div id="gameContainer"> <canvas id="gameCanvas" width="480" height="360"></canvas> <div class="tips"> 方向键移动 | 鼠标点击方块切换状态 </div> </div> <script src="game.js"></script> </body> </html>

这段代码定义了一个 480×360 的画布,格子宽度设为 60px,也就是横向 8 个格子、纵向 6 个格子。使用外部game.js管理逻辑,结构更清晰。

4.3 JavaScript 核心逻辑

创建game.js,代码如下。

const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); const COLS = 8; const ROWS = 6; const TILE = 60; // 地图数据:1 表示障碍物,0 表示可通行 const map = [ [1, 1, 1, 1, 1, 1, 1, 1], [1, 0, 0, 0, 0, 0, 0, 1], [1, 0, 0, 1, 1, 0, 0, 1], [1, 0, 0, 1, 1, 0, 0, 1], [1, 0, 0, 0, 0, 0, 0, 1], [1, 1, 1, 1, 1, 1, 1, 1], ]; // 玩家初始位置(像素坐标,中心点位于格子内部) const player = { x: 2 * TILE + TILE / 2, y: 2 * TILE + TILE / 2, size: 40, // 碰撞盒边长 speed: 3, }; function drawMap() { for (let row = 0; row < ROWS; row++) { for (let col = 0; col < COLS; col++) { const x = col * TILE; const y = row * TILE; if (map[row][col] === 1) { ctx.fillStyle = '#724c2f'; ctx.fillRect(x, y, TILE, TILE); ctx.strokeStyle = '#4a2f1a'; ctx.strokeRect(x + 0.5, y + 0.5, TILE - 1, TILE - 1); } else { ctx.fillStyle = '#3a3a3a'; ctx.fillRect(x, y, TILE, TILE); ctx.strokeStyle = '#555'; ctx.strokeRect(x + 0.5, y + 0.5, TILE - 1, TILE - 1); } } } } function drawPlayer() { const x = player.x - player.size / 2; const y = player.y - player.size / 2; ctx.fillStyle = '#ffcc00'; ctx.fillRect(x, y, player.size, player.size); ctx.strokeStyle = '#fff'; ctx.strokeRect(x, y, player.size, player.size); } // 判断玩家是否与障碍物碰撞 function collidesWithMap(px, py) { const half = player.size / 2; const left = px - half; const right = px + half; const top = py - half; const bottom = py + half; const startCol = Math.floor(left / TILE); const endCol = Math.floor(right / TILE); const startRow = Math.floor(top / TILE); const endRow = Math.floor(bottom / TILE); for (let row = startRow; row <= endRow; row++) { for (let col = startCol; col <= endCol; col++) { if (map[row][col] === 1) { return true; } } } return false; } function movePlayer(dx, dy) { const nextX = player.x + dx * player.speed; const nextY = player.y + dy * player.speed; // 分轴检测,保证可以沿墙滑动 if (!collidesWithMap(nextX, player.y)) { player.x = nextX; } if (!collidesWithMap(player.x, nextY)) { player.y = nextY; } } let keys = {}; document.addEventListener('keydown', (e) => { keys[e.code] = true; }); document.addEventListener('keyup', (e) => { keys[e.code] = false; }); canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const col = Math.floor(mx / TILE); const row = Math.floor(my / TILE); if (col >= 0 && col < COLS && row >= 0 && row < ROWS) { map[row][col] = map[row][col] === 1 ? 0 : 1; } }); function gameLoop() { let dx = 0; let dy = 0; if (keys['ArrowLeft']) dx = -1; if (keys['ArrowRight']) dx = 1; if (keys['ArrowUp']) dy = -1; if (keys['ArrowDown']) dy = 1; movePlayer(dx, dy); ctx.clearRect(0, 0, canvas.width, canvas.height); drawMap(); drawPlayer(); requestAnimationFrame(gameLoop); } gameLoop();

4.4 运行与验证

用浏览器直接打开index.html,或者使用本地服务器启动后访问。页面会出现一个暗色背景的方块地图,角色是一个黄色方块。

演示逻辑:

  • 按下方向键,角色可以在地图内移动。
  • 碰到棕色的障碍方块时,角色无法继续前进。
  • 用鼠标点击任意方块,棕色方块会变成暗色可通行方块,再次点击则变回障碍物。
  • 注意地图外圈都是障碍物,角色不会走出屏幕。

4.5 小细节:碰撞检测为什么用 AABB

上面的collidesWithMap函数使用了最简单的 AABB(Axis-Aligned Bounding Box,轴对齐包围盒)碰撞检测。做法是把玩家角色抽象成一个矩形,把地图上的每个格子当作一个矩形,然后检查两个矩形在 x 轴和 y 轴上是否有重叠。

在 2D 游戏中,AABB 的特点是“计算快、实现简单”,很适合大量物体的粗略碰撞检测。但也有局限:如果物体旋转后,AABB 会变大,不适合精确模拟。这时候可以考虑圆形碰撞体、Polygon Collider2D 或物理引擎内置的碰撞体组件。

这里的关键点是:碰撞检测的基准,其实就是碰撞盒的位置。角色的player.x和player.y默认存的是中心点,所以在绘制矩形时,要减去size / 2来得到左上角。如果基准不一致,就会出现一个角色“看起来没碰到障碍物,却被挡住了”的诡异现象。

5. 实战:Unity 2D 碰撞检测与触发事件

5.1 场景搭建要点

在 Unity 中创建新场景,然后按下面步骤搭建:

  1. 在 Hierarchy 窗口右键创建 2D Object → Sprites → Square。
  2. 给方块添加Box Collider 2D。
  3. 创建一个玩家物体,可以使用简单 Sprite 或空物体挂Sprite Renderer。
  4. 给玩家添加Rigidbody2D和Box Collider2D。

这里有一个新手很容易踩的坑:Rigidbody2D是刚体组件,负责物理模拟;Collider2D是碰撞体,负责外形。只有碰撞体没有刚体时,物体不会受到物理作用。只有刚体没有碰撞体时,物理系统无法计算碰撞。

在 2D 项目中,如果玩家由脚本控制移动,建议把Rigidbody2D的Body Type设为Dynamic,但把Gravity Scale设为 0。不要直接修改transform.position移动刚体,应该使用Rigidbody2D.MovePosition或直接设置linearVelocity。

5.2 玩家移动脚本

在Assets下创建Scripts文件夹,然后新建一个PlayerController.cs。

using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed = 5f; private Rigidbody2D rb; private Vector2 inputDirection; void Start() { rb = GetComponent<Rigidbody2D>(); } void Update() { float horizontal = Input.GetAxisRaw("Horizontal"); float vertical = Input.GetAxisRaw("Vertical"); inputDirection = new Vector2(horizontal, vertical).normalized; } void FixedUpdate() { // 物理运算应在 FixedUpdate 中进行 rb.MovePosition(rb.position + inputDirection * moveSpeed * Time.fixedDeltaTime); } }

这里为什么使用FixedUpdate?因为 Unity 的物理引擎以固定步长运行,MovePosition是一个物理移动接口。如果在Update中移动,帧率变化会导致移动速度不稳定,也容易与物理碰撞产生抖动。

5.3 碰撞体与基准偏移的关系

在 Unity Inspector 中选中玩家物体,可以看到Box Collider2D有一个Offset属性。这个 Offset 就是碰撞体相对于物体 Transform 原点的偏移。

如果 Sprite 图片的 Pivot 在中心,物体 Transform 原点在中心,碰撞体也自动在中心,那么碰撞的基准是合适的。如果美术导出图片时留了空白边,或在图片中角色位置偏左,那么 Sprite 的中心点和玩家视觉中心就不一致,碰撞体看起来会“偏”。

这里可以用热搜词中的“2D图纸基准选取”来类比:图纸选了错误的基准会导致尺寸链失准,碰撞体选了错误的偏移会导致玩家明明站在平台上,却掉下去。

正确的做法:

  • 在导入 Sprite 时,设置正确的 Pivot 位置。
  • 在编辑模式下,根据实际游戏逻辑调整 Collider2D 的 Offset。
  • 不要在运行时频繁修改碰撞体 Offset,容易造成物理状态混乱。

5.4 触发事件与碰撞事件

碰撞检测分为两类:

  • 碰撞事件:两个物体发生实际碰撞,互不相穿。
  • 触发事件:作为“区域”使用,可以相互穿过,但会触发回调。

碰撞回调示例:

using UnityEngine; public class CollisionLogger : MonoBehaviour { void OnCollisionEnter2D(Collision2D collision) { Debug.Log($"碰撞开始:{collision.gameObject.name}"); } void OnCollisionExit2D(Collision2D collision) { Debug.Log($"碰撞结束:{collision.gameObject.name}"); } }

触发回调示例:

using UnityEngine; public class TriggerLogger : MonoBehaviour { void OnTriggerEnter2D(Collider2D other) { Debug.Log($"进入触发区域:{other.gameObject.name}"); } void OnTriggerStay2D(Collider2D other) { // 在触发区域内持续调用 } void OnTriggerExit2D(Collider2D other) { Debug.Log($"离开触发区域:{other.gameObject.name}"); } }

使用触发事件前,必须把 Collider2D 组件上的Is Trigger勾选为 true。注意:如果两个物体都带碰撞体,其中一个勾选了 Is Trigger,就不会产生物理阻挡,但会触发回调。

判断是否回调触发可以根据应用场景练习:

  • 敌人突然出现、宝箱检测、玩家进入区域提示:使用 Trigger。
  • 角色踩到地面、墙阻挡、子弹击中目标:使用 Collision。

5.5 运行验证与结果

把PlayerController挂到玩家物体上,把CollisionLogger挂到其中一个方块上。运行游戏后,按下 WASD 或方向键移动玩家,观察 Console 窗口中的日志输出。

预期结果:

  • 玩家靠近方块时,日志打印“碰撞开始”。
  • 玩家离开方块后,日志打印“碰撞结束”。
  • 如果玩家在移动过程中无法穿过方块,说明 Box Collider2D 配置正确。

如果玩家直接穿过方块,最常见原因有三个:

  • 玩家物体没有挂 Rigidbody2D。
  • 碰撞体组件被禁用。
  • 在脚本中直接修改了transform.position,绕过了物理系统。

6. 进阶:Godot 2D 物理跨平台回滚不一致问题

6.1 现象与原因

“Godot physics 2d 跨平台 rollback 时回滚不干净”是一个比较专业的网络热词,常见于使用 Godot 开发联网同步游戏或帧同步游戏的团队。

简单解释一下现象:为了保证所有客户端看到相同的游戏状态,游戏引擎会定期记录“足够的信息”,当网络延迟或丢包后,客户端需要回滚到某个旧状态,然后重新模拟这段时间的输入。按理说,只要输入相同,最终的物理状态就应该相同。但 Godot 2D 物理在不同平台、不同机器上运行的结果却可能不一样,导致回滚后出现瞬移、物体轻微错位、关闭弹开角度不同等问题。

根本原因通常包括:

  1. 浮点数精度差异:不同平台的 CPU 对浮点运算有微小差异,经过多次物理模拟后误差放大。
  2. 物理步长不一致:如果使用变帧率或_physics_process(delta)的 delta 不一致,物理模拟结果会不同。
  3. 刷新率差异:高刷新率屏幕会在一次物理 tick 内插入更多帧,导致时间分配差异。
  4. 使用非确定性随机函数:例如randf()如果没有使用固定种子,在同一时间内不同客户端会产生不同随机数。
  5. 使用了依赖平台状态的功能:例如OS.get_ticks_msec()、Time.get_ticks_usec()、物理设备返回的实时输入顺序。

6.2 回滚设计的基本思路

一个比较稳妥的思路是“只回滚输入,不回滚物理状态”。做法如下:

  • 每个客户端只广播玩家的输入指令,不直接广播物理坐标。
  • 服务器或主机维护固定步长的逻辑时钟。
  • 每条输入指令带有准确的逻辑帧编号。
  • 客户端在回滚时,先恢复逻辑帧对应的“状态快照”,再按照固定步长重放输入。
  • 重放期间禁用所有实时时钟,只使用逻辑帧时间。

这个方法的核心是:确保输入是确定性的,而不是依赖实时物理状态。回滚不是“还原物理模拟结果”,而是“用相同的确定性输入重算一遍”。

GDScript 片段如下,仅演示固定步长思路:

# 示例思路,需按 Godot 实际版本调整 const TICK_RATE := 60 const TICK_TIME := 1.0 / 60.0 var accumulator := 0.0 func _physics_process(delta: float) -> void: accumulator += delta while accumulator >= TICK_TIME: _apply_network_inputs() _step_simulation(TICK_TIME) accumulator -= TICK_TIME

看起来很简单,但实际项目中会遇到一个关键问题:_physics_process(delta)本身是引擎按真实时间调用的,如果两次调用之间的 delta 有抖动,累积器会把“额外时间”积攒起来,但物理状态重放必须保证每次TICK_TIME完全相等。因此,真正的回滚逻辑不该直接依赖引擎自带物理,而是自己维护一个确定性物理步骤。

6.3 避免回滚不干净的检查清单

如果你正在做 Godot 2D 帧同步或回滚同步,建议按下表自检:

检查项建议
物理步长是否固定使用自定义逻辑时钟,严格固定 tick 间隔
浮点数是否统一避免在不同平台依赖不同精度,必要时用定点数替代浮点数
随机数是否可控所有随机数使用种子随逻辑帧生成
输入是否确定性输入指令带顺序编号,不直接采样系统事件
是否混用实时时间禁用Time.get_ticks_usec()作为逻辑时间
是否直接修改物理节点状态回滚时先恢复快照,再重新模拟,不直接设置位置

不要寄希望于物理引擎自动保持一致。2D 物理引擎的确定性本身就不是所有引擎都保证的,尤其是跨平台场景下,需要开发者自己构建一层“确定性封装”。

7. 常见问题与排查思路

7.1 表格速查

问题现象常见原因解决思路
Canvas 绘制模糊Canvas 尺寸与 CSS 尺寸不一致用canvas.width与canvas.height控制物理像素,避免拉伸
Canvas 点击位置偏移没有考虑页面滚动或 Canvas 缩放使用getBoundingClientRect()换算鼠标坐标
Unity 中角色穿墙没有 Rigidbody2D 或直接修改 Transform在FixedUpdate中使用MovePosition
Unity 碰撞回调不触发两个物体都没有刚体,或碰撞层级忽略确保至少一个物体挂 Rigidbody2D,检查 Layer Collision Matrix
Trigger 不触发忘记勾选Is Trigger勾选对应 Collider 的Is Trigger属性
Godot 跨平台回滚位置偏移回滚依赖物理引擎实时状态改为固定逻辑帧 + 确定性输入重放
2D 医学图像分割中点选错位使用了 UI 坐标当作图像坐标区分像素坐标、模型输入坐标和世界坐标

7.2 深挖:2D 图纸基准选取

如果你是在做 CAD 或机械制图相关的 2D 功能,基准选取的问题会更加突出。一个典型场景是:在图纸上设计一个零件,基准放在左下角,但程序读取时默认把图片左上角当作原点,导致标注尺寸全部偏移。

解决思路:

  • 在图纸文件格式中明确记录基准点坐标。
  • 程序加载图纸时先读基准点,再计算所有几何对象相对基准点的位置。
  • 在导出图纸时,统一约定基准位置,例如固定在左下角。
  • 在 UI 中提供“重新对基准”功能,方便用户手动校准。

这个原则和游戏开发里的 Pivot、Collider Offset 完全一致:2D 世界里的坐标系是一种约定,代码需要把约定转换成唯一合法的坐标体系。

7.3 深挖:2D 医学图像分割与缺陷检测

热搜词中有一个 “Missformer: An Effective Transformer for 2D Medical Image Segmentation”。这说明 2D 技术也大量出现在深度学习和机器视觉领域。注意,这里的“2D”通常指单张二维图像输入,而不是视频或三维体数据。

在 2D 医学图像分割或工业 2D 缺陷检测中,常见问题包括:

  • 图像坐标与真实世界坐标混淆。
  • 标注数据和原图分辨率不一致。
  • 类别不平衡,导致少数类分割效果差。
  • 模型输出是概率图,需要设置阈值并做连通域过滤。

这些项目对基准和坐标的要求同样严格。比如在缺陷检测中,一个缺陷框在图像坐标系中的中心点,和物理相机坐标系下的实际位置,两者之间的换算关系就是“2D 图纸基准选取”的活生生案例。

建议实际项目中使用字典结构统一存储图像元信息,包括图像尺寸、基准点、像素间距、坐标原点位置,不要分散在多个变量中。

8. 最佳实践与工程建议

8.1 统一基准,消灭魔法数字

无论你是写 Canvas、Unity 还是 Godot,都建议把“基准点”作为对象设计的一部分,而不是随手写一个数字。可以这样做:

  • Sprite 图片导入时,统一使用固定 Pivot 规范,例如人物素材中心点放在角色脚底或身体中心,武器素材放在握把处。
  • 碰撞体 Offset 在编辑器中配置后,导出到团队文档。
  • 地图瓦片原点统一为左上角。
  • 所有配置项集中到一个配置文件中,不散落在代码里。

一句话总结:“2D图纸基准选取”不是设计师一个人的问题,而是前端、后端、美术、测试之间需要共同维护的接口约定。

8.2 物理步长与确定性优先

如果你的 2D 项目涉及网络同步,请从项目初期就考虑确定性:

  • 使用固定逻辑帧,不把实时帧率作为逻辑时间。
  • 所有物理模拟只依赖逻辑输入和固定种子随机数。
  • 回滚功能需要与状态快照配合,快照要记录对象的坐标、旋转、线速度、角速度。
  • 不要在物理模拟中插入平台相关的 API。

如果项目只是单机小游戏,可以适当降低确定性要求,但仍建议固定步长,避免高刷新率屏幕下角色移动速度异常。

8.3 资源与目录结构

2D 项目很容易出现资源混乱的问题。可以参考下面的目录划分:

Assets/ Scenes/ Scripts/ Sprites/ Player/ Enemy/ Tiles/ Animations/ Prefabs/ Data/

Canvas 项目同理:

project/ assets/ images/ sounds/ src/ game.js map.js player.js index.html

命名规范:

  • 文件名使用英文,不要带空格和中文。
  • 同类型资源使用相同前缀,例如player_idle_0.png、player_run_0.png。
  • 碰撞体配置和 Sprite 资源配置写在文档里,方便团队同步。

8.4 2D 检测或分割类项目的额外提醒

如果你的“2D”项目是 Machine Vision,那么最佳实践会有些不同:

  • 在存储数据时,保存原始图像尺寸,不要因为显示缩放而丢失元数据。
  • 使用独立的坐标转换模块,统一处理像素坐标、模型输入坐标和世界坐标。
  • 训练集和测试集划分要参考同一基准信息,不要在同一张图上多次标注。
  • 对模型输出做后处理时,保持阈值、最小面积、最大面积等参数可配置。

这部分虽然和游戏开发路径不同,但核心思维一致:坐标系、基准、输入和输出之间的映射必须可追溯。

8.5 异常处理、日志与回滚

在开发过程中,不要只用Debug.Log打印零散信息。建议:

  • 使用统一的日志格式,例如[时间][级别][模块] 消息。
  • 在回滚逻辑中,打印帧号和输入指令,方便对比不同客户端是否一致。
  • 捕获异常时保留上下文信息,不要吞掉异常。
  • 对破坏性操作,例如清空地图、重置玩家位置,加条件判断或二次确认。

生产环境中的 2D 游戏或工具,命名、日志、数据校验和可重复运行能力同样重要。一个“看起来能跑”的小项目,和“能稳定上线”的工程产品,差距往往就在这些细节里。

9. 总结与学习路线

本文从一个玩梗的标题出发,实际上拆解了 2D 开发中最重要的几个技术点。

你已经看到了:

  • 2D 坐标系和基准选取是 2D 开发的地基,所有碰撞、旋转、对齐都依赖它。
  • Canvas 实战里,我们用纯 JavaScript 实现了一个可交互的 2D 方块世界,并写出了 AABB 碰撞检测。
  • Unity 2D 实战中,我们理解了 Rigidbody2D、Collider2D、碰撞事件和触发事件的区别。
  • Godot 2D 物理跨平台回滚不一致的问题,核心原因在于不确定性和浮点差异,解决思路是固定逻辑帧和确定性输入重放。

如果你打算继续深入学习,可以从这几个方向中选择一个展开:

  • 学习更多 2D 碰撞算法,例如圆形碰撞分离轴定理(SAT)、像素级碰撞检测、空间哈希优化。
  • 学习 Unity 或 Godot 的动画状态机,给 2D 角色加入待机、跑步、攻击动画。
  • 学习 2D 地图工具 Tiled,将地图数据导出后接入游戏引擎。
  • 如果对视觉感兴趣,可以研究 2D 缺陷检测或医学图像分割中的坐标变换和模型后处理。

实话说,2D 开发的门槛并不比 3D 低多少。它只是把复杂度从渲染和模型,转移到了设计、手感、确定性这些更“细腻”的地方。正因如此,“终于是 2D 的了”才常常意味着团队终于找到了最适合自己的技术路线。

希望这篇文章能帮你少踩一些坑。如果你在搭建过程中有自己的踩坑经历,也欢迎在评论区分享,后续我可以结合更多真实问题补充分章教程。

返回列表