零、DEMO试玩
1. 点击在线试玩ES6-Mario
2. 手机扫码试玩:
源码:github.com/JuniorTour/…
一、序
-
超级马力欧的游戏细节:
简介
《超级马力欧兄弟》是最初发布于1985年的 Nintendo Family Computer游戏机平台的一款平台跳跃类游戏。
风靡全球,售出了超过 4000 万份,游戏的设定、理念时至今日仍然为我们津津乐道。
体积优化
承载这款游戏数据的载体:卡带,空间非常有限,只能容纳 256kb 的代码和 64kb 的精灵图。
所以游戏的开发者在许多方面做了优化,以满足这些限制。
例如:
-
复用精灵图:
- 云朵、草丛复用同一个图片素材,通过渲染不同的颜色来区分。
-
拼接精灵图:
- 游戏中的很多图像都是左右对称的,储存这些素材时会只存储一半、甚至四分之一精灵图,通过翻转,拼接出对称的物体,以节省空间。
据网上的资料传说,原本运行在 NES 平台的游戏中,图像素材只占据了 32kb 的内存空间
- 复用音效:
- 吃蘑菇音效是吃旗子音效的6倍快速重播版:video.h5.weibo.cn/1034:452587…
- 马力欧受伤的音效和进入管道的相同;
虽然今天的软硬件性能都已今非昔比,很少需要开发者主动优化软件的性能、体积,但是这些优化所展现的思路,仍然很值得我们学习,也非常有趣。
游戏指引
进入游戏后的前30秒、第一个场景,显然经过了精心的设计,既引起人的好奇心,又非常符合直觉,让玩家无需指引,就带着好奇心快速上手游玩:
这个场景:
- 第一帧画面,没有任何文字、图标指引,看似没有设定目标。
- 人物位于画面左下角,面向朝右,又在直觉上指引前进的方向。
- 没有危险,且有较多的留白,可供玩家熟悉操作。
- 继续前进,出现一个闪烁着的问号方块,邀请玩家继续向右探索。
- 再向右一步,第一个敌人出现,外形带着明显的“愤怒、恶意”,并且不断地向你靠近,这时玩家只有两个选项:
- 继续向右,被“蘑菇”伤害,学到了游戏中的受伤规则。
- 跳过或踩到“蘑菇”,学到了应对游戏中敌人的策略。
- 再往后的,大片问号砖块、能力增强蘑菇、水管,各项元素都没有显著的指引,却一步步地揭露了游戏世界的各项规则:问号有奖励、蘑菇会受到重力影响、水管需要跳跃翻越。
利用游玩者的好奇心做内容指引。
二、2D游戏常见基础概念
帧(Frame)
2D游戏的原理和视频类似,连贯的画面,是由一幅幅静止不动的图片”快速连续交替“形成的,
如上动图所示,动图中的每一张纸就是一”帧”。
通常用 每秒帧数(FPS,Frame Per Second),来计量帧数的高低,电影电视一般是24FPS;游戏也会以一定的的帧数绘制画面、运行。
(也有把“帧”称为“张”的叫法)
层(Layer)
浏览器中有文档流的概念,块级元素、行内元素默认都在文档流中,从上到下、从左到右排列,
如果给元素声明了float: left; position: fixed;
等特殊属性,会使元素脱离文档流,视觉上的表现就是这些元素会“浮”在文档流中的元素之上,仿佛有2个层次、2张带有透明度的纸叠在了一起一样。
2D游戏为了营造丰富的视觉效果,也会把游戏内的图像区分为不同的层次,通过层叠(composite)
各个层次,渲染出最终呈现在的玩家面前的游戏画面。
以此次介绍的超级马力欧游戏 DEMO 为例,区分出了:
- 静态背景层
- 动态实体层
- UI 层
3个层次,请看 DEMO:
2D游戏中也经常见到「视差移动」效果,把不同层次的画面,以不同的运动速度,呈现在玩家面前,营造出近似于3D的效果:youtube.com/watch?v=MGH…
类似的效果在浏览器中也可以实现,效果也十分亮眼。
精灵图(Sprite)
前端也曾有过精灵图(雪碧图)的概念,早年间,客户端网络带宽较小,为了节约网络资源,会把页面所需的图片资源拼接成一张图片,
一次请求,获取到所有的图片资源,再利用CSS的图片定位功能,把不同的图标呈现在对应的位置。
这个思路和2D游戏异曲同工,2D游戏很久以前就一直是这样做的,
基于这种实现方式,还可以把所需的图像切割、重组,实现之前在游戏细节章节所说的体积优化等特殊处理。
下图是超级马力欧 DEMO 中所使用的精灵图:
原版游戏不是这样的,可能是直接用二进制数据储存的,
因为在当时的平台上还没有图片文件这一概念,更没有 jpeg, png 等文件格式(都是90年代的产物),
此外为了节约卡带的内存空间,也不会区分这么多颜色,布局也会更紧凑。
镜头(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…
以下图为例,马力欧所处的红色边线单元格,与板栗仔所处的红色单元格,发生了重叠,主循环在绘制当前帧、进行碰撞检测是,
会根据双方所处的坐标系位置,判断出马力欧“踩在了”板栗仔的头部,板栗仔将被打倒。
物理效果、手感
游戏通常都有自己独特的的世界观,但又常常要和现实世界对齐。
很多游戏都有模仿现实世界的物理效果,用来改善游戏的手感,增强趣味性。
以超级马力欧这款游戏为例,
- 操控马力欧快速奔跑时,松开方向键,马力欧并不会立刻停下,而是会受惯性影响向前继续冲刺一段距离,并因为摩擦力最终静止停下。
- 马力欧跳跃时,高度会受到按键时长的影响;到达顶点后落下的轨迹,也明显的带有重力加速度效果。
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/