Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

基于 Canvas 的2D游戏开发——以超级马力欧为例 #6

Open
JuniorTour opened this issue Oct 21, 2020 · 0 comments
Open

基于 Canvas 的2D游戏开发——以超级马力欧为例 #6

JuniorTour opened this issue Oct 21, 2020 · 0 comments

Comments

@JuniorTour
Copy link
Owner

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

new-start

零、DEMO试玩

源码:https://github.com/JuniorTour/es6-mario

「闲聊一会~」

一、序

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

简介

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

风靡全球,售出了超过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:

源码:JuniorTour/es6-mario@b42e968

3d

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

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

精灵图(Sprite)

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

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

image

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

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

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

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

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

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

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比较小)的游戏(实体数量几十、几百个),优化效果也很有限。

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

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

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 还提供了非常多的特性,可以基于这些特性实现 空间立体声音效、音频裁剪 等功能,

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

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)
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant