游戏开发中的长地图和摄像机

在一些横版游戏中,需要搭建超过一个屏幕的长地图,然后控制角色在整个地图中进行移动。本文主要讨论通过 tiled map 实现长地图场景,然后通过控制摄像机的方式访问整个地图。

<!--more-->

1. 瓦片地图

1.1. 什么是瓦片地图

某些游戏会存在超过屏幕大小的背景地图,如果通过切换背景图的方式来切换背景,需要提前准备很多背景图,这些图片体积较大,会造成资源浪费。

2d 游戏是由一张张图片构成,图片上总是有相同部分,把这些相同的,可以重复利用的部分看成盖房子的瓦片。这样只需要利用重复拼接,就能创造出各类图片。

瓦片地图是由一张一张的正方形小图片拼接成的地图,把不同的瓦片拼接在一起,就可以组成完整的地图,例如炸弹人,QQ 堂都是非常典型的瓦片游戏。

tiled是一款比较流行的制作瓦片地图的编辑工具,下面介绍 tiled 的一些基础概念

  • 图块资源,导入一张图片,可以选择图片上的部分图片作为瓦片图块
  • tsx 文件,引用图块资源的 xml 文件,注意这里不是 react 的 tsx 文件,而是一个 xml 描述文件
  • tmx 文件,引用 tsx 的地图描述文件,其本质是一个三维数组,第一维是图层,第二三维就是描述地图的二维数组,每个元素使用贴图 id 填空
    • 图层,图层主要用于隔离不同层级的贴图,方便管理和层叠限制;在同一个图层中,一个方块只能放置一张贴图
    • 对象层,主要用于需要进行移动、变换形态、显示或隐藏等逻辑的元素,可以通过 API 得到对应对象

其出入是图块资源文件,输出是 tsx 和 tmx 文件,最后将这三个文件放在游戏项目下,就可以通过 tmx 在游戏中渲染出对应的地图了

1.2. 创作瓦片地图

下面收集了一些介绍一些 tiled 教学视频,需要多加练习

在瓦片地图中甚至可以制作一些简单的帧动画,比如地图上面飘曳的火把、闪烁的灯光等等,减少在游戏引擎中的开发工作。

在制作时需要注意当前层,以及及时保存等习惯,否则可能会将图片放置在错误的图层上面,最后通过 api 获取到的图块异常,肉眼却看不来是什么问题。

也可以通过使用查看tsxtmx等 xml 文件的内容,来排查最后产出的地图文件是否符合预期,

1.3. 在 cocos 中使用瓦片地图

参考

下面是访问瓦片地图中某个层某个瓦片单元格的demo代码

export default class NewClass extends cc.Component {
    @property(cc.TiledMap)
    map: cc.TiledMap = null;

    onLoad() {
        this.initMap();
    }

    initMap() {
        // 获取瓦片宽度
        let tiledSize = this.map.getTileSize();
        // 通过layerName获取地图中的层,layerName是在tiled中制作地图时声明的
        let layer = this.map.getLayer("ground");

        // 获取瓦片地图的尺寸,单位是个
        let layerSize = layer.getLayerSize();

        for (let i = 0; i < layerSize.width; ++i) {
            for (let j = 0; j < layerSize.height; ++j) {
                // 依次获得对应layer的每一个瓦片单元格
                let tiled = layer.getTiledTileAt(i, j, true);
                console.log(tiled.x, tiled.y) // x === i, y ===j, 以左下角为原点

                // 每个瓦片都有全局唯一id,被称作gid,空瓦片的gid为0
                if (tiled.gid !== 0) {
                    tiled.node.group = "ground";
                    // 获取到tiled.node之后,就可以执行各种操作了,如添加物理组件、碰撞逻辑等
                    // 比如常用到的地面碰撞检测
                }
            }
        }
    }
}

需要注意的是tiled的坐标问题

对于单个瓦片而言,在瓦片地图存在好几个坐标

  • 瓦片坐标,也就是上面遍历layer.getTiledTileAt传入的i、j,在直角地图中左下角为原点
  • 节点坐标,相对地图layer锚点作为原点的本地坐标系

为了更直观的确定瓦片的坐标,我常用的做法是将tilemap节点的锚点设置为0,0,这样就跟getTiledTileAt一致了,这样也可以很方便地获取某个瓦片的本地坐标和世界坐标

比如我们要将某个节点放在0,0坐标的瓦片对应的位置上

// 当瓦片分辨率比价低的时候,可能会通过对map节点进行scale放大来增加分辨率
const scale = this.map.node.scale 
const tileSize = this.map.getTileSize();
const layer = this.map.getLayer("ground");

const tiled = layer.getTiledTileAt(0, 0, true);

// 世界坐标转换
const pos = tiled.node.parent.convertToWorldSpaceAR(tiled.node.position)
const localPos = this.hero.node.parent.convertToNodeSpaceAR(pos)

