综述
移动行为是游戏中的一个基本概念,其最简形式可表达为向一个位置值上累加步进值。本文描述一种带容差滑动的平滑移动算法,并且可以适配至多种基于 tile 的场景。
目标
记得炸弹人的平滑移动手感吗?
当按下右方向键但是你的炸弹人脚边有块格子阻挡时,角色会先尝试在另一坐标轴向其(相对于阻挡格子)较易通行的方向移动一小段距离,直到不再被阻挡再继续向期望方向移动。
本文以此手感为目标,并实现一套可适用于不同角色、tile 大小的解决方案。
逐网格移动
从最简单的移动行为开始,按惯例 tile 地图以连续整型代表其索引:
用两个整型数分别代表 x,y 坐标。则移动和渲染代码为:
local pos = Vec2.new(0, 0)
local hero = Resources.load('hero.spr')
local ground = Resources.load('ground.map')
function update()
-- Grid-wise move.
if keyp(KeyCode.Left) then
pos.x = pos.x - 1
elseif keyp(KeyCode.Right) then
pos.x = pos.x + 1
end
if keyp(KeyCode.Up) then
pos.y = pos.y - 1
elseif keyp(KeyCode.Down) then
pos.y = pos.y + 1
end
-- Pixelated rendering.
map(ground, 0, 0)
spr(hero, pos.x * TILE_SIZE, pos.y * TILE_SIZE)
end
在这种情况下,对象位置和 tile 索引使用同一套格子单位。
逐像素移动
逐像素移动可以通过简单修改逐格子移动版本的最后一行 sprite 渲染得到:
local pos = Vec2.new(0, 0)
local hero = Resources.load('hero.spr')
local ground = Resources.load('ground.map')
function update()
-- Pixelated move.
if key(KeyCode.Left) then
pos.x = pos.x - 1
elseif key(KeyCode.Right) then
pos.x = pos.x + 1
end
if key(KeyCode.Up) then
pos.y = pos.y - 1
elseif key(KeyCode.Down) then
pos.y = pos.y + 1
end
-- Pixelated rendering.
map(ground, 0, 0)
spr(hero, pos.x, pos.y)
end
此时对象坐标以像素为单位,与渲染相同。表现上相较上一个版本更加平滑。
碰撞检测
在本文的理想化伪代码中,我们并未考量任何时间相关量,但为什么逐像素移动代码比逐网格移动代码看起来更简单些呢?不妨考虑添加与 tile 的碰撞检测来判断是否可移动,则代码为:
-- Grid-stepped.
function isMovable()
local index = pos + expDir
local tile = mget(ground, index.x, index.y)
local movable = not isBlock(tile)
return movable
end
在逐像素移动中我们需要在访问地图 tile 之前先进行一个从像素到网格的坐标转换:
-- Pixel-stepped.
function isMovable()
local newPos = pos + expDir
local index = Vec2.new(
math.floor(newPos.x / TILE_SIZE),
math.floor(newPos.y / TILE_SIZE)
)
local tile = mget(ground, index.x, index.y)
local movable = not isBlock(tile)
return movable
end
从这里开始代码复杂度会逐渐增加,但不必担心最终代码过于复杂。
位置坐标不再截断到网格单位。我们现在只能检查角色前进方向边沿所有可能碰到的 tile 的可通过与否,才能最终确定角色是否可以移动。
不妨称待检查的路径为“扫描线”。在真实项目中,扫描线越多则程序的 CPU 资源占用越高。由于 tile 的大小通常不至于小到只有 1x1 像素,在工程上这一求解过程可以简化为若干段带间距的扫描线,而不必逐像素计算。
local STEP_COUNT = 3
function isVerticallyMovable()
local step = CHAR_SIZE / STEP_COUNT
local beginX = pos.x + expDir.x
local endX = pos.x + expDir.x + CHAR_SIZE
local movable = true
for x = beginX, endX, step do
local newPos = pos + Vec2.new(x, pos.y + expDir.y)
local index = Vec2.new(
math.floor(newPos.x / TILE_SIZE),
math.floor(newPos.y / TILE_SIZE)
)
local tile = mget(ground, index.x, index.y)
if isBlock(tile) then
movable = false
break
end
end
return movable
end
做为额外的好处,采用扫描线的本算法亦适用于不同角色和 tile 大小。
滑动优化
在面对一条很窄的通路时,你仍然需要小心翼翼的将角色和地图元素对齐才能通过。为优化这一行为,首先将扫描线束的宽度略微缩小用以容差。
其次计算表示其畅通程度的通过率。
用畅通的扫描线数量除以总数得到通过率:
local SHRINK_SIZE = 1
local MOVABLE_RATE = 0.6667
local STEP_COUNT = 3
function isVerticallyMovable()
local step = (CHAR_SIZE - SHRINK_SIZE * 2) / STEP_COUNT
local beginX = pos.x + expDir.x + SHRINK_SIZE
local endX = pos.x + expDir.x + CHAR_SIZE - SHRINK_SIZE
local passed = 0
local total = 0
for x = beginX, endX, step do
local newPos = pos + Vec2.new(x, pos.y + expDir.y)
local index = Vec2.new(
math.floor(newPos.x / TILE_SIZE),
math.floor(newPos.y / TILE_SIZE)
)
local tile = mget(ground, index.x, index.y)
if not isBlock(tile) then
passed = passed + 1
end
total = total + 1
end
local movable = passed / total >= MOVABLE_RATE
return movable
end
最后,如果通过率大到足以移动但仍有部分阻挡,我们在另一坐标轴上进行微小滑动,然后继续向行走方向移动。
单向平台
在某些游戏中你可以跳上某一平台,然后平台会支撑着角色防止下落;或你控制角色通过一扇门,但无法原路返回……诸如此类,我们称其为单向平台或单向门。通过添加一个掩码参数以控制通行条件,我们得以实现这一行为。
local NONE, LEFT, RIGHT, UP, DOWN = 0, 1, 2, 4, 8
...
function isVerticallyMovable(passMask)
...
for x = beginX, endX, step do
...
local mask = expDir.y < 0 and UP or DOWN
if not isBlock(tile) or (passMask & mask ~= NONE) then
passed = passed + 1
end
total = total + 1
end
local movable = passed / total >= MOVABLE_RATE
return movable
end
通过向函数传入 UP
现在可以由下至上单向通过此平台。
应用场景
此算法非常适合顶视和横版平台跳跃游戏。通过适当构造场景结构,这一思路也能用于部分 3D 场景。
本算法已内置于 Bitty Engine 和 BASIC8。
Bitty Enginestore.steampowered.com BASIC8 on Steamstore.steampowered.com已知问题
穿透问题
本算法未做连续性检查。如果移动过程单步步长过大,则会造成穿透问题。可以把较大的步长分解成几段小的步骤规避此问题。
实数精度
IEEE-754 标准下的浮点数精度有限。当尝试在某一大地图边缘移动一小段距离时会出现精度问题。表现为无法准确向一个较大数值上加减另一个较小数值。使用双精度浮点数代替单精度能缓解此问题,但也只是把问题显现的时机推迟到更大的数值上了。一个可行的方案是给所有物体添加偏移量,使角色、地图、摄像机等在任意时刻的坐标都被投影在一个可精确计算的范围内。
实现代码
点击查看完整 C++ 代码
Smooth Tile-based Movement Algorithm with Slidingpaladin-t.github.io