65.9K
CodeProject 正在变化。 阅读更多。
Home

UltimateVolley

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (34投票s)

2012年10月12日

CPOL

26分钟阅读

viewsIcon

98856

downloadIcon

2192

创建一款超级棒的浏览器游戏,延续了传奇的Blobby Volley的路线!

Ultimate Volley

  1. 引言
  2. 背景
  3. 在线实时版本
  4. 特点
  5. 游戏规则
  6. HTML和CSS
  7. 基本代码设计
    1. 一个简单的辅助工具
    2. 类图
    3. 游戏算法
    4. 控制游戏
    5. 物理方面
    6. 一个小技巧:回放
    7. 可调整的常量
  8. 使用代码
  9. 关注点
  10. 历史

介绍

22至29岁的所有德国人应该都熟悉Blobby Volley这款游戏。我不太确定它在德国以外是否也那么流行,但你可以相信我,这款游戏在几年前真的很受欢迎。在这个游戏中,两个外星软糖熊互相玩排球。这些外星软糖熊就是所谓的“blob”。这就是为什么这款游戏被称为Blobby Volley。游戏本身是MS-DOS游戏Arcade Volley的高级版本,规则相似。原始的Blobby Volley可以在Sourceforge找到。

原始版本的截图如下所示

The original version

如今这款游戏已经不再那么受欢迎了。在我看来,其中一个原因是其大部分静态的游戏玩法。游戏本身有第二版,并且也已移植到网络上。然而,可用的版本仍然保持着旧的静态感觉。因此,需要某种新颖的元素才能让游戏再次变得生动有趣——如果你明白我的意思的话。我的版本是基于我的一位学生编写的旋转算法。

我很想改变那个旋转算法,但是玩了大约两周(学生做的)游戏后,我发现那个(有时有bug的)旋转算法带来了我在电脑游戏中从未有过的乐趣。游戏本身可能具有挑战性,我们已经举办了一场锦标赛,并确定了一位“世界冠军”。

背景

在本文中,我们将介绍基本的代码细节和最重要的算法。我将解释游戏集成和时间片分割的概念。我还将尝试展示一个有望解决跨浏览器键盘实现的良好方案。这里呈现的代码将是第一阶段的代码。它是实验性的,因此任何人都可以查看并试用。在下一阶段,我将把游戏打包到一个漂亮的网站中,为用户提供配置可能性。

最后阶段实际上将涉及将游戏与SignalR连接起来,从而实现实时通信和网络游戏。虽然我们将在本文中讨论第一阶段,但第二和第三阶段将在下一篇文章中发布,或者有一天直接上线。这很大程度上取决于未来几个月我能在这方面投入多少时间,考虑到Intel AppUp竞赛和我的工作量。

在线实时版本

您可以观看在线演示的YouTube视频

YouTube video

如果您想亲自尝试一下,只需访问我的主页。在那里您可以玩与上面源代码包下载版本相同的游戏。

特色功能

此版本将包含以下方面(* = 不包括在第一阶段)

  • 额外的(不真实的)旋转物理特性——为游戏增添额外的技巧因素
  • 随机位置(每个位置都有不同的背景图像);目前对球的物理特性没有影响
  • 随机开球
  • 脉搏测量,移动能力随脉搏升高而降低(如果脉搏过高)
  • 球网可以晃动(被球击中时)并具有圆顶
  • 即时回放和每次回合的回放
  • 弱电脑敌人
  • *强电脑敌人
  • *带聊天和观察者的在线多人游戏
  • *在线大厅和排名系统

这些功能中的大部分已经包含在第一阶段。目前,电脑敌人只是为了好玩而加入的。当前的实现是根据随机数进行移动,没有考虑任何实际游戏的知识/计算。这肯定会在某个时候改变——但目前我们对这个弱电脑敌人感到满意。

游戏规则

原则上它与排球非常接近。更具体地说,如果每个队只有一名球员,它可能就像排球一样。每个球员最多可以连续接触球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为beachdiv元素
  • 一个带有备用提示的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的编程可以让我们(程序员)有能力像我们实际拥有类一样思考。这种思考实际上在两种情况下有效:

  1. 我们可以使用熟悉(或合理)的关键字,给我们一种使用类的印象。
  2. 我们可以直接使用诸如基函数、重写和继承等特性。

由于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()方法的类的基类

