原生的 JavaScript 简单射击视频游戏 - 更新至移动版






4.93/5 (15投票s)
让我们休息一下,
引言
我利用空闲时间创建了这个简单的游戏,只是为了让自己从日常工作中解脱出来。与典型的 DOM 操作或与服务器的 Ajax 调用相比,这是一种有趣且有益的 JavaScript 编码方式。我认为 JavaScript 在用作面向对象语言时非常强大,尤其是在结合 html5 特性时。
当然,这段代码还有很多改进的空间,主要的架构也是如此。不幸的是,我不是电子游戏制作者。但对于那些对游戏基础感兴趣的人来说,这也可以是一个起点。这个游戏可以扩展各种道具、不同的精灵、地面敌人…… 我个人很惊讶地看到一旦有了稳健的逻辑,游戏就能拥有自己的生命,我真的希望你能找到我的这种精神。
感谢 这个页面 的 Jacob Zinman Jeanes 提供的免费飞船和敌人图片。
背景
这里的主要重点是 html5 Canvas
,一个简单而强大的像素矩阵,您可以在其中绘制图像。通常,它不包含绘制在其上的项目的概念,也不包含图形对象。您需要一个对象数组来维护所有项目的引用及其状态。它也没有 z-index 的概念,所以我们必须在外部管理项目在重叠方面的优先级。只需考虑每个项目都绘制在其他项目之上,因此最后绘制的是最高层,也就是最接近观察者的层。
Using the Code
我不会注释所有代码,因为我担心这会让时间不多的读者感到厌烦和气馁。让我们只看游戏中最重要的对象和逻辑,当然,您可以下载小压缩文件来阅读所有代码。
imagesRepo
它包含游戏中每个项目的图像,充当存储库并提供所有图像的单一加载。属性 verticalImageFrames
和 horizontalImageFrames
分别表示位图中垂直和水平方向的帧数。
getImageFor
方法返回请求者类型对应的图像。
function imagesRepo() {
this.cloud = new Image();
this.cloud.src = "img/cloud.png";
this.cloud.onload = function () { this.isLoaded = true; };
this.cloud.verticalImageFrames = 1;
this.cloud.horizontalImageFrames = 1;
......
this.getImageFor = function (item, wave) {
if (item instanceof cloud) return this.cloud;
.......
}
gameObject
每个对象都继承自它,以获得常见的可用方法和属性,如下所示。它在构造函数中加载图像,并共享一些简单的方法。最重要的是 nextImageFrame()
,它能按帧序列为图像设置动画(想想飞船的反应堆)。
function gameObject(x, y, wave) {
this.x = x;
this.y = y;
this.currentFrame=1;
this.image = images.getImageFor(this, wave); //get image from images Repository
this.offsetLeftEnergy = 0;
}
gameObject.prototype.getFrameHeight = function () {
return this.image.height / this.image.verticalImageFrames; //get real height for a multiframe image
}
gameObject.prototype.getFrameWidth = function () {
return this.image.width / this.image.horizontalImageFrames;//get real width for a multiframe image
}
gameObject.prototype.nextImageFrame = function (onlyOnce) {
this.currentFrame++; //increase the frame of the object
if (this.currentFrame > this.image.verticalImageFrames * this.image.horizontalImageFrames) {
if (onlyOnce) //if the frames chain must be showed only once (like explosions...)
this.tbd = true; //mark the object to be deleted
else
this.currentFrame = 1; //if frames are looped (like ship..) restart from frame 1
}
}
scene
scene
是游戏的主对象。一切都可以从这里引用。
其实例最重要的属性是 gameItems
,这是一个包含游戏中所有对象的数组。想法是让这个数组中的对象自给自足,并在游戏的每一帧运行它们自己的 draw()
方法。每个方法都会做不同的事情(对于我们的导弹,它会从左到右移动;对于敌人,它会遵循某种模式,依此类推),所以我们必须遍历数组并为每个对象执行 item.draw()
。这样,开发者的注意力就集中在单个对象的逻辑和生命周期上,我向您保证,一切都会变得非常简单。如果我们做得好,只需将对象推入 gameItems
数组,一切应该都能正常工作。好吧,这不是什么伟大的发现,有人可能会说。;)
这是最重要的部分
scene.prototype.drawAll = function () {
this.gameTicks++;
if (this.gameTicks == 1000) this.gameTicks = 0;
purgeTbd(this.gameItems); //delete objects marked with tbd=true
this.gameItems.sort(compareZindex); //sort the array by zindex property
if (this.countOf(enemy) == 0 && this.countOf(message) == 0) {
this.wave++; //if enemies are all dead, increase wave counter and reinit them
this.initEnemies();
}
if (!this.paused) //if game is not paused (space bar)
{
//draw every items according to its own logic
this.gameItems.forEach(
function (item, index) {
item.draw();
});
}
this.setScore(); //write score on screen
if (this.ship.isDead) this.gameOver(); //if we are dead, print game over
var t = this;
requestAnimationFrame(function () {
t.drawAll() //callback to do this same function
});
};
此方法在游戏的每一帧运行,我的电脑上每秒运行 60 次。正如您所看到的,我在每一帧都使用 requestAnimationFrame
,因为它经过浏览器优化,是执行画布频繁更新的推荐方式。另一种选择是经典的 setInterval
,但在我的尝试中,游戏没有现在这么流畅。通过将其回调设置为同一个调用函数(drawAll()
),您就启动了游戏的**主循环**。
ship
飞船是我们的英雄,在天空中与敌人作战 :)。我认为需要注意的几点是
movement
:当然,您可以用鼠标移动它,但如果飞船的移动与鼠标指针重合,它会显得有些生硬或太尖锐。所以鼠标移动实际上为飞船设定了一个目标 X 和 Y,飞船会以一种延迟或惯性的方式试图达到那个点。this.x = this.x + ((this.xTarget - this.x) / this.inertia); this.y = this.y + ((this.yTarget - this.y) / this.inertia);
energy bar
:drawEnergy()
方法在飞船下方显示一个能量条,每次与炸弹碰撞(稍后会讲到)时它会减少,如果降到 0,飞船就会被摧毁。shoots
:shootToEnemy()
是一个非常简单的方法(由鼠标点击事件的监听器调用),它会从飞船向右发射一枚导弹。屏幕上导弹的最大数量是脚本顶部的游戏常量之一。导弹只是以起始x
和y
坐标被添加到gameItems
数组中。ship.prototype.shootToEnemy = function () { if (this.isDead) return; if (myscene.countOf(missile) >= MAX_MISSILES) return; myscene.gameItems.push(new missile( this.x + this.image.width - 20, this.y + 14)); new Audio('sound/shoot.wav').play(); }
enemy
在每波攻击中,三种颜色的飞碟会交替出现。每当进入新的一波时,它们的数量就会增加,而它们的“炸弹率”因子会降低,以便它们更频繁地射击,正如您下面可以推断的那样。
this.shootToShip = function () {
if (getRandom(1, 1000) > this.bombRate) { //the lower the bombRate,
//the higher the probability to shoot
var a = new enemyBomb(
this.x, this.y,
myscene.ship.x + myscene.ship.image.width / 2, myscene.ship.y
);
this.enemyBombs.push(a);
}
}
事实上,enemyBomb
对象不是 gameItems
的子项,而是包含在每个敌人的自有数组(enemyBombs
)中。这样,如果一个敌人被消灭,它的炸弹也会从屏幕上消失,这在处理困难关卡时非常有用。;)
关于敌人的模式或飞行路径,一个名为 enemyPattern
的函数会根据我们所在的波次号在每一帧提供它们的坐标。非常简单地说,它定义了敌人在画布的哪个角落出现,它们将在哪个坐标开始它们的路径,以及基于游戏滴答声它们将遵循哪个 cos()
/sin()
函数,所以它们通常以平滑的正弦波运动。在下面的代码片段中,variantx
和 varianty
会导致敌人路径出现显著变化。
if (enemy.patternStarted) {
enemy.patternTick += 3;
enemy.x += enemy.speedX * Math.cos(ToRadians(enemy.patternTick) / variantx);
enemy.y += enemy.speedY * Math.sin(ToRadians(enemy.patternTick) / varianty);
}
enemyBomb
黄色/红色的小球总是由飞碟向我们发射。所以你最好保持飞船移动。
它们在屏幕上的数量没有限制,而且正如所说,它们的数量会随着每一波攻击而增加。
云
云彩每秒生成一个。
setInterval(function () { t.gameItems.push(new cloud(0, 0)); }, 1000);
它们负责为场景带来一点视差效果。Cloud 构造函数会获取一个介于 1 和 4 之间的随机数。这个值越大,云的速度、大小以及云的“zindex
”就越大,只是为了模拟它离我们更近。
function cloud(x, y) {
gameObject.call(this, x, y);
this.speedX = getRandom(1, 4);;
this.zindex = this.speedX;
this.x = canvas.width;
this.y = this.speedX * 10;
this.draw = function () {
if (this.image.isLoaded == false) return;
this.x -= this.speedX;
var scale = 4 / this.speedX;
if (this.x + this.image.width / scale < 0) {
this.tbd = true;
return;
}
ctx.drawImage(this.image, this.x, this.y, this.image.width / scale, this.image.height / scale);
};
}
碰撞
碰撞是射击游戏中最重要的部分之一,事实上,我更倾向于简化它们,考虑到这离专业游戏还差得很远。所以这不是“逐像素”的碰撞检测,但我们可以称之为“逐框”检测。
我的意思是,每个对象都被视为一个矩形框,并且可以检测框之间的碰撞。
//returns number of overlapping pixels between two rect objects
function collisionArea(ob1, ob2) {
var overX = Math.max(0, Math.min(ob1.x + ob1.getFrameWidth(), ob2.x + ob2.getFrameWidth())
- Math.max(ob1.x, ob2.x))
var overY = Math.max(0, Math.min(ob1.y + ob1.getFrameHeight(), ob2.y + ob2.getFrameHeight())
- Math.max(ob1.y, ob2.y));
return overX * overY;
}
在游戏中,碰撞在飞船/炸弹、飞船/敌人、导弹/敌人之间有意义,对于每种关系,一个常量定义了两个对象之间必须重叠多少像素才能声明碰撞。
const SHIP_ENERGY=100, //starting ship energy
MAX_MISSILES=3, // max number of our missiles on screen
BACKGROUND_SPEED=-4, // scrolling speed
INITIAL_BOMB_RATE=996, //bomb rate - if random nbr (1,1000) > 996,
//enemy shoots. decreasing with waves
BOMB_SPEED=7, // speed of enemy bombs
MISSILE_SPEED=8, // speed of our rockets
SHIP_BOMB_COLLISION=20, //number of pixel in overlap to be a collision
ENEMY_MISSILE_COLLISION=20,// "
SHIP_ENEMY_COLLISION=50 // "
;
在我的测试中,默认值似乎足够好,但当然您可以随时更改它们,以及代码中的任何其他值。我知道这不是最好的碰撞系统,但它能满足我们的目的。
移动
我的目标不是编译一个应用程序,而只是使用与 PC 上相同的、能工作的代码文件,通过从手机文件夹启动 html 文件(html5 不是标准吗?)。没有应用程序,没有包装器,没有本地 www 服务器。我使用了一台三星 A5(未root),运行 Android 7 和 Chrome 移动版。
1. 如何运行?
如果您打开文件管理器并点击 html 文件,它将无法工作。有些文件管理器根本打不开它,有些会打开浏览器,但 URL 类似于 content://0@media...,这对我们来说不好,可能是因为它无法通过这种方式看到子文件夹。
先打开 Chrome,并将 URL 指向 file://sdcard/。您将看到一个文件夹列表,打开您复制游戏所在的文件夹。从那里运行 html 文件。(**注意**:我无法看到外部 SD 卡文件夹,所以我不得不使用内部存储。)
2. 音频
运行时创建并立即播放的音频对象在移动设备上不起作用。似乎它们必须作为 Audio 控件存在于 HTML 页面中才能播放,但还有一个问题。它们无法通过编程方式播放,至少在开始时不能。它们必须首先通过直接的用户输入来播放,这只是为了保护数据使用而采取的一种措施。
这就是为什么我引入了“点击/轻触开始游戏”的横幅。在此事件上,通过运行音频控件的 play/pause 方法来启动声音。在此之后,它们可以在我们需要的时候播放。
3. 全屏
即使全屏也必须由用户手势启动。因此,用于音频的同一个“touchend
”事件也用于将页面设置为全屏。
这是声音和全屏初始化
function initMobile()
{
if (canvas.requestFullscreen) canvas.requestFullscreen();
if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen
(canvas.ALLOW_KEYBOARD_INPUT);
if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
audioexpl1.play();
audioexpl1.pause();
audiobomb.play();
audiobomb.pause();
audioshoot.play();
audioshoot.pause();
}
关注点
如果您注意到任何帧丢失,只需减小浏览器页面大小并重新启动。甚至可以尝试按 F11,我见过有些浏览器全屏会产生影响。
历史
- 2017/10/21:首次发布
- 2017/10/28:添加了移动设备支持