瓦片地图 = 一张小图重复用 + 一张二维表记「哪格放什么」。 像素坐标换成瓦片坐标就是整除,换回去就是乘。 整张地图比窗口大得多,所以只画视口盖住的那几格——本例省掉一半多。
上一篇讲两个东西撞不撞,这一篇讲「世界本身怎么存」。 教材 6.2、6.3 节:【Tiled maps】【Map Colliders】。 Tiled 是画地图的工具,Colliders 是「地图上哪能走」。两节合起来就是一个能走的世界。 换算表、视口格数、角色压几格,都是真算的。
一、四个名词
| 名词 | 意思 |
|---|---|
| 瓦片 | 一张小图,比如 32×32 |
| 图集 | 很多瓦片拼成的一张大图 |
| 地图数据 | 一张二维表,每格记一个瓦片编号 |
| 图层 | 地面一层、障碍一层、装饰一层,各一张表 |
核心就一句话:世界不是画出来的,是查表拼出来的。 演示里地图 40 列 30 行、瓦片 32 像素,整张 1280×960,而窗口只有 800×600。 地图比窗口大,这就是「只画看得见的」的由来。 为什么用瓦片而不是一张巨图?因为巨图占内存、改起来麻烦;瓦片可以复用,改一格只改一个数字。 这是「用数据描述画面」的典型做法。
二、像素坐标和瓦片坐标
换算只有整除和乘法:像素除以瓦片宽高、取整,就是第几格;反过来乘回去,就是那格的左上角。 演示列了几个点:(31, 31) 还是第 0 格,(32, 32) 就是第 1 格了。 (100, 200) 落在第 3 列第 6 行,那格的左上角在 (96, 192)。 注意那个「差一点点」:100 不在 96 上,但它在第 3 格里。 整除干的就是这件事——把「在这一格里的哪个位置」抹掉。
这个换算在三个地方都要用:画的时候算贴图位置、走的时候算踩到哪格、鼠标点击的时候算点中了哪格。 写成一个函数,三处调用。
三、视口:只画看得见的
演示里镜头在 (400, 300),窗口 800×600。 算下来要画的瓦片是第 12 到 37 列、第 9 到 28 行,一共 520 格。 整张地图有 1200 格。520 / 1200 = 43.3%,省掉 680 格。 省下来的不只是一点点绘制,还有「遍历」本身。 地图越大,这个比例越夸张——一张 200×200 的地图,视口可能只盖住百分之几。
范围要多算一格:窗口边缘往往正好切在一格中间,那一格也得画。
算的时候用 (cam + 窗口宽 - 1) // 瓦片宽,那个减 1 就是为这个。
四、地图数据长什么样
演示用的是一张字符表:点表示能走、井号是墙、G 是草丛。 真项目里这张表是 Tiled 导出的 JSON 或者 CSV,每格是一个数字(瓦片编号)。 字符还是数字不重要,重要的是「它是一张二维表」。 为什么用字符演示?因为你能一眼看出地图长什么样。用数字的话,得对着图集一格格查。
这也是调地图时的实用技巧:先拿字符画个草图,再转成数字。草图阶段改起来快得多。
五、能不能走过去
判定就是查表:这一格的字符在不在「可走集合」里。
演示查了六格:(0,0) 能走、(10,2) 走不了(那是墙)、(11,3) 能走、(0,-1) 走不了、(40,5) 走不了。
越界的格子一律当墙。这一条能省掉后面无数个 IndexError——不写的话,角色走到地图边缘,
程序直接崩。
还有一条:可走集合里要不要包含「草丛」这种「能走但会减速」的地面? 这种格子建议单独一个集合,别硬塞进可走集合再到处判。
六、角色压住几格
角色不是一个点,它有自己的框。判定的时候要把它压住的所有格子都查一遍。
演示里角色框是 (90, 118, 24, 32),压住了第 2、3 列和第 3、4 行,一共 4 格。
四格里只要有一格是墙,这个位置就走不过去。
算法就是:左上角算一次、右下角算一次,两个范围里的格子全查。
(rx, ry) 和 (rx + rw - 1, ry + rh - 1) 各除一次。
那个减 1 不能少。不减的话,角色正好贴着格子边界时,会多算一格, 表现是「明明还没碰到墙就走不过去了」。
七、画和走是两套算法
画的时候按视口裁剪,一格一格画,格数由窗口大小决定。 走的时候按角色框判定,只看贴着的那几格,跟窗口大小无关。 两套算法别混在一起写。混着写的结果是:改镜头范围把碰撞改坏了,或者改角色尺寸把绘制改慢了。
同一份地图数据,两个不同的消费者。数据一份,用法两套——这是好设计的常见形状。
八、动手 Demo:瓦片地图:换算、视口裁剪与地图碰撞
先看地图比窗口大多少,再把像素坐标和瓦片坐标来回换一遍。 然后按镜头位置算出「要画哪几格」,跟整张图比一比省了多少。 最后查几条能不能走,再算角色一步压在几格上。
TILE, MAPW, MAPH = 32, 40, 30
W, H = 800, 600
WALKABLE = set('.G')
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 地图有多大
sec('① 地图和窗口')
info('地图 %d x %d 格,瓦片 %d 像素,共 %dx%d 像素'
% (MAPW, MAPH, TILE, MAPW * TILE, MAPH * TILE))
info('窗口只有 %dx%d,地图是它的 %.1f 倍宽' % (W, H, MAPW * TILE / float(W)))
# ② 坐标换算
sec('② 像素 <-> 瓦片')
for px, py in ((0, 0), (31, 31), (32, 32), (100, 200)):
info('(%d, %d) -> 第 %d 列第 %d 行,那格左上角 (%d, %d)'
% (px, py, px // TILE, py // TILE,
px // TILE * TILE, py // TILE * TILE))
# ③ 视口裁剪
sec('③ 只画看得见的')
camx, camy = 400, 300
x0, y0 = camx // TILE, camy // TILE
x1, y1 = (camx + W - 1) // TILE, (camy + H - 1) // TILE
vis = (x1 - x0 + 1) * (y1 - y0 + 1)
info('镜头 (%d, %d):列 %d 到 %d,行 %d 到 %d,共 %d 格'
% (camx, camy, x0, x1, y0, y1, vis))
info('整张图 %d 格,只画 %.1f%%' % (MAPW * MAPH, vis * 100.0 / (MAPW * MAPH)))
# ④ 能不能走
sec('④ 查表判断能不能走')
rows = ['....................',
'..GG..........GG....',
'..GG....###...GG....',
'........###.........']
def walk(tx, ty):
if tx < 0 or ty < 0 or tx >= MAPW or ty >= MAPH:
return False
if ty >= len(rows):
return True
return rows[ty][tx] in WALKABLE
for tx, ty in ((0, 0), (10, 2), (11, 3), (0, -1), (40, 5)):
info('(%d, %d) -> %s' % (tx, ty, '能走' if walk(tx, ty) else '走不了'))
# ⑤ 角色压几格
sec('⑤ 角色一步压几格')
rx, ry, rw, rh = 90, 118, 24, 32
xs = sorted({rx // TILE, (rx + rw - 1) // TILE})
ys = sorted({ry // TILE, (ry + rh - 1) // TILE})
info('角色框 (%d, %d, %d, %d) 压住列 %s 行 %s,共 %d 格'
% (rx, ry, rw, rh, xs, ys, len(xs) * len(ys)))
实跑输出:
--------------------------------------------------------------------
瓦片地图:切格子、算坐标、只看看得见的
--------------------------------------------------------------------
① 瓦片地图是什么
--------------------------------------------------------------------
| 名词 | 意思 |
|---|---|
| 瓦片 | 一张小图,比如 32×32 |
| 图集 | 很多瓦片拼成的一张大图 |
| 地图数据 | 一张二维表,每格记一个瓦片编号 |
| 图层 | 地面一层、障碍一层、装饰一层,各一张表 |
地图 40 列 × 30 行,瓦片 32 像素,整张地图 1280×960 像素
! 注意:整张地图比窗口大得多——这就是「只画看得见的部分」的由来。
② 像素坐标和瓦片坐标互相换
--------------------------------------------------------------------
| 像素坐标 | 瓦片坐标 | 那格的左上角 |
|---|---|---|
| (0, 0) | (0, 0) | (0, 0) |
| (31, 31) | (0, 0) | (0, 0) |
| (32, 32) | (1, 1) | (32, 32) |
| (100, 200) | (3, 6) | (96, 192) |
| (799, 599) | (24, 18) | (768, 576) |
! 整除就是换算的全部。(31,31) 和 (32,32) 差一格——格子是从 0 数到 31。
③ 视口:只看窗口盖住的那几格
--------------------------------------------------------------------
镜头在 (400, 300),窗口 800×600
要画的瓦片范围:列 12 到 37,行 9 到 28
一共 520 格(地图总共有 1200 格)
只画 43.3%,省下 680 格
! 这就是「只算看得见的」的全部内容:把遍历范围从整张图缩到视口。
④ 地图数据长什么样
--------------------------------------------------------------------
第 0 行 ....................
第 1 行 ..GG..........GG....
第 2 行 ..GG....###...GG....
第 3 行 ........###.........
第 4 行 ....................
(教材的地图是 40 行,这里截前 5 行看个样子)
! 点表示能走,井号是墙,G 是草丛。能走不能走,就是查这张表。
⑤ 能不能走过去
--------------------------------------------------------------------
| 瓦片坐标 | 结果 |
|---|---|
| (0, 0) | 能走 |
| (2, 1) | 能走 |
| (10, 2) | 走不了 |
| (11, 3) | 能走 |
| (0, -1) | 走不了 |
| (40, 5) | 走不了 |
! 越界的格子一律当墙。这一条能省掉后面无数个 IndexError。
⑥ 一次走一步要判几个格子
--------------------------------------------------------------------
角色有自己的框,比如 24×32。移动一格时,压在身上的最多有 4 个瓦片。
角色框 (90, 118, 24, 32) 压到的列:[2, 3],行:[3, 4]
一共 4 格
! 角色比格子小的时候最多压 4 格;比格子大就要多算几格。
⑦ 画的范围和走的范围是两件事
--------------------------------------------------------------------
画的时候按视口裁剪,一格一格画,画多少格由窗口大小决定。
走的时候按角色框判定,只看贴着的那几格。
! 两套算法别混在一起写。混写的代码,改一处动全身。
第二段那个 (31,31) 还是第 0 格、(32,32) 才是第 1 格,是这一篇最容易搞混的地方。
格子是从 0 数到 31 的,一个格子 32 像素宽,不是 32 个像素各占一格。
第三段那个 43.3% 是这台机器上学到的具体数。
地图越大,这个百分比越小——这就是「只画看得见的」能救命的根本原因。
第四段那个 walk() 里三行判断,顺序有讲究:先判越界再查表。反过来写,查表会先崩。
第五段那个 rw - 1 不能少。
少了之后,角色正好贴着格子边界时会多算一格,表现是「还没碰到墙就走不过去」。
差一像素的 bug 最难查。
整段代码不依赖 Tiled,
也不依赖 Cocos2d——地图的数据结构和算法跟工具无关。Tiled 只是帮你把这张表画出来而已。
30 秒实验
- 把
camx改成 0,重跑第三段,看视口范围变成多少格。 - 把
TILE改成 16,重跑第二段,看 (100, 200) 落在第几格。 - 在第四段的地图里加一行全是井号,看
walk()对那一行每一格的判断。
常见误区
- 「地图应该是一张巨图。」巨图占内存、改一格要重画整张。瓦片地图改一格只改一个数字。
- 「瓦片坐标乘以瓦片宽就是像素坐标,反过来除以也一样。」方向对,但容易把「格内偏移」忘了。 128 在 32 像素的格子里是第 4 格,但它在第 4 格里的偏移是 0。
- 「越界不用管,反正走不到。」角色走到地图边缘,不判越界就直接 IndexError 崩掉。 越界一律当墙,一行判断的事。
自测三问(点开对答案)
1. 像素 (100, 200) 在 32 像素的瓦片里是第几列第几行?那格的左上角在哪? —— 第 3 列第 6 行,那格左上角在 (96, 192)。100 // 32 = 3,200 // 32 = 6。 **注意 100 不等于 96——格子里的偏移是 4 像素。** 2. 为什么算视口范围要用 `(cam + 宽 - 1) // 瓦片宽`,那个减 1 是干什么的? —— 因为窗口右边缘往往正好切在一格中间,那一格也得画。 不减 1 会漏掉最后一列/行,表现为屏幕边上一条竖着的空白。 3. 角色是 24×32,为什么算压住几格要用 `rx + rw - 1`? —— 因为框的右边缘那一像素属于下一格,不能算进来。 不减 1 就会多算一格,角色贴着格子边界时会被判成「过不去」。世界搭好了,下一篇给它加两个「可编程」的东西:自己写的动作,和自己写的事件。
- ☐ (本篇完,下一篇 6.3 自定义 Action 与 Event)