第一个分支在展示方面相当不那么有趣。目前只创建了一个资源管理器——它负责图像。在项目的第二阶段,将添加音效和音乐——那时需要一两个额外的资源管理器。

第二个分支如下图所示。

VirtualObject inheritance

基本上,所有未实现`paint()`方法(以及`width`、`height`等相关属性)的对象都派生自`VirtualObject`类。该类为所有继承类提供了基本的字符串和数字操作方法。

我们将在稍后讨论`Control`类的实现。我们还将深入讨论`Keyboard`类的实现。

最后一个分支具有以下示意图表示

DrawObject inheritance

这里我们看到基本上有三个重要的类。第一个是ViewPort。它捆绑所有绘图对象并调用它们的paint()方法。还有一个特殊的派生类叫做ReplayViewPort,用于显示回放。

下一个子分支是Figure类。Figure是游戏中任何可以移动的对象(无论是通过与其他图形的交互,还是通过键盘控制)。目前(可能永远),有三种类型的图形:PlayerBallNet

Player总是由Control类型的一个实例控制。因此,创建了Player的另外两个派生类。这两个类由AI/预设输入控制。

最后一个子分支是Field。这里我们有一个真实的BigField,它基本上是完整的ViewPort,以及它的一部分;一个SubFieldField负责检查其子级的边界条件。一个Player实例将在一个SubField实例中,而Ball总是位于一个BigField中。

最终,所有内容都通过Game实例连接在一起。它们之间的关系如下

DrawObject inheritance

所以基本上一切都依赖于这个实例。玩家和观察者是例外,他们不是由游戏直接创建的,而是被添加进去的。理论上可以有多个玩家——但目前我们只限定为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()函数。

这个函数首先会检查逻辑是否应该暂停几帧。这发生在玩家得分之后。为了不直接开始(并可能为玩家做一些意想不到的事情),逻辑应该等待一小段时间。

A game in orlando

此函数执行的第二件事是更新任何玩家的控制。因此,我们告诉位于`players`数组中的`Player`的所有实例调用`steer()`函数。

在这些强制更新之后,我们还会更新当前回放录制的数据。然后(终于!)我们执行逻辑步骤。

每个逻辑步骤由以下几点组成

  1. 执行球的逻辑(与边界的碰撞检测、移动)
  2. 执行网的逻辑(移动)
  3. 执行每个玩家的移动(与边界的碰撞检测、移动)
  4. 执行球与每个玩家的碰撞检测
  5. 执行球与球网的碰撞检测

这些步骤会一遍又一遍地执行。因此,我们正在进行一个无限小的积分。我们这样做是为了防止球没有击中玩家的问题(如果速度大于玩家)。

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 值之和的平方。

然后可以将其与两个半径之和的平方进行比较。如果该值大于两个半径之和的平方,则我们未检测到碰撞,否则我们已发现碰撞,并且必须将球放回目标表面。现在棘手的部分开始了!

让我们在一个简短的草图中看一下所有涉及的变量。该草图显示了实际碰撞发生之前的游戏画面。

Involved physical variables

我们可以看到,球和目标都具有一定的速度。这里我们有一个速度矢量,即总速度的一部分沿x方向,剩余部分沿y方向。我们还看到球和目标之间的位置存在某种角度(称为α)。如果碰撞中α = 0,则我们是纯粹的水平碰撞。如果α = Π/2或90°,则我们是纯粹的垂直碰撞。

我们还看到球具有一些旋转属性。确切的物理原理有些复杂,并未完全移植过来。因此,我们不会对此进行深入探讨。我们只需说明以下两点即可

  1. 旋转物理部分已实现,旨在为游戏带来一些动作
  2. 没有这部分,游戏也能正常运行——但球永远不会开始旋转

因此,由您来决定是否排除或改进旋转物理部分。旋转物理存在一些已知问题——但出于个人原因,我确实喜欢当前的实现(也许让球爬上墙壁的一个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游戏帮助了我们。

Replay

这是最终版本——我们只是加入了“回放”字样,以便所有人都能立即意识到:嘿!这是回放!也可以考虑加入其他东西;比如深色叠加层或一些有趣的漫画图形。但目前这样就可以了!我也没有显示比分(如上所述)——但它们保存在回放文件中,即比分可以在其他地方使用。

可调整的常量

以下是文件`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日
© . All rights reserved.