SpaceShoot 单人游戏






4.98/5 (46投票s)
构建一个功能齐全(且充满乐趣)的开箱即用式单人游戏。
引言
在最初的 SpaceShoot 文章中,我们提出了一个功能齐全的 HTML5 游戏的蓝图。这一次,我们实现了之前提出的许多用于扩展游戏的想法。本文将介绍游戏本身及其部分代码。我们将探讨以下主题:
- 使用 HTML 构建菜单并通过 JavaScript 进行控制
- 使用
LocalStorage
保存设置和个人最高得分 - 编码管理器类型以方便扩展加载过程
- 集成关卡以增加游戏的趣味性
- 构建炸弹(可选武器)
- 实现 CPU 控制的飞船
- 通过文本使游戏更具信息量
- 构建文本控件以显示介绍
在我们开始讨论任何这些主题之前,应该先看看游戏目前的状况。
背景
本文是 **SpaceShoot - HTML5 中的多人游戏** 文章的后续,可以在 这里 找到。整个项目源于构建使用最近推出的 WebSocket
元素的想法。然而,很明显,一个功能齐全的单人模式也将非常有用。
将有另一篇文章详细介绍多人模式的完整实现。这篇文章主要包含 C#,因为服务器是在 Jason Staten 编写的 Fleck 的帮助下编写的。最后,我还在考虑一个移动版本。这可能是一个仅在 Windows Phone 7 上的多人版本,或者只是使用 HTML / CSS 中的响应式设计技术。
游戏本身
SpaceShoot 的概念很简单:玩家控制一艘漂浮在太空中的飞船,并受到小行星和其他飞船的攻击。游戏通过键盘上的以下按键进行控制:
- 箭头键用于加速(**上**)、刹车(**下**)以及向左(**左**)和向右(**右**)转向
- **空格键** 用于发射普通弹药
- **Ctrl 键** 用于部署炸弹
- **Tab 键** 可用于切换分数屏幕的显示
- **Esc 键** 可用于进入和退出菜单
玩家开始时拥有满生命值和满弹药,但没有炸弹和护盾。为了恢复生命值、补充弹药、获得炸弹或增强护盾,玩家必须收集偶尔出现的物品。这些物品的存在是有限的,即它们会在一段时间后消失。这可以通过物品下方一个衰减的进度条显示。这些物品可以被收集:
- 恢复 +30 生命值
- 获得 +20 弹药
- 获得 +2 炸弹
- 获得满护盾
收集物品会为玩家得分。另外,需要注意的是,即使某些补给包可能会增加某个属性,例如生命值、弹药或炸弹数量,该属性仍然会受到一定的限制,即该属性的值不能超过其最大值。例如,拾取生命值会给予我们 0(死亡)到 100(最大生命值)的范围。因此,拾取一个生命值补给包(+30 生命值)而当前生命值为 87 时,结果将是 100 生命值,而不是 117。
护盾是一种特殊能力,可以保护飞船。护盾通过承受伤害来保护飞船。它的出现频率会随着关卡数的增加而增加。这一点很重要,因为小行星的数量也会随着关卡数的增加而增加。此外,计算机控制的无人机(定期派出)在高关卡中将是很大的麻烦。每第 15 关,都会派出整个无人机舰队来摧毁玩家的飞船。这些舰队的规模与关卡数成正比。
计算机控制的飞船试图直接飞向玩家的飞船。如果玩家足够近,它们会以固定的间隔开火。如果小行星挡在它们前面,它们也会开火。但是,它们不会试图绕道以避开小行星。
代码
代码(在架构上)自上次以来变化不大。主要区别在于代码在许多领域得到了扩展。还值得注意的是,我上次提到的一些改进已包含在此版本中。所有主要的扩展和改进将在本节中深入讨论。
菜单实现
菜单是使用 DOM 实现的。在这里,我们希望利用 HTML,而不是仅仅滥用 Canvas
元素(毕竟,CSS 提供的样式化可能性不应被回避,而应尽可能多地使用)。使用 jQuery 可以进一步提高生产力。然而,在本例中,为了在不使用任何外部库的情况下开发菜单,排除了 jQuery。基本的 HTML 构建如下所示:
<div id="menuscreen">
<p id="title">SpaceShoot</p>
<ul id="menu-main">
...
</ul>
<ul id="menu-scr">
...
</ul>
...
</div>
因此,我们只是生成一个包含所有菜单的 <div>
元素。在 CSS 中,我们应用了一些漂亮的样式,以保持一切的整洁和和谐。将显示的不同菜单项使用 <ul>
列表表示。让我们看看事件如何在我们的 JavaScript 代码中应用:
menu.init = function() {
document.getElementById('menu-sp').onclick = function() {
game.startSingle();
menu.toggle();
};
document.getElementById('menu-setname').onclick = function() {
menu.select('menu-user');
};
// Others ...
};
所有菜单函数都放在一个名为 menu
的对象中。init()
方法将设置菜单,即显示正确的子菜单并设置所有点击回调以供进一步使用。虽然有些元素有直接的点击事件,例如启动单人游戏(然后关闭菜单),但其他元素会打开其他子菜单。打开是通过 menu.select(id)
完成的。这里执行以下代码:
menu.select = function(id) {
var m = document.getElementById('menuscreen');
var items = m.getElementsByTagName('ul');
for(var i = items.length; i--; ) {
var isel = items[i].id === id;
items[i].style.display = isel ? 'block' : 'none';
}
};
该方法只是打开菜单并获取所有下层的子菜单。然后将隐藏所有这些子菜单,除了应该被选择(显示)的那个。使用 jQuery,这样的命令可以在一行短代码中写成。菜单的切换(显示或隐藏)是通过 toggle()
方法完成的。该方法实现如下:
menu.toggle = function() {
var m = document.getElementById('menuscreen');
var im = document.getElementById('inactive')
if(m.style.display === 'block') {
game.running = true;
m.style.display = 'none';
im.style.display = 'none';
c.canvas.focus();
} else {
game.running = game.running && game.multiplayer;
m.style.display = 'block';
im.style.display = 'block';
menu.select('menu-main');
m.focus();
}
};
该方法检查菜单当前是否显示(通过菜单屏幕 <div>
的 display
规则)。然后,它根据可见性状态执行正确的语句。ID 为 *inactive* 的 <div>
元素只是一个放置在菜单和文档画布/主体之间的层。因此,文档的其余部分将显示一个指示焦点现在在菜单上,并且必须关闭菜单才能继续其余内容的指示。
通过 CSS 样式,可以轻松调整菜单中文本框和标题的设计。由于我们的 JavaScript 设计是为每个按钮调用一个(或多或少)唯一的事件,因此我们可以设置一个事件来捕获保存设置链接,并更新本地设置。所有设置都存储在 localStorage
对象中。这是一个非常有用的概念,现在将对此进行解释。
使用 LocalStorage
为了保存设置并保存个人最高得分,我们需要处理 cookie 或其他技术。使用 cookie 不是一个好主意,因为它们不仅有限制,而且使用起来也很麻烦。localStorage
对象是解决 cookie 大多数问题的解决方案。为了限制对该对象的访问(从而限制 JSON 转换),我们将当前设置以及当前最高得分缓冲在一些局部变量中。
当应用程序加载时,以前的最高得分将使用 localStorage
对象和 JSON 加载到本地数组中。不幸的是,我们只能将 DOMString
类型变量保存在存储中。但是,这意味着通过使用 JSON,我们可以存储所有 JavaScript 对象。加载顺序在代码中定义如下:
score.init = function() {
var s = JSON.parse(localStorage.getItem('highscores'));
if(s)
score.scores = s;
};
这并不复杂。我们从 localStorage
获取保存的最高得分,如果之前保存了最高得分(首次加载时肯定没有),则将其保存在本地缓冲区中。否则,score.scores
仍然只是一个空数组,因为局部变量 s
将是未定义的,因此为 false
。
游戏中的当前分数屏幕始终可以通过按 TAB 键来切换。游戏结束后,分数屏幕会自动显示。下面显示了一个示例分数屏幕(包含所有可能的统计信息)。
为了在游戏结束后更新最高得分列表,会调用 score.update()
。该方法将检查当前得分是否是新的最高得分。如果是,将显示一个模态消息对话框。无论如何,当前得分都会添加到分数列表中。此外,localStorage
对象会使用当前分数列表进行更新,以保持分数最新。
score.update = function() {
var dd = new Date();
var pts = myship.points;
if(pts > score.high().points) {
//Show Message!
}
score.scores.push({ date : dd, points : pts, level: game.level, player : settings.playerName });
localStorage.setItem('highscores', JSON.stringify(score.scores));
};
图像和音频管理器
如前一篇文章所述,我们需要一个更强大的管理器来加载和获取图像。由于游戏在某些情况下需要音频,因此还需要另一个音频对象的管理器。最终的单人游戏代码包含一个 resourceManager
对象,该对象创建 imageManager
和 audioManager
的实例来处理精灵、徽标和声音片段。资源管理器对象声明如下:
var resourceManager = new function() {
this.sounds = [
//Name of sound files in audio directory without .wav
];
this.sprites = [
//Name of image files in img directory without .png
];
this.done = 0;
this.total = this.sounds.length + this.sprites.length;
infoTexts.push(new infoText(c.canvas.width / 2, c.canvas.height / 2,
100, "Loading 0%", secondaryColors[0]));
draw();
};
我们只需要在此处放置文件名(不带扩展名)和目录。为了告知用户加载过程已开始,屏幕中央会放置一个信息文本。其余部分通过执行 init()
方法来完成:
resourceManager.init = function() {
soundEffects = new soundManager(resourceManager.sounds, resourceManager.callback);
spriteSheets = new imageManager(resourceManager.sprites, resourceManager.callback);
};
基本上,所有管理器都在此处启动。立即值得注意的一点是,我们不仅传递了对象数组,还传递了一个回调方法。该方法将处理所有魔法,确定所有图像是否已加载,并在屏幕上输出一些有用的信息以告知用户。
resourceManager.callback = function() {
resourceManager.done += 1;
infoTexts.splice(0, 1);
if(ready = resourceManager.done === resourceManager.total)
resourceManager.ready();
else
infoTexts.push(new infoText(c.canvas.width / 2, c.canvas.height / 2, 100,
"Loading " + parseInt(100 * resourceManager.done / resourceManager.total) + "%",
secondaryColors[0]));
draw();
};
如果所有外部文件(图像和音频)都已加载,则会调用 ready()
方法。这是执行清理任务的方法。在我们的例子中,最重要的调用是设置网络。在当前代码(单人游戏)中,这不会做任何事情。然而,在第三篇文章中,这将变得重要(并且它看起来会更像我们在第一篇文章中实现了一个非常简单的多人游戏)。
resourceManager.ready = function() {
//Set other unimportant tasks
network.setup();
};
由于 imageManager
对象按上次说明的方式工作,这次我们将看看 audioManager
。这个管理器和图像管理器之间有一些主要区别。首先,让我们看看代码:
var soundManager = function(sounds, callback) {
this.soundNames = sounds;
this.sounds = [];
this.count = sounds.length;
for(var i = 0; i < this.count; i++) {
var t = document.createElement('audio');
t.preload = 'auto';
t.addEventListener('loadeddata', function() {
callback();
}, false);
t.src = 'sounds/' + this.soundNames[i] + '.wav';
this.sounds.push([t]);
}
};
构造函数只接受声音名称数组以及成功加载声音文件后要调用的方法。为了实现这一点,我们设置了各种选项。其中一个选项是使用自动预加载。另一个选项是将事件监听器绑定到 loadeddata
事件。一旦整个音频片段加载完成,这将触发回调方法。imageManager
中已知的 load
事件在这里不适用。最后,声音文件被添加到由本地管理器对象维护的数组(<audio>
标签)中。
接下来我们看的是 get()
方法。代码中的所有方法都可以通过 get()
方法访问外部文件(放置在相应的标签中)。让我们看看它:
soundManager.prototype.play = function(name) {
if(settings.playSounds)
for(var i = this.count; i--; ) {
if(this.soundNames[i] === name) {
var t = this.sounds[i];
for(var j = t.length; j--; ) {
if(t[j].duration === 0)
return;
if(t[j].ended)
t[j].currentTime = 0;
else if(t[j].currentTime > 0)
break;
t[j].play();
return;
}
var s = document.createElement('audio');
s.src = t[0].src;
t.push(s);
s.play();
return;
}
}
};
这看起来很花哨!为什么我们要使用这么多代码?一个简单的 this.sounds[i].play()
后跟一个 return
语句不就行了吗?答案很明显是**不** - 然而,原因很有趣:音频标签(幸运的是)不能多次播放或混合。目前,Google 在此进行了大量编码,以提供另一个标签来克服所有这些问题,即使 HTML 标准更适合游戏中的音频。由于他们的大多数实现仅限于 Google Chrome,并且所有这些实验仍处于非常早期阶段,因此我们尝试通过这种解决方法来克服这一限制。
基本上,代码只是检查所有匹配的音频标签是否已在播放片段。如果是,将创建一个克隆标签并将其添加到数组中。然后,这个新克隆的标签将播放声音片段。如果声音名称等于请求的名称,则该标签是匹配的。图像可以多次使用,因此这种解决方法仅对 audioManager
是必需的。
包含物品和关卡
为了增加游戏的趣味性,有必要给玩家一些值得骄傲的东西:例如关卡或物品(恢复生命值、弹药或其他)。所有这些都已在游戏循环中实现,并在 draw()
方法之前在循环结束时调用。让我们简要看一下要调用的方法:
var items = function() {
//...
if(game.ticks % 200 === 0) {
//Next Level
game.level++;
//START DRONE WAVE if Level == 15, 30, ...
}
//Give certain levels (2, 5, 10, 25, 50, 75, ...) something interesting
var gt = (game.ticks * game.level);
//Generate an asteroid
if(gt % 100 === 0)
generateAsteroid();
//Generate an AI controlled drone
if(gt % 500 === 0)
generateDrone();
//Cool Items!
if(game.ticks % 50 === 0) {
var coin = Math.random();
if(coin < 0.1)
generateHealthpack();
else if(coin < 0.2)
generateAmmopack();
//...
}
};
炸弹包增加了一个很酷的可能性。拥有炸弹包(包含两个炸弹)后,可以部署其中一颗炸弹。它们将在四秒后引爆。
更多可能性
由于只用一种武器四处飞行真的很无聊,因此有必要包含一些其他(花哨的)武器。在这种情况下,我们选择了一种完全不同的武器:炸弹。特点很简单:玩家不发射炸弹,而是部署它。炸弹不会立即引爆,它会给玩家一些时间来逃离炸弹的爆炸半径。这是强制性的,因为炸弹也会对玩家造成伤害。炸弹的伤害由平方公式计算。这是因为炸弹的伤害与它覆盖的区域成反比。区域在这种情况下是一个圆形,即我们炸弹的伤害半径存在平方关系。
为了包含另一种武器,需要进行多项扩展。一个扩展是另一个物品。它的实现方式与包含弹药包和其他补给包的方式相同。另一个要求是飞船有一个包含炸弹数量的属性。此外,飞船的逻辑必须检查是否按下了某个键,并在玩家至少有一个炸弹的情况下部署炸弹。
设置 AI
计算机控制的无人机必须是智能的(不用担心——还没到天网时代!)。为了节省重要的 CPU 周期并使游戏仍然令人愉快,我们实现了一种非常基础的方法,以一种*不显眼*的方式来保持移动。我们的逻辑遵循以下简单条件:
- 如果小行星直接在飞行路径上(且距离在一定范围内),无人机必须开火
- 计算飞向玩家飞船的角度
- 如果角度大于某个容差,则开始旋转
- 否则,如果玩家飞船距离在某个范围内,则开火
这已按以下方式实现:
drone.prototype.logic = function() {
//Set up variables and calculate some tolerances
var ta = d2g(this.angle); //degrees to grad of current angle
//Loop over all asteroids
for(var i = asteroids.length; i--; ) {
t1 = asteroids[i].x - this.x;
t2 = asteroids[i].y - this.y;
d = Math.sqrt(t1 * t1 + t2 * t2);
beta = Math.acos((Math.sin(ta) * t1 + Math.cos(ta) * t2) / d);
//The drone can shoot and it should (angle is OK and distance is OK) shoot
if(this.cooldown === 0 && beta < tol2 && d < bomb2) {
this.cooldown = DRONE_INIT_COOLDOWN;
particles.push(new particle(this.x, this.y,
(3 + this.speed) * Math.sin(ta), - (3 + this.speed) * Math.cos(ta), this));
//LEAVE iteration
break;
}
}
//Now let's have a look for the player's ship
var f = this.x > ships[0].x ? 1 : -1;
t1 = this.x - ships[0].x;
t2 = this.y - ships[0].y;
d = Math.sqrt(t1 * t1 + t2 * t2);
beta = Math.acos((Math.sin(ta) * f * t1 + Math.cos(ta) * t2) / d);
//What to do with those numbers?
if(beta > tol) {
this.angle = this.angle + f * ROTATE;
} else if(this.cooldown === 0 && d < MAX_BOMB_RADIUS) {
this.cooldown = DRONE_INIT_COOLDOWN;
particles.push(new particle(this.x, this.y,
(3 + this.speed) * Math.sin(ta), - (3 + this.speed) * Math.cos(ta), this));
}
//...
};
这个 AI 只是一个有趣的开端,并且有一些愚蠢的后果。一方面,无人机无疑会成为你的眼中钉(考虑在 150 级有 150 架无人机涌入),另一方面,这也将帮助玩家摧毁小行星。这个有趣的副作用也在一个阻止性的信息文本中显示,该文本会在每一波无人机出现时显示。稍后将详细介绍。
渐变文本
为了以一种不显眼且酷炫的方式显示小的通知文本,我们需要引入一个新的对象:infoText
。基本构造函数非常简单,具有以下源代码:
var infoText = function(x, y, time, text, color) {
this.x = x;
this.y = y;
this.time = time;
this.total = time;
this.text = text;
this.color = color;
};
基本上,我们只设置文本的(中心)位置,以及颜色和时间。我们将时间存储两次,以便在一个时间上进行递减,仍然拥有原始值。然后使用此值来确定颜色的当前 alpha 值,其中 1 是起始值,0 是最终值。绘制在 draw()
方法中执行:
infoText.prototype.draw = function() {
c.save();
c.translate(this.x, this.y);
c.textAlign = 'center';
c.fillStyle = 'rgba(' + this.color + ', ' + (this.time / this.total) + ')';
c.fillText(this.text, 0, 0);
c.restore();
};
阻塞文本
渐变文本是一个很好的功能,但它不适合用于介绍或片尾字幕或其他需要阻止游戏逻辑的场景。另一种文本类型的额外优点是,某些文本应该缓慢显示,以避免给玩家屏幕上充满字母。解决方案是 introText
对象,该对象已按以下方式设置:
var introText = function(sx, sy, maxwidth, maxheight, lineheight, textArray) {
this.fulltext = textArray;
this.currentline = 0;
this.currentindex = 0;
this.lines = textArray.length;
this.linelength = textArray.length > 0 ? textArray[0].length : 0;
this.text = [''];
this.font = '20px Orbitron';
this.fillcolor = 'rgb(255, 255, 255)';
this.strokecolor = 'rgb(0, 0, 0)';
this.x = sx;
this.y = sy;
this.lineheight = lineheight;
this.width = maxwidth;
this.height = maxheight;
this.fadetime = 50;
};
在这里,我们看到很多属性都已设置。构造函数调用基本上包含要在某个起始位置(x
和 y
)显示的文本,以及文本的最大宽度和最大高度。此外,还必须指定行高。重要的是要注意,不同的行必须被分割成一个文本数组,其中每个条目包含一行文本。
目前,画布不像 GDI+(包含在 .NET Framework 中)中的文本绘制方法那样方便。一个缺点是它不自动包含换行符或指定文本最大宽度的选项。我们目前唯一的选择是测量文本,并根据文本测量结果减少每行中的字符数。
代码将执行以下操作:
- 当前行已完成?然后转到下一行。
- 没有下一行了吗?然后开始渐隐。
- 计算当前索引并添加下一个单词。
- 如果从开头到下一个单词的文本宽度大于行宽,则开始新的一行显示。
- 将下一个字母附加到当前显示行。
- 递增位置索引。
如果作为参数传递了至少一行文本,代码将起作用。大部分代码当然可以在不进行逐个字符显示效果的情况下使用。基本上,它可以在循环中使用,以便在 <canvas>
元素上的框中显示一些文本。
introText.prototype.logic = function() {
if(this.currentindex === this.linelength) {
this.currentline += 1;
this.text.push('');
this.currentindex = 0;
if(this.currentline < this.lines)
this.linelength = this.fulltext[this.currentline].length;
}
if(this.currentline === this.lines)
return --this.fadetime;
var idx = this.text.length - 1;
var text = this.text[idx];
var line = this.fulltext[this.currentline];
var chr = line[this.currentindex];
var next = line.indexOf(' ', this.currentindex);
var plus = '';
if(next > 0)
plus = line.substring(this.currentindex, next);
else if(next < 0)
plus = line.substring(this.currentindex);
c.save();
c.font = this.font;
if(c.measureText(text + plus).width > this.width)
this.text.push(chr);
else
this.text[idx] = text + chr;
c.restore();
this.currentindex += 1;
return true;
};
集成社交连接器
一个全新的功能是主菜单中的社交栏。请注意,这本身并不是什么新鲜事:与原始文章相比,它只是 SpaceShoot 的一个新功能。此类连接器可以帮助任何游戏为人所知,因为它们允许人们毫不费力地分享网站。让我们看看包含 Facebook、Google+ 和 Twitter 的(非常基础的)社交插件的 HTML:
<div id="promo">
<a href="https://twitter.com/share" class="twitter-share-button" data-url="http://html5.florian-rappl.de/SpaceShootSingle/" data-lang="en" data-count="vertical">Tweet</a>
<div class="g-plusone" data-size="tall" data-href="http://html5.florian-rappl.de/SpaceShootSingle/"></div>
<div class="fb-like" data-href="http://html5.florian-rappl.de/SpaceShootSingle/" data-send="false" data-layout="box_count" data-width="60" data-show-faces="false" data-action="like"></div><div id="fb-root"></div>
</div>
</div>
这是应该包含在网站上的官方代码,以便 JavaScript 能够正常工作。为了将这些外部 JavaScript 放在一个组合的位置,我们将其放在一个名为 *promo.js* 的文件中。该文件包含以下 JavaScript 片段:
!function(d,s,id) {
var js,fjs=d.getElementsByTagName(s)[0];
if(!d.getElementById(id)) {
js=d.createElement(s);
js.id=id;
js.src="http://platform.twitter.com/widgets.js";
fjs.parentNode.insertBefore(js,fjs);
}}(document,"script","twitter-wjs");
(function(d, s, id) {
var js,fjs=d.getElementsByTagName(s)[0];
if(d.getElementById(id))
return;
js=d.createElement(s);
js.id=id;
js.src="https://#/en_US/all.js#xfbml=1";
fjs.parentNode.insertBefore(js,fjs);
}(document, 'script', 'facebook-jssdk'));
(function() {
var po=document.createElement('script');
po.type='text/javascript';
po.async=true;
po.src='https://apis.google.com/js/plusone.js';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(po, s);
})();
源代码已略作修改。但是,主要目的完全没有改变。这些脚本中的每一个的行为都相似:它们都创建一个脚本标签并设置相应的目标源。一个值得注意的地方是,只有 Google 的脚本执行异步操作(如果可用)。这是其他脚本中缺失的功能,并且应该包含在 SpaceShoot(或任何网站)的最终版本中。
关注点
为了使游戏与服务器完美配合,代码需要进行一些更改。这些更改将在下一篇文章中详细讨论。其中大部分更改涉及自定义生成对象,如小行星、飞船等,以及对游戏循环的更改。实时版本还包含一些社交集成,以便更容易分享,以及一些方法来使单人游戏的作弊稍微困难一些。
功能齐全的单人游戏可以在 http://html5.florian-rappl.de/SpaceShootSingle/ 实时查看。
截图显示了我的个人最高得分。应该注意的是,这并不是最好的整体最高得分——我的一个同事达到了 251 级,得分超过 190000 分。在如此高的关卡中,最可能杀死玩家的将不再是小行星,而是(波次的)无人机。一次面对大约 180 架无人机几乎是不可能完成的任务,除非拥有大量的生命值、炸弹、弹药和激活的护盾。祝所有尝试者好运!
这是基于 **SpaceShoot** 游戏的第二篇文章。第一篇可以在 CodeProject 上找到,网址为 https://codeproject.org.cn/Articles/314965/SpaceShoot-A-multiplayer-game-in-HTML5。
浏览器问题
根据官方来源,游戏应在所有当前浏览器(IE 9、Chrome 17、Safari 5.1、Opera 11.6 和 Firefox 10)上正常运行。然而,似乎所有这些浏览器都实现了 <audio>
标签,但并非所有指定的事件都实现了。因此,在使用这些浏览器之一时,您可能会遇到一些问题。
该游戏主要是在 Opera 上开发的。在 Google Chrome 上也进行了大量测试——因此这两个可能是最安全的选择。如果您在使用任何当前浏览器(包括 Opera 和 Chrome)时遇到问题,请随时尽快在评论中报告。
历史
- v1.0.0 | 初始发布 | 2012.02.12。
- v1.1.0 | 更新社交连接器 | 2012.02.14。
- v1.1.1 | 更新浏览器问题 | 2012.02.15