HTML5 2D平台游戏开发#6地图绘制

  此前已经完成了一部分角色的动作,现在还缺少可以交互的地图让游戏看起来能玩。不过在开始之前应当考虑清楚使用什么类型的地图,就2D平台游戏来说,一般有两种类型的地图,Tile-based和Art-based,即基于瓦片风格和美术风格两种。Tile-based的典型代表是《Super Mario》(超级马里奥),Art-based记不太清楚了,能够回想起来的是去年出的一款叫做《Owlboy》(猫头鹰男孩)的游戏。

   
 Super Mario  Owlboy

由于Art-based的实现需要一款较为专业的可视化游戏开发工具,因此我选择较为传统的Tile-based。

 

  • 瓦片尺寸的选择

   通常可选的瓦片尺寸有16X16、32X32、64X64、128X128等,都是2的N次方。原因之一当然是比较好计算,另外一个原因是计算机对2的倍数计算速度有比较明显的优化。这里采用32X32的尺寸。

游戏中暂时用到的瓦片集合

 

  • 将瓦片集合制作成地图

   Tiled Map Editor是一款非常强大的地图编辑软件,体积不大而且还是开源的,是地图编辑的首选。

 

编辑中的地图

可以添加多个图层来表现游戏地图的层次,这里使用两个图层,背景层和地面层。经过考虑,我暂时不打算使用对象层,故而上面的勾去掉了。虽然添加这个层可以很方便地得到碰撞数据,但需要对每个碰撞的部分建立包围盒,当地图很大并且很多的时候就十分耗费时间去添加和维护,后期会在添加角色出生点或者寻路时才使用它。

 

  • 导出地图数据

  将地图数据导出为JSON格式,得到类似下面的数据:

layers:[
    [3, 3, 3, 8, 9, 3, 3, 3, 3, 3, 3, 3...],//背景层
    [-1, -1, -1, -1, -1, -1,-1, -1, 10...]//地面层
]

其中的数字代表瓦片在瓦片集合中的索引,不过需要注意索引是从1开始的,而一般我们计算时都喜欢从0开始,可以在代码中将其减去1或者用数组的map方法处理一下并覆盖原始数据。同时建立一个列表来标注瓦片的类型:

MAPCONFIG.FIELDTYPE = {
    'solid': [0, 1, 2, 13, 14, 15, 26, 27, 28, 30, 31, 32, 33, 34, 35],
    'null': [3, 5, 6, 7, 8, 9, 11, 12, 16, 17, 18, 19, 22, 23]
};

根据瓦片的数字来判断它是否需要建立碰撞包围盒,这样就省去了上述建立对象层的步骤。

 

  • 在画布上绘制地图

  上面已经得到了地图数据,可以开始进行地图的绘制了。新建一个地图管理函数MapManager()

class MapManager {
    constructor(level, ctx, assets) {
        this.spriteSheet = assets.image;
        //416,96 所采用图片资源的宽高
        this.dimensions = {w: assets.w, h: assets.h};
        this.level = level;
        this.layerLength = this.level.layers.length;

        this.ctx = ctx;
    }

    //prototype ...
}

接下来需要一个获取瓦片代表数字的方法getTile(),这个方法返回像3,5,-1这样的数字,即瓦片在图片上的索引。

//layerIndex 图层索引
//col 瓦片所在列的编号
//row 瓦片所在行的编号
getTile(layerIndex, col, row) {
    //this.level.cols为整个地图的列数
    return this.level.layers[layerIndex][row * this.level.cols + col];
}

_drawLayer()方法绘制一个层:

let startCol = 0,//起始列
endCol = 40,//结束列
startRow = 0,//开始行
endRow = 20,//结束行
tileSize = MAPCONFIG.TILESIZE;//32

for (let r = startRow; r < endRow; r++) {
    for (let c = startCol; c < endCol; c++) {
        let tile = this.getTile(layerIndex, c, r),
        x = (c - startCol) * tileSize, //瓦片的x坐标
        y = (r - startRow) * tileSize; //瓦片的y坐标
        if (tile !== -1) {//-1代表空瓦片不绘制
            this.ctx.drawImage(
        this.spriteSheet,
        tile * tileSize % this.dimensions.w, //瓦片精灵图上的x坐标  Math.floor(tile * tileSize / this.dimensions.w) * tileSize, //瓦片精灵图上的y坐标         tileSize, tileSize,
        Math.round(x), Math.round(y),
        tileSize, tileSize
       ); } } }

_drawMap()方法绘制多个层:

_drawMap() {
  this.level.layers.forEach((layer, index) => this._drawLayer(index));
}

最后使用render()方法进行绘制:

render() {
    this._drawMap();
}

最终得到下面的地图:

