Canvas 2D游戏开发分享——以超级马力欧为例

3,314 阅读10分钟

零、DEMO试玩

1. 点击在线试玩ES6-Mario

2. 手机扫码试玩:

JuniorTour-ES6-Mario.png

源码:github.com/JuniorTour/…

一、序

  1. 超级马力欧的游戏细节:

简介

《超级马力欧兄弟》是最初发布于1985年的 Nintendo Family Computer游戏机平台的一款平台跳跃类游戏。

风靡全球,售出了超过 4000 万份,游戏的设定、理念时至今日仍然为我们津津乐道。

体积优化

承载这款游戏数据的载体:卡带,空间非常有限,只能容纳 256kb 的代码和 64kb 的精灵图。

所以游戏的开发者在许多方面做了优化,以满足这些限制。

68747470733a2f2f7777772e6869742d6a6170616e2e636f6d2f66632f31383039313932303038392e4a5047

例如:

  • 复用精灵图:

    • 云朵、草丛复用同一个图片素材,通过渲染不同的颜色来区分。
    • grass-cloud
  • 拼接精灵图:

    • 游戏中的很多图像都是左右对称的,储存这些素材时会只存储一半、甚至四分之一精灵图,通过翻转,拼接出对称的物体,以节省空间。

据网上的资料传说,原本运行在 NES 平台的游戏中,图像素材只占据了 32kb 的内存空间

  • 复用音效:

虽然今天的软硬件性能都已今非昔比,很少需要开发者主动优化软件的性能、体积,但是这些优化所展现的思路,仍然很值得我们学习,也非常有趣。

游戏指引

进入游戏后的前30秒、第一个场景,显然经过了精心的设计,既引起人的好奇心,又非常符合直觉,让玩家无需指引,就带着好奇心快速上手游玩:

这个场景:

  • 第一帧画面,没有任何文字、图标指引,看似没有设定目标。
  • 人物位于画面左下角,面向朝右,又在直觉上指引前进的方向。
  • 没有危险,且有较多的留白,可供玩家熟悉操作。
  • 继续前进,出现一个闪烁着的问号方块,邀请玩家继续向右探索。
  • 再向右一步,第一个敌人出现,外形带着明显的“愤怒、恶意”,并且不断地向你靠近,这时玩家只有两个选项:
    • 继续向右,被“蘑菇”伤害,学到了游戏中的受伤规则。
    • 跳过或踩到“蘑菇”,学到了应对游戏中敌人的策略。
  • 再往后的,大片问号砖块、能力增强蘑菇、水管,各项元素都没有显著的指引,却一步步地揭露了游戏世界的各项规则:问号有奖励、蘑菇会受到重力影响、水管需要跳跃翻越。

利用游玩者的好奇心做内容指引。

1-1-start

二、2D游戏常见基础概念

帧(Frame)

frames

2D游戏的原理和视频类似,连贯的画面,是由一幅幅静止不动的图片”快速连续交替“形成的,

如上动图所示,动图中的每一张纸就是一”帧”。

通常用 每秒帧数(FPS,Frame Per Second),来计量帧数的高低,电影电视一般是24FPS;游戏也会以一定的的帧数绘制画面、运行。

(也有把“帧”称为“张”的叫法)

层(Layer)

浏览器中有文档流的概念,块级元素、行内元素默认都在文档流中,从上到下、从左到右排列,

如果给元素声明了float: left; position: fixed;等特殊属性,会使元素脱离文档流,视觉上的表现就是这些元素会“浮”在文档流中的元素之上,仿佛有2个层次、2张带有透明度的纸叠在了一起一样。

2D游戏为了营造丰富的视觉效果,也会把游戏内的图像区分为不同的层次,通过层叠(composite)各个层次,渲染出最终呈现在的玩家面前的游戏画面。

以此次介绍的超级马力欧游戏 DEMO 为例,区分出了:

  • 静态背景层
  • 动态实体层
  • UI 层

3个层次,请看 DEMO:

源码:github.com/JuniorTour/…

3d

2D游戏中也经常见到「视差移动」效果,把不同层次的画面,以不同的运动速度,呈现在玩家面前,营造出近似于3D的效果:youtube.com/watch?v=MGH…

类似的效果在浏览器中也可以实现,效果也十分亮眼。

精灵图(Sprite)

前端也曾有过精灵图(雪碧图)的概念,早年间,客户端网络带宽较小,为了节约网络资源,会把页面所需的图片资源拼接成一张图片,

一次请求,获取到所有的图片资源,再利用CSS的图片定位功能,把不同的图标呈现在对应的位置。

image

这个思路和2D游戏异曲同工,2D游戏很久以前就一直是这样做的,

基于这种实现方式,还可以把所需的图像切割、重组,实现之前在游戏细节章节所说的体积优化等特殊处理。

下图是超级马力欧 DEMO 中所使用的精灵图:

原版游戏不是这样的,可能是直接用二进制数据储存的,

因为在当时的平台上还没有图片文件这一概念,更没有 jpeg, png 等文件格式(都是90年代的产物),