// 锚点偏移量
localPos.x += tileSize.width * scale / 2
localPos.y += tileSize.height * scale / 2

this.hero.node.position = localPos

2. cocos creator 摄像机

参考

3. 基础知识

什么是摄像机?说的直白些,摄像机就是游戏中我们能看到的游戏画面,模型、特效、动画等都是通过摄像机展现给我们的

  • 摄像机的显示范围是以摄像机为中心,以屏幕大小为范围, 绘制物体,然后成像到屏幕上
  • 可以通过 Camera 组件的culling Mask来设置当前摄像机需要拍摄的分组, 如果勾选上相关分组,说明当前摄像机会拍摄对应分组的这些物体, 不在物体类型中的物体不会被摄像机绘制
  • 如果存在多个摄像机,每个摄像机画面先后都会被绘制到屏幕上,最终屏幕显示的是多个摄像机叠加后的结果,可以通过 Camera 组件的depth来决定绘制顺序,depth 小的先绘制,大的后绘制。

3.1. 手动渲染摄像机内容

除了 main camera 之外,还可以手动添加额外的 camera 节点,用于渲染指定分组的节点。

摄像机有一个比较特殊的属性targetTexture,如果设置了 targetTexture,那么摄像机渲染的内容不会输出到屏幕上,而是会渲染到 targetTexture 上。

因此,如果想把摄像机的渲染内容手动绘制到屏幕上,其大致实现为

// 构建一个空的texture
const texture = new cc.RenderTexture();
texture.initWithSize(this.sp_camera.node.width, this.sp_camera.node.height);

// 使用texture构建一个spriteFrame,方便某个Sprite节点使用
const spriteFrame = new cc.SpriteFrame();
spriteFrame.setTexture(texture);

// 用来承接spriteFrame并展示的节点
this.sp_node.spriteFrame = spriteFrame;

// 设置相机的targetTexture,这样相机渲染的内容会绘制到texture,通过spriteFrame在sp_node上展示
this.camera.targetTexture = texture;

3.2. 移动摄像机

添加了 camera 节点之后,也可以通过移动节点的方法来移动摄像机

update(){
    // todo 计算x
    this.camera.node.x = x
}

一些可以运用的场景

  • 在游戏开始时平移浏览全部场景中的敌人分布,如战棋游戏等
  • 一些横版游戏的背景移动,如飞机大战、跑酷等

3.3. 摄像机跟随角色

参考

在很大的地图场景中,移动角色的同时还需要移动背景等元素,更常见的做法是让摄像机跟随玩家操作的角色,使玩家看起来像在地图中移动一样

具体步骤为,

  • 将背景与角色放在同一个分组中,将相机的 cullingMask 设置为为该分组
    • 如果元素较少,直接使用 Main Camera 相机和 default 分组即可
    • 如果有其他不同渲染逻辑的节点(比如操作按钮是固定在屏幕上面不会动的),可以使用单独的分组和摄像机来管理
  • 在角色移动的时候,同时更新对应相机的 node.position 即可
  • 在期间会遇见一些问题,比如角色的坐标可能会超出屏幕,因此这里需要用到摄像机运镜技术。

下面是一个简单的例子

如下图构建节点树

大概的场景界面为

然后编写脚本组件

@ccclass
export default class NewClass extends cc.Component {
    @property(cc.Node)
    player: cc.Node = null;

    @property(cc.Camera)
    mainCamera: cc.Camera = null;

    @property(cc.Camera)
    uiCamera: cc.Camera = null;

    dir: 1 | -1;
    start() {
        this.dir = 1;
    }

    // 点击前进按钮
    moveFoward() {
        this.dir = 1;
    }
    // 点击后退按钮
    moveBack() {
        this.dir = -1;
    }

    updateCameraPosition() {
        const pos = this.player.position;
        const screenW = this.node.width;
        if (this.dir === 1) {
            pos.x = Math.max(Math.min(screenW * 0.5, pos.x), screenW * -0.5);
        } else {
            pos.x = Math.min(Math.max(screenW * -0.5, pos.x), screenW * 0.5);
        }
        this.mainCamera.node.position = pos;
    }

    update(dt) {
        this.player.x += dt * this.dir * 300;
        this.updateCameraPosition();
    }
}

这里运用了一个简单的运镜,当角色移动到屏幕边缘时,就不会更新摄像头位置了,这样可以避免旁边出现黑边。

关于摄像机运镜,在查阅资料的时候发现这篇文章Scroll Back:2D 横版游戏摄像机运镜原理与实践,写的真的是太好了

4. 小结

本文整理了tiledmap的一些基础知识,了解如何使用tiledmap搭建游戏地图。然后介绍通过移动摄像机的方式浏览长地图。

最近一直在折腾相关的功能点,代码写的比较草率,后面继续完善。