这类绘制地图的方法在很多游戏中也通用,我曾经在《使用HTML5制作简单的RPG游戏》里也使用了同样的地图数据格式,不过那时是依靠框架完成的,里面具体发生了什么自己并不清楚,现在正好回顾和总结一下。

 

  • 处理地图与角色的碰撞

  现在地图还只是单纯的视角效果,并不能与角色产生交互,需要将各个瓦片进行标注,以表明它到底是不能通过的固体还是可以通过的空瓦片。在此之前,需要先了解一个概念,即axis-aligned bounding box,翻译过来就是轴对齐边界盒子,简称AABB。通过为瓦片与角色建立AABB,可以很方便地检测它们之间是否碰撞,这一概念在之前写过的一篇文章《Chrome自带恐龙小游戏的源码研究(七)》中也有涉及到。至于更为复杂的碰撞检测,比如SAT,现在还没有深入研究,后续会逐步加入游戏中。

AABB的实现代码:

class AABB {
    /**
     * 碰撞盒子
     * @param x    {number} 盒子x坐标
     * @param y    {number} 盒子y坐标
     * @param w    {number} 盒子宽度
     * @param h    {number} 盒子高度
     */
    constructor(x,y,w,h) {
        this.pos = new Vector(x,y);
        this.size = new Vector(w,h);
        this.center = new Vector(this.pos.x + w / 2,this.pos.y + h / 2);
        this.halfSize = new Vector(this.size.x / 2,this.size.y / 2);
    }
}

其中又涉及到向量的概念(累😂),向量在2D和3D游戏中都有着大量的应用,属于硬知识。下面为瓦片添加AABB,回顾MapManager方法,为其添加一个存储AABB的数组:

constructor(level, ctx, assets) {
    this.gridForAABB = [];
    //......
}

_drawLayer(layerIndex) {
    let startCol = 0,   //起始列
        endCol = 40,    //结束列
        startRow = 0,   //开始行
        endRow = 20,    //结束行
        tileSize = MAPCONFIG.TILESIZE,
        isSolidLayer = layerIndex === (this.layerLength - 1),
        grid = [];

    for(let r = startRow; r < endRow; r++) {
        for (let c = startCol; c < endCol; c++) {
            let tile = this.getTile(layerIndex, c, r),
                x = (c - startCol) * tileSize,  //瓦片的x坐标
                y = (r - startRow) * tileSize;  //瓦片的y坐标

            //......
            if (isSolidLayer) {
                //如果遇到的瓦片是固体
                if (MAPCONFIG.FIELDTYPE['solid'].includes(tile)) {
                    //为其建立AABB
                    grid.push(new AABB(c, r, 1, 1));
                } else grid.push(tile);  //存入普通瓦片        
            }
        }
    }
    //将所有瓦片的AABB数据保存起来
    if (isSolidLayer) this.gridForAABB = grid;
}    

如同上面的getTile()方法一样,得到一个瓦片的AABB可以用如下代码:

gridForAABB[row * this.level.cols + col]

最后,需要将其与角色的位置联系起来。

 

  • 获取影响角色位置的瓦片

  首先观察一张图

假设玩家在地图里向右移动,此时角色占据的空间为蓝色部分区域,因此需要在这个范围内查找有没有障碍物阻止角色移动,在MapManager()函数中新增一个方法:

    obstacleAt(layerIndex, pos, size) {
        let startCol = Math.floor(pos.x), //player's x position
             endCol = Math.ceil(pos.x + size.x), //pos.x plus player's width
             startRow = Math.floor(pos.y),
             endRow = Math.ceil(pos.y + size.y);

        for (let c = startCol; c < endCol; c++) {
            for (let r = startRow; r < endRow; r++) {
                let tile = this.getTile(layerIndex, c, r),
                    fieldType = null;

                if (MAPCONFIG.FIELDTYPE['solid'].includes(tile)) {
                    fieldType = 'solid';
                }

                if (fieldType) return {col: c, row: r, fieldType: fieldType};
            }
        }
        return null;
    }

一旦在这个区域找到障碍物,立即返回它。

接下来需要处理角色与障碍物的碰撞:

假设角色在移动中碰到了右边的障碍物(橘色部分),此时应当修正角色的位置避免继续碰撞:

以下是测试中的效果:

不过一些细节还需要调整,有时间的话会继续更新。

 

更新日志

  2017/04/09  更新角色跳跃

  2017/04/21  更新角色冲刺

  2017/05/01  更新角色状态机

  2017/05/16  更新角色攻击动画

  2017/05/22  更新角色移动攻击动画

  2017/05/24  更新角色跳跃攻击动画

  2017/06/04  更新地图绘制

posted @ 2017-06-04 21:05  逐影  阅读(1523)  评论(0编辑  收藏  举报