此外为了节约卡带的内存空间,也不会区分这么多颜色,布局也会更紧凑。

tiles.png

镜头(Camera)、横向卷轴移动

游戏中的视角多种多样,现在流行的赛车、枪战等3D游戏往往会有第一人称、第三人称等多重视角,不同的视角会产生截然不同的体验。

游戏视角一般是由镜头(Camera)决定的。

2D游戏的视角在游戏过程中通常是全程固定的,在横版卷轴游戏中,游戏的画面会像卷轴一样徐徐展开,呈现出游戏中的世界。

超级马力欧兄弟就是一个典型的横版卷轴2D游戏。

Demo 中构造了一个 Camera 对象,来记录当前视角所处的二维坐标系位置。

在每一帧中,根据 Camera 当前的位置以及提前配置好的该位置对应图像,绘制出一帧画面、实现卷轴滚动的效果。

边界盒(BoundingBox)、碰撞检测(Collision)

2D游戏通常会有自己的坐标体系,用来更方便、高效的构建游戏世界。

DEMO中构建了一个以16px * 16px为一单元格,共计有16 * 15格的世界,砖块、马力欧、板栗仔都占据一单元格。

当2个实体(entity)所处的单元格重叠时,就会在主循环中、绘制当前帧时,遍历每个实体,进行碰撞检测,根据实体各自的属性,判断、计算将要产生的结果。

function collisionDetect(curEntity) {
 allEntities.forEach( entity => {
  if (curEntity.collides(entity)) {
   // do sth  
  }
 })
}

性能问题:用两层循环,在每一帧中遍历所有实体,检测和其他实体是否有碰撞,复杂度是 O(n^2),相当高。

还有一种算法是,构建二维坐标矩阵,遍历一次矩阵,判断同一个坐标中,实体之间是否有碰撞,复杂度预计可以显著降低到 O(n);

但相比之下,开发复杂度显然高得多。对体量比较小(n比较小)的游戏(实体数量几十、几百个),优化效果也很有限。

如果对 碰撞检测 感兴趣,还可以看看这篇文章:joshbradley.me/object-coll…

以下图为例,马力欧所处的红色边线单元格,与板栗仔所处的红色单元格,发生了重叠,主循环在绘制当前帧、进行碰撞检测是,

会根据双方所处的坐标系位置,判断出马力欧“踩在了”板栗仔的头部,板栗仔将被打倒。

image

物理效果、手感

游戏通常都有自己独特的的世界观,但又常常要和现实世界对齐。

很多游戏都有模仿现实世界的物理效果,用来改善游戏的手感,增强趣味性。

以超级马力欧这款游戏为例,

  • 操控马力欧快速奔跑时,松开方向键,马力欧并不会立刻停下,而是会受惯性影响向前继续冲刺一段距离,并因为摩擦力最终静止停下。
  • 马力欧跳跃时,高度会受到按键时长的影响;到达顶点后落下的轨迹,也明显的带有重力加速度效果。

DEMO中,通过在人物行走时,计算位移的逻辑中增加加速度系数( acceleration)摩擦系数 ( dragFactor )

模拟了奔跑时逐渐加速;快速奔跑后、惯性运动一段距离的物理效果。

import {Trait} from '../Entity.js'

/*extends keyword can be used to inherit all the properties and methods. */
export default class Go extends Trait {
    constructor() {
        /*super keyword in here means the father class's constructor of this class. */
        super('go');

        this.dir = 0;
        this.acceleration = 400;
        this.deceleration = 300;
        this.dragFactor = 1/5000;

        this.distance = 0;
        this.heading = 1;
    }

    update(entity, { deltaTime }) {
        const absX = Math.abs(entity.vel.x);

        if (this.dir !== 0) {
            entity.vel.x += this.acceleration * deltaTime * this.dir;
            if (entity.jump) {
                if (!entity.jump.falling) {
                    this.heading = this.dir;
                }
            } else {
                this.heading = this.dir;
            }
        } else if (entity.vel.x !== 0) {
            const decel = Math.min(absX, this.deceleration * deltaTime);
            entity.vel.x += entity.vel.x > 0 ? -decel : decel;
        } else {
            this.distance = 0;
        }

        const drag = this.dragFactor * entity.vel.x * absX;
        entity.vel.x -= drag;

        this.distance += absX * deltaTime;
    }
}

三、源码实现介绍

面向对象:封装 - 万物皆对象

游戏这样复杂度非常高的项目,很适合用面向对象的思路来构建,DEMO也是这样实现的,大量运用了基于类封装、继承的思路。

DEMO中把 马力欧(Mario.js)、板栗仔(Goomba.js)、行走能力(Go.js)、甚至一整个关卡(Level.js)都视为一个“对象“,封装、抽象出一个类来构建这些实例。

实例各自拥有特殊的属性、方法,用来储存、更新自己的状态(update())。

以玩家操控的人物马力欧为例:

createMario()创建马力欧,每个马力欧都是继承自 Class Entity 的实例,

拥有 pos(位置), vel(速度), traits(特性) 等等属性,用来记录实例的位置、速度等状态。

