UltimateVolley






4.99/5 (34投票s)
创建一款超级棒的浏览器游戏,延续了传奇的Blobby Volley的路线!
介绍
22至29岁的所有德国人应该都熟悉Blobby Volley这款游戏。我不太确定它在德国以外是否也那么流行,但你可以相信我,这款游戏在几年前真的很受欢迎。在这个游戏中,两个外星软糖熊互相玩排球。这些外星软糖熊就是所谓的“blob”。这就是为什么这款游戏被称为Blobby Volley。游戏本身是MS-DOS游戏Arcade Volley的高级版本,规则相似。原始的Blobby Volley可以在Sourceforge找到。
原始版本的截图如下所示
如今这款游戏已经不再那么受欢迎了。在我看来,其中一个原因是其大部分静态的游戏玩法。游戏本身有第二版,并且也已移植到网络上。然而,可用的版本仍然保持着旧的静态感觉。因此,需要某种新颖的元素才能让游戏再次变得生动有趣——如果你明白我的意思的话。我的版本是基于我的一位学生编写的旋转算法。
我很想改变那个旋转算法,但是玩了大约两周(学生做的)游戏后,我发现那个(有时有bug的)旋转算法带来了我在电脑游戏中从未有过的乐趣。游戏本身可能具有挑战性,我们已经举办了一场锦标赛,并确定了一位“世界冠军”。
背景
在本文中,我们将介绍基本的代码细节和最重要的算法。我将解释游戏集成和时间片分割的概念。我还将尝试展示一个有望解决跨浏览器键盘实现的良好方案。这里呈现的代码将是第一阶段的代码。它是实验性的,因此任何人都可以查看并试用。在下一阶段,我将把游戏打包到一个漂亮的网站中,为用户提供配置可能性。
最后阶段实际上将涉及将游戏与SignalR连接起来,从而实现实时通信和网络游戏。虽然我们将在本文中讨论第一阶段,但第二和第三阶段将在下一篇文章中发布,或者有一天直接上线。这很大程度上取决于未来几个月我能在这方面投入多少时间,考虑到Intel AppUp竞赛和我的工作量。
在线实时版本
您可以观看在线演示的YouTube视频
如果您想亲自尝试一下,只需访问我的主页。在那里您可以玩与上面源代码包下载版本相同的游戏。
特色功能
此版本将包含以下方面(* = 不包括在第一阶段)
- 额外的(不真实的)旋转物理特性——为游戏增添额外的技巧因素
- 随机位置(每个位置都有不同的背景图像);目前对球的物理特性没有影响
- 随机开球
- 脉搏测量,移动能力随脉搏升高而降低(如果脉搏过高)
- 球网可以晃动(被球击中时)并具有圆顶
- 即时回放和每次回合的回放
- 弱电脑敌人
- *强电脑敌人
- *带聊天和观察者的在线多人游戏
- *在线大厅和排名系统
这些功能中的大部分已经包含在第一阶段。目前,电脑敌人只是为了好玩而加入的。当前的实现是根据随机数进行移动,没有考虑任何实际游戏的知识/计算。这肯定会在某个时候改变——但目前我们对这个弱电脑敌人感到满意。
游戏规则
原则上它与排球非常接近。更具体地说,如果每个队只有一名球员,它可能就像排球一样。每个球员最多可以连续接触球3次。如果球落地,比赛结束。
发球时,每个球员也有3次连续接触球的机会。击中边界和球网的次数不限。每局比赛得分达到25分,赢得比赛需要2局。一旦一名球员得分,他就有权发球。
所有这些规则都可以更改。其中一些可以在constants.js文件中找到,而另一些必须在指定的例程中更改。最大分数和最大局数在Game
类中设置(稍后会详细介绍)。这样做是为了方便更改默认值(从HTML应用程序)。
赢得一局比赛需要两名玩家之间至少有两分的差异。因此,任何一局比赛都不会以25-24结束。在这种情况下,26-24可能是最终比分,29-27或27-29也可能是。这完全取决于哪位玩家将首先获得2分的领先。
HTML和CSS
让我们从HTML开始。下面的大部分代码都很明显,例如正确的文档类型、设置正确的字符集或包含一些外部样式表。在body标签的末尾,我们按正确的顺序包含了所有必需的脚本。这就是奇迹发生的地方。
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Ultimate Volley</title>
<link rel="stylesheet" href="Content/style.css" />
</head>
<body>
<div id="beach"></div>
<canvas id="canvas" width="1000" height="600">
Your browser is too old. Please consider upgrading your browser.
</canvas>
<script src="Scripts/oop.js" charset="utf-8"></script>
<script src="Scripts/constants.js" charset="utf-8"></script>
<script src="Scripts/variables.js" charset="utf-8"></script>
<script src="Scripts/enums.js" charset="utf-8"></script>
<script src="Scripts/game.js" charset="utf-8"></script>
<script>
/* We'll discuss this later! */
</script>
</body>
</html>
应该指出的是,在生产网站上包含所有这些脚本不是必需的。在这里,我们只需将所有文件打包成一个并进行最小化。因此,只需要向网络服务器发出一个请求,并且传输大小已减小。因此,我们只看到页面上两个感兴趣的部分
- 一个ID为
beach
的div
元素 - 一个带有备用提示的
canvas
元素
让我们看一下样式表,感受一下页面的外观。首先,我们会注意到页面使用了一种名为*Merge*的特殊字体。实际上,这种字体并不是很特殊(通常我们会包含各种特殊/奇怪/酷炫的字体,因为游戏不仅依赖图形,也依赖排版),但它看起来确实很漂亮,会给游戏带来独特的外观。
@font-face { /* Define a special font */
font-family: 'Merge';
src:url('fonts/merge_light.eot');
src:local('☺'), /* To prevent old IEs from reading this */
url('fonts/merge_light.woff') format('woff'),
url('fonts/merge_light.otf') format('opentype'),
url('fonts/merge_light.ttf') format('truetype'),
url('fonts/merge_light.svg') format('svg');
font-weight: normal;
font-style: normal;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%; /* nice background tiles */
background: url('background.png');
}
#canvas {
display: block;
position: absolute;
top: 50%;
left: 50%;
margin-left: -500px; /* Center it */
margin-top: -300px;
}
#beach {
display: block;
position: absolute;
top: 50%;
left: 50%;
margin-left: -500px; /* Center above canvas */
margin-top: -380px;
height: 80px;
width: 1000px;
font: 36pt Merge;
color: white;
text-align: center;
}
我们还为元素选择了绝对定位。canvas
元素将放置在屏幕正中央,div
元素(ID为beach
)则位于其正上方。我们还设置了一些字体大小和文本对齐语句,并调整了网页的背景。
这本身没什么特别的,而且非常无聊!那么让我们转向JavaScript方面,看看所有奇妙的事情是如何发生的。
基本代码设计
阅读过我关于Mario5游戏的文章(可在CodeProject上找到)的人,已经了解了我对JavaScript中类的痴迷。为了不混淆任何人:JavaScript是面向对象的,但面向对象不等于基于类的。JavaScript作为一种(脚本)语言是基于原型的,即没有类(尽管有一些关于新ECMAScript(是的,那是JavaScript的官方名称)版本包含class
关键字及其所有属性的提议)。
但是基于prototype
的编程可以让我们(程序员)有能力像我们实际拥有类一样思考。这种思考实际上在两种情况下有效:
- 我们可以使用熟悉(或合理)的关键字,给我们一种使用类的印象。
- 我们可以直接使用诸如基函数、重写和继承等特性。
由于Mario5文章中介绍的小型助手满足所有要求,我们将在本游戏中再次使用它。
一个简单的辅助工具
JavaScript中OOP的助手放在`oop.js`文件中。它包含以下代码
(function(){
var initializing = false,
fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/;
// The base Class implementation (does nothing)
this.Class = function(){ };
// Create a new Class that inherits from this class
Class.extend = function(prop) {
var _super = this.prototype;
// Instantiate a base class (but only create the instance, don't run the init constructor)
initializing = true;
var prototype = new this();
initializing = false;
// Copy the properties over onto the new prototype
for (var name in prop) {
// Check if we're overwriting an existing function
prototype[name] =
typeof prop[name] == "function" &&
typeof _super[name] == "function" &&
fnTest.test(prop[name]) ?
(function(name, fn) {
return function() {
var tmp = this._super;
// Add a new ._super() method that is the same method
// but on the super-class
this._super = _super[name];
// The method only need to be bound temporarily, so we
// remove it when we're done executing
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, prop[name]) : prop[name];
}
// The dummy class constructor
function Class() {
// All construction is actually done in the init method
if (!initializing && this.init)
this.init.apply(this, arguments);
}
// Populate our constructed prototype object
Class.prototype = prototype;
// Enforce the constructor to be what we expect
Class.prototype.constructor = Class;
// And make this class extensible
Class.extend = arguments.callee;
return Class;
};
})();
在这里,我们将一个名为Class
的对象附加到window
元素(假设this
指向该对象;但由于我们是直接包含它,上下文将是当前window
的上下文)。然后可以使用extend()
函数来使用此对象。因此,以下代码片段将创建一个类
var SomeClass = Class.extend({
init: function() {
//The is the constructor of the class "SomeClass"
},
//Other methods to follow the same pattern
/* Always call base function with this._super() within the corresponding method
});
我们将广泛使用这种构造来构建所有类。
类图
我们直接从Class
对象派生出三个继承树。这三个分支是
- 一个
Resources
类,所有资源管理器都将从中派生 - 一个
VirtualObject
类,它是所有没有paint()
方法的对象的主要起点 - 一个
DrawObject
类,它是所有具有paint()
方法的类的基类
第一个分支在展示方面相当不那么有趣。目前只创建了一个资源管理器——它负责图像。在项目的第二阶段,将添加音效和音乐——那时需要一两个额外的资源管理器。
第二个分支如下图所示。
基本上,所有未实现`paint()`方法(以及`width`、`height`等相关属性)的对象都派生自`VirtualObject`类。该类为所有继承类提供了基本的字符串和数字操作方法。
我们将在稍后讨论`Control`类的实现。我们还将深入讨论`Keyboard`类的实现。
最后一个分支具有以下示意图表示
这里我们看到基本上有三个重要的类。第一个是ViewPort
。它捆绑所有绘图对象并调用它们的paint()
方法。还有一个特殊的派生类叫做ReplayViewPort
,用于显示回放。
下一个子分支是Figure
类。Figure
是游戏中任何可以移动的对象(无论是通过与其他图形的交互,还是通过键盘控制)。目前(可能永远),有三种类型的图形:Player
、Ball
和Net
。
Player
总是由Control
类型的一个实例控制。因此,创建了Player
的另外两个派生类。这两个类由AI/预设输入控制。
最后一个子分支是Field
。这里我们有一个真实的BigField
,它基本上是完整的ViewPort
,以及它的一部分;一个SubField
。Field
负责检查其子级的边界条件。一个Player
实例将在一个SubField
实例中,而Ball
总是位于一个BigField
中。
最终,所有内容都通过Game
实例连接在一起。它们之间的关系如下
所以基本上一切都依赖于这个实例。玩家和观察者是例外,他们不是由游戏直接创建的,而是被添加进去的。理论上可以有多个玩家——但目前我们只限定为2个。在第三阶段,可能会加入特殊的2对2和/或2对1模式。
游戏算法
一个Game
对象有许多方法。其中最重要的两个是play()
和pause()
。第一个启动无限游戏循环并绑定特定的控制事件处理程序,而第二个停止游戏循环并解除事件处理程序的绑定。
var Game = VirtualObject.extend({
/* ... */
play: function() {
var me = this;
if(!me.running) {
me.running = true;
for(var i = this.players.length; i--; )
this.players[i].input.bind();
me.loop = setInterval(function() {
me.tick();
}, LOGIC_STEP);
}
},
pause: function() {
if(this.running) {
this.running = false;
for(var i = this.players.length; i--; )
this.players[i].input.unbind();
clearInterval(this.loop);
}
},
/* ... */
});
然而,如果我们不指定无限游戏循环应该做什么,这两个函数将什么也看不见。在我们的例子中,我们会在每次调用中调用tick()
函数。
这个函数首先会检查逻辑是否应该暂停几帧。这发生在玩家得分之后。为了不直接开始(并可能为玩家做一些意想不到的事情),逻辑应该等待一小段时间。
此函数执行的第二件事是更新任何玩家的控制。因此,我们告诉位于`players`数组中的`Player`的所有实例调用`steer()`函数。
在这些强制更新之后,我们还会更新当前回放录制的数据。然后(终于!)我们执行逻辑步骤。
每个逻辑步骤由以下几点组成
- 执行球的逻辑(与边界的碰撞检测、移动)
- 执行网的逻辑(移动)
- 执行每个玩家的移动(与边界的碰撞检测、移动)
- 执行球与每个玩家的碰撞检测
- 执行球与球网的碰撞检测
这些步骤会一遍又一遍地执行。因此,我们正在进行一个无限小的积分。我们这样做是为了防止球没有击中玩家的问题(如果速度大于玩家)。
var Game = VirtualObject.extend({
/* ... */
tick: function() {
if(!this.wait) {
for(var i = this.players.length; i--; )
this.players[i].steer();
this.instantReplay.addData(this.players);
for(var t = TIME_SLICES; t--; ) {
this.ball.logic();
this.net.logic();
for(var i = this.players.length; i--; ) {
this.players[i].logic();
this.ball.collision(this.players[i]);
}
this.ball.collision(this.net);
}
} else {
this.wait--;
}
this.viewPort.paint(this.ball, this.players);
},
/* ... */
});
最后,我们通过调用附加的`ViewPort`实例来绘制当前场景,包括球和玩家。`viewPort`对象然后完成其余的工作。它的`paint()`方法如下所示
var ViewPort = DrawObject.extend({
/* ... */
paint: function() {
context.clearRect(0, 0, width, height);
context.drawImage(this.background, 0, 0);
this.field.paint();
this.net.paint();
this.ball.paint();
for(var i = this.players.length; i--; )
this.players[i].paint();
if(this.ball.getBottom() > height)
this.paintCursor(this.ball.x);
this.paintScore();
for(var i = this.players.length; i--; )
this.players[i].paintPulse();
this.paintMessage();
},
/* ... */
});
在这里,我们只是清理当前场景并在其上绘制新内容。所以我们首先绘制背景,然后绘制整个场地,球网,最后是球和玩家。之后我们再绘制仪表。
这里我们首先开始显示球是否太高(在画布元素上方/外部)的指示器。然后我们绘制每个玩家的当前分数以及玩家的脉搏。我们还必须绘制任何消息,例如关于玩家赢得一局比赛的信息。
控制游戏
任何电脑游戏最重要的方面之一就是输入。如果程序员编写的输入函数马虎,或者整体游戏设计没有涉及与游戏的适当交互,那么游戏本身就会失败。通过浏览器提供正确的控件基本上相当容易。仔细研究一些细节,我们会发现,仍然需要对设计和实现投入一些思考。
在这种情况下,我们的实现从基类`Control`开始。在不指定任何特定输入设备(鼠标、键盘、触摸屏等)的绑定情况下,我们能够描述输入设备实际应该做什么。在我们的例子中,只能执行(或不执行)3个动作
- 跳跃,即向上移动
- 向左行走/奔跑
- 向右行走/奔跑
我们为这3个动作提供了所有属性和方法。我们的整体设计规定了以下行为:任何输入都只会写入缓冲区,只有在调用`update()`时才会更新实际值。此方法还会保存前一个状态,因此我们始终可以将当前值与先前值进行比较。
我们为什么要这样做?嗯,`update()`方法将在玩家的`steer()`方法中被调用。此方法在任何逻辑周期的开始时调用,但不在时间片集成内部。这意味着:用户只能每40毫秒更改一次blobby的移动方向,而整体游戏分辨率要低得多,并且独立于这40毫秒。
var Control = VirtualObject.extend({
init: function() {
this.reset();
this._super();
},
update: function() {
this.previousUp = this.up;
this.previousLeft = this.left;
this.previousRight = this.right;
this.left = this.bufferLeft;
this.up = this.bufferUp;
this.right = this.bufferRight;
},
reset: function() {
this.up = false;
this.left = false;
this.right = false;
this.bufferUp = false;
this.bufferLeft = false;
this.bufferRight = false;
this.previousUp = false;
this.previousLeft = false;
this.previousRight = false;
},
setUp: function(on) {
this.bufferUp = on;
},
setLeft: function(on) {
this.bufferLeft = on;
},
setRight: function(on) {
this.bufferRight = on;
},
bind: function() { },
unbind: function() { },
cancelBubble: function(e) {
var evt = e || event;
if(evt.preventDefault)
evt.preventDefault();
else
evt.returnValue = false;
if (evt.stopPropagation)
evt.stopPropagation();
else
evt.cancelBubble = true;
},
copy: function() {
if(this.previousUp === this.up && this.previousRight === this.right && this.previousLeft === this.left)
return 0;
return {
l: this.left ? 1 : 0,
u: this.up ? 1 : 0,
r: this.right ? 1 : 0
};
}
});
我们为什么要保存以前的输入数据?嗯,我们可以看看`copy()`方法。它仅用于向回放提供数据。我们稍后会讨论回放,但现在我们可以说,这将节省大量内存(可能还有磁盘空间),从而提高性能。这样做的原因是,如果键没有改变,我们可以引入一种特殊的表示法。否则,我们只提供键的当前状态。
这里需要提及的另一个方法是`cancelBubble()`。这是一个小助手,用于阻止所有浏览器中的事件传播。这应该确保键盘输入不会冒泡到浏览器并产生未定义的行为,例如重新加载页面或打开书签或执行手势(例如返回上一页)。所有这些行为都对出色的游戏体验构成威胁,这就是我们必须消除它们的原因。
让我们来看看`Control`类的一个具体实现
var Keyboard = Control.extend({
init: function(codeArray) {
var me = this;
me._super();
me.codes = {};
me.codes[codeArray[0]] = me.setUp;
me.codes[codeArray[1]] = me.setLeft;
me.codes[codeArray[2]] = me.setRight;
var handleEvent = false;
this.downhandler = function(event) {
handleEvent = me.handler(event, true);
return handleEvent;
};
this.uphandler = function(event) {
handleEvent = me.handler(event, false);
return handleEvent;
};
this.presshandler = function(event) {
if (!handleEvent)
me.cancelBubble(event);
return handleEvent;
};
},
bind: function() {
document.addEventListener('keydown', this.downhandler, false);
document.addEventListener('keyup', this.uphandler, false);
document.addEventListener('keypress', this.presshandler, false);
//The last one is required to cancel bubble event in Opera!
},
unbind: function() {
document.removeEventListener('keydown', this.downhandler, false);
document.removeEventListener('keyup', this.uphandler, false);
document.removeEventListener('keypress', this.presshandler, false);
},
handler: function(e, status) {
if(this.codes[e.keyCode]) {
(this.codes[e.keyCode]).apply(this, [status]);
this.cancelBubble(e);
return false;
}
return true;
},
});
当然,我们需要实现`bind()`和`unbind()`方法。通常我们只需要对*keydown*和*keyup*事件执行此操作,但Opera中的一个bug(?)迫使我们也要绑定*keypress*事件。如果我们不这样做,事件仍然会传播。
在`Keyboard`类的构造函数中,我们将对象的键设置为传递的键码。最后,我们只需查看当前输入的键码是否是对象中的一个键,并执行该函数——该函数是键码后面对象的值。`handleEvent`闭包变量的技巧是使该变通方法正常工作所必需的。否则,我们将要么始终停止传播,要么从不停止传播。这使我们能够确定是否应该停止它。
物理方面
在物理方面,我们必须处理弹性/非弹性碰撞的整个能量问题。一旦我们检测到碰撞,真正的麻烦就开始了。检测碰撞非常容易,因为我们只需处理圆形(以及我们的边界,但这些也可以很快解决!)。检测碰撞只是将两个 x 值(第一个来自 blobby;或者通常来自正在检测它是否击中球的对象——第二个来自球)相加,然后将它们平方,并加上两个 y 值之和的平方。
然后可以将其与两个半径之和的平方进行比较。如果该值大于两个半径之和的平方,则我们未检测到碰撞,否则我们已发现碰撞,并且必须将球放回目标表面。现在棘手的部分开始了!
让我们在一个简短的草图中看一下所有涉及的变量。该草图显示了实际碰撞发生之前的游戏画面。
我们可以看到,球和目标都具有一定的速度。这里我们有一个速度矢量,即总速度的一部分沿x方向,剩余部分沿y方向。我们还看到球和目标之间的位置存在某种角度(称为α)。如果碰撞中α = 0,则我们是纯粹的水平碰撞。如果α = Π/2或90°,则我们是纯粹的垂直碰撞。
我们还看到球具有一些旋转属性。确切的物理原理有些复杂,并未完全移植过来。因此,我们不会对此进行深入探讨。我们只需说明以下两点即可
- 旋转物理部分已实现,旨在为游戏带来一些动作
- 没有这部分,游戏也能正常运行——但球永远不会开始旋转
因此,由您来决定是否排除或改进旋转物理部分。旋转物理存在一些已知问题——但出于个人原因,我确实喜欢当前的实现(也许让球爬上墙壁的一个bug仍需修复——但这几乎从未发生过),而且在我看来,这是(不——这是)游戏中最有趣的部分。
旋转功能包含在`Ball`类中,如下所示
var Ball = Figure.extend({
/* ... */
changeSpin: function(vx, vy, dx, dy, sign) {
var distance = dx * dx + dy * dy;
var scalar = (dx * vx + dy * vy) / distance;
var svx = vx + sign * dx * scalar;
var svy = vy + sign * dy * scalar;
this.omega += (svx * dy - svy * dx) / distance;
},
spin: function(vx, vy, dx, dy) {
this.changeSpin(this.vx, this.vy, dx, dy, 1);
this.changeSpin(vx, vy, dx, dy, -1);
},
/* ... */
});
如果我们把代码改成以下片段(即删除`changeSpin()`函数并删除`spin()`函数的主体),那么我们就成功地从游戏中移除了旋转物理。
var Ball = Figure.extend({
/* ... */
// No more changeSpin
spin: function(vx, vy, dx, dy) {
//No body!
},
/* ... */
});
其余的魔法在哪里发生?让我们简要地看一下`Figure`基类。那里有三个重要的函数
var Figure = DrawObject.extend({
/* ... */
checkField: function() {
if(this.y < this.hh) {
this.y = this.hh;
this.vy = 0;
}
if(this.getLeft() < this.container.x) {
this.vx = 0;
this.x = this.container.x + this.wh;
} else if(this.getRight() > this.container.x + this.container.width) {
this.vx = 0;
this.x = this.container.x + this.container.width - this.wh;
}
},
logic: function() {
this.vy -= ACCELERATION;
this.x += this.vx;
this.y += this.vy;
this.checkField();
},
hit: function(dx, dy, ball) {
var distance = dx * dx + dy * dy;
var angle = Math.atan2(dy, dx);
var v = ball.getTotalVelocity();
var ballVx = Math.cos(angle) * this.friction * v;
var ballVy = Math.sin(angle) * this.friction * v;
ballVx += BALL_RESISTANCE * ball.omega * dy / distance;
ballVy -= BALL_RESISTANCE * ball.omega * dx / distance;
ball.setVelocity(ballVx, ballVy);
},
/* ... */
});
我们实际上没有讨论这三个方法中最后一个方法何时被调用。嗯,我们看过了`logic()`方法何时被调用;它在游戏循环内部被调用,这基本上由`Game`类的`tick()`方法表示。
`logic()`方法根据重力定律和玩家输入(改变速度变量)改变当前位置。此外,玩家可能出界。因此,我们还需要在这里检查逻辑,这意味着调用`checkField()`函数。在这里,我们只比较一些变量并决定是否应该反射一些速度。
那么`hit()`方法究竟在哪里被调用呢?我们已经看到对`collision()`方法的调用。这个方法实际上是基础的。`Figure`类已经包含这个方法,但它还没有实现(在JavaScript中,这意味着它存在,但它有一个空的主体)。每个子类都必须实现自己的`collision()`方法(当然它不必实现——但那样就不会发生任何事情)。
这个方法基本上是检查是否发生碰撞(与球的碰撞),并遵循对象实现中的所有定律。如果发生碰撞,`hit()`函数将带参数被调用。参数是
- 水平距离是多少
- 垂直距离是多少
- 把球给我!
最后,`hit`函数获取当前对象和球的属性,并更改球的特定属性。
一个小技巧:回放
如果我们能一遍又一遍地观看精彩的回合,那不是很好吗?就像反复观看最佳进球、最佳触地得分或最佳动作一样。因此,包含录制和播放回放的可能性是必须的。
保存回放毫不费力,因为游戏中没有任何随机数(否则我们需要存储它们)。对于任何一回合,我们只需要以下信息
- 球一开始在哪里(哪个玩家发球?)
- 玩家一开始在哪里?
- 当前比分是多少?
- 每个玩家当前的脉搏是多少?
- 玩家的名字是什么?
- 玩家的颜色是什么?
此外,我们只需存储任何键盘输入。因此,回放文件的首次尝试如下所示
{"beach":"Mauritius","ballx":247.5,"bally":250,"data":[[{"left":true,"up":false,"right":false},{"left":false,"up":false,"right":false}],[{"left":true,"up":false,"right":false},{"left":false,"up":false,"right":false}],[{"left":true,"up":false,"right":false},{"left":false,"up":false,"right":false}],
/* many many more */,
"players":[{"color":"#0000FF","name":"Lefty","points":2,"sets":0},{"color":"#FF0000","name":"Righto","points":1,"sets":0}],"count":580}
总共(我省去了你阅读这些的麻烦!)580帧用了47209个字符!经过一些优化后,回放文件看起来像这样
"{"beach":"Mauritius","ballx":752.5,"bally":250,"data":[[0,{"l":0,"u":0,"r":1}],[0,0],[0,0],[0,0],[0,0],[0,0],[0,{"l":0,"u":0,"r":0}],
/* many many more */,
"players":[{"color":"#0000FF","name":"Florian","points":22,"sets":1},{"color":"#FF0000","name":"Christian","points":19,"sets":0}],"count":442,"contacts":14}"
是的,好的。在这个文件中,我用442次接触获得了4487个字符。因此,如果我们推断并取比率,我们会发现这些更改导致的文件只包含原始数据的1/7到1/8。我还添加了一个名为“contacts”的新属性(用于确定是否值得即时回放)。还有什么被改变了?
- 键盘的属性名称缩短了——左边变成了l,右边变成了r,上边变成了u
- true / false已更改为 1 / 0(在 JavaScript 中仍然评估相同)
- 如果输入没有改变,则添加 0 而不是对象
在这里,我们滥用了对象(即使是空的)评估为`true`,而0评估为`false`。返回零与否的魔力已经实现在我们的`Control`类中。现在让我们看看代表`Replay`的特定类。
var Replay = VirtualObject.extend({
init: function(beach, ball, players) {
/* Initialize data for a new replay */
},
addData: function(players) {
var inputs = [];
this.contacts = 0;
for(var i = 0; i < players.length; i++) {
inputs.push(players[i].input.copy());
this.contacts += players[i].totalContacts;
}
//Before starting to add data - look if the data is worth saving (no action = not worth)
if(this.count === 0) {
var containsData = false;
for(var i = inputs.length; i--; ) {
if(inputs[i]) {
containsData = true;
break;
}
}
if(!containsData)
return;
}
this.data.push(inputs);
this.count++;
},
play: function(game, continuation) {
game.pause();
var frames = this.count;
var index = 0;
/* Create view for replays and fill with objects */
var data = this.data;
for(var i = 0; i < this.players.length; i++) {
var bot = new ReplayBot(game, game.players[i].container);
bot.setIdentity(this.players[i].name, this.players[i].color);
replayBots.push(bot);
}
var iv = setInterval(function() {
if(index === frames) {
//Return to the game
clearInterval(iv);
if(continuation)
continuation.apply(game);
game.play();
return;
}
/* Logic and paint methods being called */
}, LOGIC_STEP / 2);
}
});
`addData()`方法将由`Game`实例调用,并带有一个包含所有玩家的数组。然后将获取每个玩家的当前数据,并构建一个包含这些数据点的临时数组。如果尚未添加任何数据,则将该数组添加,并排除未更改的数组。这排除了在新回合开始时任何玩家开始移动之前的操作。这可以防止保存没有意义的数据。
`play()`方法开始播放回放。因此,它使用另一个`ViewPort`。它必须执行自己的绘画,因为在观看回放时,游戏循环(因此是绘画)是暂停的。一个重要的部分是,这里的循环也可以是递归计时器。这将允许调整回放的速度。目前,回放的速度设置为`LOGIC_STEP / 2`,即真实游戏速度的两倍。这个值在物理上没有任何区别,因为真实游戏评估是在`TIME_SLICES`的集成中执行的。逻辑本身看起来与游戏非常相似。
那么,现在究竟是哪个视口执行绘制呢?嗯,是这个派生版本执行了这项任务
var ReplayViewPort = ViewPort.extend({
init: function(players, container, net, ball) {
var pseudo = {
players: players,
field: container,
net: net,
ball: ball
};
this._super(pseudo);
this.setMessage(Messages.Replay);
},
setup: function() {},
paintScore: function() {}
});
基本上,它与普通的ViewPort
相同,但它不绘制分数,也没有setup()
例程。它还有一个不同的构造函数,它创建一个新对象,其中包含所有回放机器人、场地、球网和球(所有这些都已更改为仅用于回放的东西)。然后将此对象传递给基类的构造函数。如果我们使用TypeScript,并且我们(应该)告诉ViewPort
的构造函数只接受游戏实例,那么我们在这里会得到一个编译器错误。但幸运的是,我们仍然在动态仙境中,而这个pseudo
游戏帮助了我们。
这是最终版本——我们只是加入了“回放”字样,以便所有人都能立即意识到:嘿!这是回放!也可以考虑加入其他东西;比如深色叠加层或一些有趣的漫画图形。但目前这样就可以了!我也没有显示比分(如上所述)——但它们保存在回放文件中,即比分可以在其他地方使用。
可调整的常量
以下是文件`constants.js`中的代码。所有游戏中使用的常量都在这里定义。去尝试调整它们吧。其中一些不应更改,例如`TWOPI`或`MIN_PLAYERS`和`MAX_PLAYERS`。其他一些会产生有趣的移动或行为。
// Defines the width of the net
var NET_WIDTH = 10;
// Defines the height of the net
var NET_HEIGHT = 290;
// Converts grad to rad
var GRAD_TO_RAD = Math.PI / 180;
// Just saves one operation
var TWOPI = 2 * Math.PI;
// Time of 1 logic step in ms
var LOGIC_STEP = 40;
// Number of time slices per iteration
var TIME_SLICES = 40;
// Frames per second (inverse of LOGIC_STEP)
var FRAMES = 1000 / LOGIC_STEP;
// Sets the g factor
var ACCELERATION = 0.001875;
// The minimum amount of players
var MIN_PLAYERS = 2;
// The maximum amount of players
var MAX_PLAYERS = 4;
// The maximum amount of contacts per move
var MAX_CONTACTS = 3;
// The maximum (horizontal) speed of a player
var MAX_SPEED = 0.4;
// The maximum (vertical) speed of a player
var MAX_JUMP = 1.05;
// The (default) maximum number of points per set
var DEFAULT_MAX_POINTS = 25;
// The (default) maximum number of sets per match
var DEFAULT_MAX_SETS = 2;
// The time between two points in ms
var POINT_BREAK_TIME = 450;
// The start height of the ball in px
var BALL_START_HEIGHT = 250;
// Sets the acceleration of the ball through the player
var BALL_SPEEDUP = 0.4;
// Sets the strength of the reflection of the ball while serving
var BALL_LAUNCH = 1.5;
// Sets strength of the reflection of the ball
var BALL_REFLECTION = 0.8;
// Sets the air resistancy of the ball
var BALL_RESISTANCE = 1;
// Sets the drag coefficient of the ball
var BALL_DRAG = 0.005;
// Sets the increase per iteration of pulse
var PULSE_RECOVERY = 0.0004;
// Sets the decrease per iteration of pulse while running
var PULSE_RUN_DECREASE = 0.0005;
// Sets the decrease per iteration of pulse for jumping
var PULSE_JUMP_DECREASE = 0.17;
// Sets the size of the circles in the won-sets-display
var SETS_WON_RADIUS = 10;
如果您想更改游戏规则,只需更改`DEFAULT_MAX_POINTS`或`DEFAULT_MAX_SETS`。更改`MAX_CONTACTS`或任何物理常数也很有趣。
使用代码
游戏需要所有 JavaScript 文件。但是,在这些文件中,找不到实际创建`Game`实例并插入玩家的正确命令。此代码也必须创建(别担心——它已经完成;但请随意更改它!)。这是简单的双人游戏所需的代码,Lefty(左侧)和Righto(右侧),使其正常工作。
键盘可以手动设置。为此,您需要特定的键码。键码列表可以通过Google查找,编写几行代码(在HTML文件、浏览器中或任何JavaScript控制台中)或查阅以下列表
键 | 代码 | 键 | 代码 | 键 | 代码 |
---|---|---|---|---|---|
退格 | 8 | 制表符 | 9 | 回车 | 13 |
Shift | 16 | Ctrl | 17 | alt | 18 |
暂停/中断 | 19 | 大写锁定 | 20 | Escape | 27 |
Page Up | 33 | Page Down | 34 | end | 35 |
主页 | 36 | 左箭头 | 37 | 上箭头 | 38 |
右箭头 | 39 | 下箭头 | 40 | 插入 | 45 |
删除 | 46 | 0 | 48 | 1 | 49 |
2 | 50 | 3 | 51 | 4 | 52 |
5 | 53 | 6 | 54 | 7 | 55 |
8 | 56 | 9 | 57 | a | 65 |
b | 66 | c | 67 | d | 68 |
e | 69 | f | 70 | g | 71 |
h | 72 | i | 73 | j | 74 |
k | 75 | l | 76 | m | 77 |
n | 78 | o | 79 | p | 80 |
q | 81 | r | 82 | s | 83 |
t | 84 | u | 85 | v | 86 |
w | 87 | x | 88 | 是 | 89 |
z | 90 | 左侧视窗键 | 91 | 右侧视窗键 | 92 |
选择键 | 93 | 数字键盘 0 | 96 | 数字键盘 1 | 97 |
数字键盘 2 | 98 | 数字键盘 3 | 99 | 数字键盘 4 | 100 |
数字键盘 5 | 101 | 数字键盘 6 | 102 | 数字键盘 7 | 103 |
数字键盘 8 | 104 | 数字键盘 9 | 105 | 乘法 | 106 |
加法 | 107 | 减法 | 109 | 小数点 | 110 |
除法 | 111 | F1 | 112 | F2 | 113 |
F3 | 114 | F4 | 115 | F5 | 116 |
F6 | 117 | F7 | 118 | F8 | 119 |
F9 | 120 | F10 | 121 | F11 | 122 |
F12 | 123 | 数字锁定 | 144 | 滚动锁定 | 145 |
分号 | 186 | 等号 | 187 | 逗号 | 188 |
破折号 | 189 | 句号 | 190 | 斜杠 | 191 |
重音符 | 192 | 左方括号 | 219 | 反斜杠 | 220 |
右方括号 | 221 | 单引号 | 222 |
参数必须按以下顺序给出:**上、左、下**。因此,默认设置是左侧玩家使用A-W-D键盘(左-上-右),而右侧玩家使用箭头键←-↑-→(左-上-右)。
(function() {
// Create new game
var game = new Game();
// Create the left boundary
var fieldLeft = new SubField(0, 2);
// Set up the keyboard for the left player
var keyboardLeft = new Keyboard([87, 65, 68]);
// Create and add left player
var playerLeft = new Player(game, fieldLeft, keyboardLeft);
playerLeft.setIdentity('Lefty', '#0000FF');
game.addPlayer(playerLeft);
// Create the right boundary
var fieldRight = new SubField(1, 2);
// Set up the keyboard for the right player
var keyboardRight = new Keyboard([38, 37, 39]);
// Create and add right player
var playerRight = new Player(game, fieldRight, keyboardRight);
playerRight.setIdentity('Righto', '#FF0000');
game.addPlayer(playerRight);
// Start game
game.beginMatch();
})();
让我们讨论一下您将在这些JavaScript文件中找到的内容。
- oop.js 包含`Class`原型和OOP魔法的定义
- constants.js 包含游戏使用的常量——常量总是用大写字母书写
- variables.js 包含游戏使用的全局变量——这只是画布和一些属性
- enums.js 包含枚举,例如海滩类型和可用的消息
- game.js 包含所有类和基本上所有的代码
因此,`game.js`文件可能是最有趣的一个。我只能建议您尝试修改一些常量。它会产生非常有趣的效果!
兴趣点
正如我所写,这“只是”一个三阶段开发过程的第一阶段。然而,我确实希望您喜欢这个结果。我已经享受了与我的一些同事进行本地多人游戏的比赛。凭借(有bug的,或者说奇怪的)旋转物理、脉搏和即时回放,我们玩得很开心。甚至有一次回放显示的结果与真实游戏不同。这让我思考:为什么?然而,这仍然很有趣,因为我的对手只是笑得前仰后合,并不断谈论“视频证据”。这个视频证据的事情简直是太搞笑了!
游戏的最终版本将具有与SpaceShoot游戏类似的多人功能(基本概念,最终实现)。然而,它很可能通过使用SignalR编写,因为SignalR也使用长轮询AJAX请求来处理旧的网页浏览器或那些禁用WebSockets的浏览器。
在最终版本中,您可以将最佳回放保存在本地,即在`localStorage`中或通过下载。然后,您可以与一些在线朋友或独自观看它们。一个带有全球游戏系统和排名的聊天大厅也将包含在内。唯一剩下的问题是
谁将成为世界冠军?历史
- v1.0.0 | 首次发布 | 2012年10月12日
- v1.1.0 | 增加了“游戏规则”部分 | 2012年10月13日
- v1.1.1 | 次要代码修复 | 2012年10月13日
- v1.1.2 | 修复了一些错别字 | 2012年10月14日
- v1.1.3 | 增加了目录 | 2012年10月14日
- v1.2.0 | 添加了YouTube视频 | 2012年10月15日
- v1.3.0 | 增加了关于常量的部分 | 2012年10月16日
- v1.3.1 | 在目录中添加了条目 | 2012年10月17日