以及 addTrait , collides, update 等方法,用来调用实例的特性,从而更新状态。

function createMarioFactory(sprite, audio) {
    const runAnim = sprite.animations.get("run");

        function frameRoute(mario) {
        if (mario.jump.falling) {
            return 'jump';
        }

        if (mario.go.distance > 0) {
            if ((mario.vel.x > 0 && mario.go.dir < 0) ||
                (mario.vel.x < 0 && mario.go.dir > 0)) {
                return 'break';
            }

            return runAnim(mario.go.distance);
        }

        return 'idle';
    }

    function drawMario(context) {
        sprite.draw(frameRoute(this), context, 0, 0, this.go.heading < 0);
    }

    return function createMario() {
        const mario = new Entity();
        mario.audio = audio
        mario.size.set(14, 16);

        mario.addTrait(new Physics());
        mario.addTrait(new Solid());
        mario.addTrait(new Go());
        mario.addTrait(new Jump());
        mario.addTrait(new Stomer());
        mario.addTrait(new Killable());
        // mario.addTrait(new PlayerController());

        mario.killable.removeAfter = 0;
        // mario.playerController.setPlayer(mario);

        mario.draw = drawMario;

        return mario;
    }
}

主循环

游戏“动起来”靠的就是主循环。

DEMO中的实现是用一个基于 requestAnimationFrame API 的 while 循环:

当代码运行积累的时间( accumulatedTime)大于设定的更新一帧的时间(默认是 1/60 === 0.016667秒),

就会调用 level.update 等方法,更新当前关卡的状态、移动镜头,让画面动起来。

export default class Timer {
    constructor(deltaTime = 1/60) {
        this.animationFrameID = null

        let accumulatedTime = 0;
        let lastTime = 0;

        this.updateProxy =  (time) => {
            accumulatedTime += (time - lastTime) / 1000;

            if (accumulatedTime > 1) {
                /* A hack to Solve the time accumulate
                * when it is running background.
                * So that our computer wont be slow down by this,
                * after long time of running this in background.*/
                accumulatedTime = 1;
            }

            while (accumulatedTime > deltaTime) {
                this.update(deltaTime);

                accumulatedTime -= deltaTime;
            }

            lastTime = time;

            this.enqueue();
        }
    }

    enqueue() {
        this.animationFrameID = requestAnimationFrame(this.updateProxy);
    }

    start() {
        this.enqueue();
    }

    stop() {
        if (this.animationFrameID) {
            window.cancelAnimationFrame(this.animationFrameID)
        }
    }
}

电子游戏之所以让人着迷,核心原因是它有即时的正向反馈。

按下按键就会有华丽的动画效果、生动的声音特效,立刻就能得到引人入胜的反馈。

不像现实世界,很多事情的结果和反馈不以人的主观意志为转移,例如学习。

Canvas相关API

游戏的主要反馈就是丰富多彩的画面图像的变化。

DEMO中主要用了 Canvas 2d 上下文的 API 来绘制画面:

const canvas = document.getElementById('screen')

// getContext 在Canvas画布上创建 2D上下文,可选参数有'2d','webgl'等。
// https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLCanvasElement/getContext
const context = canvas.getContext('2d'); 

// clearRect 指定一片2D 上下文中的矩形区域,清除其中的内容,将区域内的像素设置为透明。
// https://developer.mozilla.org/zhCN/docs/Web/API/CanvasRenderingContext2D/clearRect
context.clearRect(0,0,buffer.width,buffer.height); 

// drawImage 把传入的 canvas 图像源绘制到指定的位置
context.drawImage(`buffer, -camera.pos.x % 16, -camera.pos.y);

音效相关API

声音在现实生活中也潜移默化的影响着我们,例如吸尘器吹风机和汽车故意制造的轰鸣声、薯片的弧度以便产生酥脆的感觉。

音效也是游戏反馈的核心组成。

DEMO里借助浏览器平台的 window.AudioContext() API 来控制音效的播放。

把异步加载的 .ogg 文件二进制数据,预先储存在内存里的 Map 结构(this.buffers)中,需要播放时从内存中取出即可。

AudioContext 还提供了非常多的特性,可以基于这些特性实现 空间立体声音效、音频裁剪 等功能,

const audioContext = new window.AudioContext()

export default class AudioBoard {
    constructor () {
        // this.context = context
        // not hardcore audioContext, but send it as a param below,
        // so than we can change the context easily.
        this.buffers = new Map()
    }

    addAudio(name, buffer) {
        this.buffers.set(name, buffer)
    }

    playAudio(name, audioContext) {
        const source = audioContext.createBufferSource()

        // TODO Global Volume Setting
        // const gainNode = audioContext.createGain();
        // gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
        // source.connect(gainNode)
        // gainNode.connect(audioContext.destination);

        source.connect(audioContext.destination)
        source.buffer = this.buffers.get(name)
        source.start(0)
    }
}

市面上已经有了很多成熟的浏览器平台音效管理框架,可以参考:howlerjs.com/