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

Html5 英式桌球俱乐部

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (203投票s)

2011 年 6 月 28 日

CPOL

18分钟阅读

viewsIcon

385853

downloadIcon

6666

加入 HTML 5 的革命,学习如何创建具有精美图形和强大音频体验的快速游戏。

screenshot

目录

简介

毫无疑问,HTML5 是我们已经看到的伟大 Web 开发革命的背后推手。在 HTML4 统治多年之后,一场新的运动即将彻底改变 Web,释放出迄今为止仅限于 Flash 和 Silverlight 等插件框架运行的现代、丰富的用户体验。

如果您是年轻的开发者,也许您已经从头开始学习 HTML5,所以您可能不会太注意到这种变化。无论如何,我希望这篇文章对您有用,就像我希望像我这样的老家伙也能学到一些新技巧一样。

您的反馈很重要,所以我期待您的来信。但真正让我高兴的是,当您右键单击页面并想到“嘿,这不是 Flash!也不是 Silverlight!”

系统要求

要使用本文提供的 HTML5 台球俱乐部应用程序,您只需安装以下任一 Web 浏览器:Chrome 12、Internet Explorer 9 或 Fire Fox 5

title

游戏规则 - 概述

game

也许您已经知道这个游戏是怎么回事了。需要注意的是,这是“英式斯诺克”,而不是世界各地的许多变体之一。嗯,好吧……实际上,我们称之为“简化英式斯诺克”,因为并非所有规则都已实现。您的目标是通过按照正确的顺序将目标球打入袋中,获得比对手更高的分数。轮到您时,您会获得一次击球机会:用球杆的杆头,您必须击打球杆,瞄准将其中一个红球打入袋中,然后得分一分。如果您打入了至少一个红球,它会留在袋中,您会获得另一次击球机会 - 但现在您必须瞄准将“彩色球”(即所有非红球)打入袋中。如果您成功了,您将获得所打入彩色球的价值。然后彩色球会回到桌面上,您必须尝试打入另一个红球。这种情况一直持续下去,直到您未能将“目标球”(即您当时应该打入的球)打入袋中,这时您应该离开,您的对手将获得下一次击球机会。游戏继续进行,直到所有红球都被打入袋中。当桌面上只剩下 6 个彩色球时,您的目标是以预定的顺序将它们打入袋中:黄色(2 分)、绿色(3 分)、棕色(4 分)、蓝色(5 分)、粉色(6 分)和黑色(7 分)。如果打入了预定球以外的球,它会回到桌面的原始位置。否则,它会留在袋中。当最后一个球(黑球)被击入袋中时,游戏结束,得分最高的玩家获胜。

犯规

如果击球犯规,则对手将获得罚分

  • 如果白球被打入袋中,则罚 4 分
  • 如果白球先碰到错误的球,则该球的价值
  • 如果错误球先被打入袋中,则该球的价值
  • 罚分的最低值为 4 分

下面的代码片段展示了我们如何计算犯规

	var strokenBallsCount = 0;
console.log('strokenBalls.length: ' + strokenBalls.length);
	for (var i = 0; i < strokenBalls.length; i++) {
		var ball = strokenBalls[i];
		// causing the cue ball to first hit a ball other than the ball on
		if (strokenBallsCount == 0) {
		    if (ball.Points != teams[playingTeamID - 1].BallOn.Points) {
		        if (ball.Points == 1 || teams[playingTeamID - 1].BallOn.Points == 1 || 
                fallenRedCount == redCount) {
		            if (teams[playingTeamID - 1].BallOn.Points < 4) {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = 4;
		                $('#gameEvents').append('<br/>Foul 4 points :  Expected ' +
                         teams[playingTeamID - 1].BallOn.Points + ', but hit ' + ball.Points);
		            }
		            else {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = teams[playingTeamID - 1].BallOn.Points;
		                $('#gameEvents').append('<br/>Foul ' + teams[playingTeamID - 1]
                        .BallOn.Points + ' points :  Expected ' + teams[playingTeamID - 1]
                        .BallOn.Points + ', but hit ' + ball.Points);
		            }
		            break;
		        }
		    }
		}

		strokenBallsCount++;
	}

	//Foul: causing the cue ball to miss all object balls
	if (strokenBallsCount == 0) {
		teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1].FoulList.length] = 4;
		$('#gameEvents').append('<br/>Foul 4 points :  causing the cue ball 
        to miss all object balls');
	}

	for (var i = 0; i < pottedBalls.length; i++) {
		var ball = pottedBalls[i];
		// causing the cue ball to enter a pocket
		if (ball.Points == 0) {
		    teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1].FoulList.length] = 4;
		    $('#gameEvents').append('<br/>Foul 4 points :  causing the cue ball
             to enter a pocket');
		}
		else {
		    // causing a ball different than the target ball to enter a pocket
		    if (ball.Points != teams[playingTeamID - 1].BallOn.Points) {
		        if (ball.Points == 1 || teams[playingTeamID - 1].BallOn.Points == 1
                 || fallenRedCount == redCount) {
		            if (teams[playingTeamID - 1].BallOn.Points < 4) {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = 4;
		                $('#gameEvents').append('<br/>Foul 4 points : '
                         + ball.Points + ' was potted, while ' + teams[playingTeamID - 1]
                         .BallOn.Points + ' was expected');
		                $('#gameEvents').append('<br/>ball.Points: ' + ball.Points);
		                $('#gameEvents').append('<br/>teams[playingTeamID - 1]
                        .BallOn.Points: ' + teams[playingTeamID - 1].BallOn.Points);
		                $('#gameEvents').append('<br/>fallenRedCount: ' + fallenRedCount);
		                $('#gameEvents').append('<br/>redCount: ' + redCount);
		            }
		            else {
		                teams[playingTeamID - 1].FoulList[teams[playingTeamID - 1]
                        .FoulList.length] = teams[playingTeamID - 1].BallOn.Points;
		                $('#gameEvents').append('<br/>Foul ' + teams[playingTeamID - 1]
                        .BallOn.Points + ' points : ' + ball.Points + ' was potted, while '
                         + teams[playingTeamID - 1].BallOn.Points + ' was expected');
		            }
		        }
		    }
		}
	}

得分

斯诺克的目标是根据规则合法地将球打入袋中,并获得比对手更高的分数。目标球的分值:红球(1 分)、黄球(2 分)、绿球(3 分)、棕球(4 分)、蓝球(5 分)、粉球(6 分)、黑球(7 分)。

		if (teams[playingTeamID - 1].FoulList.length == 0) {
		    for (var i = 0; i < pottedBalls.length; i++) {
		        var ball = pottedBalls[i];
		        //legally potting reds or colors
		        wonPoints += ball.Points;
		        $('#gameEvents').append('<br/> Potted +' + ball.Points + ' points.');
		    }
		}
		else {
		    teams[playingTeamID - 1].FoulList.sort();
		    lostPoints = teams[playingTeamID - 1].FoulList
                         [teams[playingTeamID - 1].FoulList.length - 1];
		    $('#gameEvents').append('<br/> Lost ' + lostPoints + ' points.');
		}
		teams[playingTeamID - 1].Points += wonPoints;
		teams[awaitingTeamID - 1].Points += lostPoints;

动画化玩家照片

picture animation

游戏中分为两名玩家,每名玩家都有自己的照片和下面的名字。玩家的名字分别是“玩家 1”和“玩家 2”(尽管为用户创建输入可能是一个不错的想法),并且每位玩家都有一张非常聪明的狗在打斯诺克的照片。当一名玩家获得击球机会时,应用程序会“闪烁”他的/她的照片,然后关闭对手照片的灯光。

这是通过更改包含照片的 `img` 元素的 CSS-3 属性 `opacity` 来实现的:我们使用 jQuery 动画将不透明度更改为 0.0,然后再改回 1.0。

	function animateCurrentPlayerImage() {
		var otherPlayerImageId = 0;
		if (playingTeamID == 1)
		    otherPlayerImageId = 'player2Image';
		else
		    otherPlayerImageId = 'player1Image';
		var playerImageId = 'player' + playingTeamID + 'Image';
		$('#' + playerImageId).animate({
		    opacity: 1.0
		}, 500, function () {
		    $('#' + playerImageId).animate({
		        opacity: 0.0
		    }, 500, function () {
		        $('#' + playerImageId).animate({
		            opacity: 1.0
		        }, 500, function () {
		        });
		    });
		});

		$('#' + otherPlayerImageId).animate({
		    opacity: 0.25
		}, 1500, function () {
		});
	}

力度条

strength bar

好的斯诺克玩家知道每次击球需要施加多少力度。不同的技术需要不同类型的击球:直接、间接、使用球边(缓冲)等等。不同方向和不同力度水平的组合可以创造数百万种可能的路径(好吧,我说“数百万”,但这只是一个猜测)。幸运的是,Html5 台球俱乐部配备了一个漂亮的力度条,允许玩家在每次击球前校准他们的球杆。

对于这个功能,我们使用了新的 HTML5 `meter` 元素,该元素用于定义测量值。它充当仪表。建议仅将 `meter` 标签用于具有已知最小和最大值的测量。在我们的例子中,值必须介于零(如果您拥有心灵感应,如 X 教授)和 100(如果您像绿巨人一样流畅)之间。由于 IE9 目前不支持此 `meter` 元素,我改用了带有背景图像的 `div`。效果是相同的。

    #strengthBar { position: absolute; margin:375px 0 0 139px; 
        width: 150px; color: lime; background-color: orange; 
        z-index: 5;}

当您单击力度条时,您实际上是在选择新的力度。起初,您的击球看起来会显得不熟练,但就像在真实游戏中一样,需要时间和训练才能变得完美。

	$('#strengthBar').click(function (e) {
		var left = $('#strengthBar').css('margin-left').replace('px', '');
		var x = e.pageX - left;
		strength = (x / 150.0);
		$('#strengthBar').val(strength * 100);
	});   

显示目标球

ball on

在当前玩家的照片框内,您会注意到一个小球。这就是“目标球”,即玩家此时应该打入袋中的球。如果漏掉目标球,玩家将损失 4 分。如果玩家先击打了目标球以外的球,他/她也会损失 4 分。

目标球是由一组直接绘制在 `canvas` 元素上的图形(弧线和直线)组成的,该 `canvas` 覆盖了每个玩家的照片(我们将在本文稍后处理球的渲染)。所以,当您看到照片上方的那个球时,它看起来就像一个 `div` 上方的标准 `img` 元素,但没有球的图像元素。此外,我们无法直接在 `div` 上绘制弧线和直线,这就是为什么我们需要在玩家图像上方放置一个 `canvas`。

    <canvas id="player1BallOn" class="player1BallOn"> 
	</canvas>
    <canvas id="player2BallOn" class="player2BallOn"> 
	</canvas>
    var player1BallOnContext = player1BallOnCanvas.getContext('2d');
    var player2BallOnContext = player2BallOnCanvas.getContext('2d');
    .
    .
    .
	function renderBallOn() {
		player1BallOnContext.clearRect(0, 0, 500, 500);
		player2BallOnContext.clearRect(0, 0, 500, 500);
		if (playingTeamID == 1) {
		    if (teams[0].BallOn != null)
		        drawBall(player1BallOnContext, teams[0].BallOn, new Vector2D(30, 120), 20);
		}
        else {
		    if (teams[1].BallOn != null)
		        drawBall(player2BallOnContext, teams[1].BallOn, new Vector2D(30, 120), 20);
		    player1BallOnContext.clearRect(0, 0, 133, 70);
		}
	}

旋转吊扇

游戏桌上方的吊扇只是为了好玩(好吧,整篇文章都是为了好玩,但这是一种不同类型的乐趣……)。它为什么在那里?嗯,因为游戏名称是Html5 台球俱乐部,所以我们希望它具有俱乐部的氛围。此外,这也是展示如何实现CSS 3 旋转的一种方式。

实现起来相当简单:首先,我们有一个吊扇的 PNG 图像。不是吊扇本身的图像,而是它的阴影。但是,通过展示投射在游戏桌上的阴影而不是实际的吊扇,我们这样做是故意的,以创造一种吊扇似乎在我们头顶上的效果。

    #roofFan { position:absolute; left: 600px; top: -100px; width: 500px; height: 500px; 
        border: 2px solid transparent; background-image: url('/Content/Images/roofFan.png'); 
        background-size: 100%; opacity: 0.3; z-index: 2;}
    .
    .
    .
    <div id="roofFan"> </div>

为了获得更逼真的氛围,我选择了一张吊扇的图画,并使用 Paint.Net 软件对其应用了模糊/平滑效果。现在您看不到吊扇的边缘了。我认为这是一个简单的解决方案,并且效果很棒。

roof fan

除了图像处理程序的技巧之外,用于吊扇阴影的元素是一个普通的 `div` 元素,其背景为图像。没有什么特别之处。既然有了吊扇,我们就希望它开始旋转。而这正是CSS 3 发挥作用的地方。CSS 3 现在提供了 `rotate` 变换属性,我们将用它来动画化我们的吊扇。

除了是一个强大的功能之外,使用 CSS 3 实现旋转也非常简单。您只需将旋转弧度(以度为单位)应用于元素的 `transform` 属性。在这种情况下,我们使用三个不同的变换属性(针对不同浏览器):`-moz-transform`、`-webkit-transform` 和 `msTransform`。如果我们不再需要担心用户使用哪个浏览器进行游戏,那将很棒,但这又是另一回事了……

下图显示了以 60 度旋转的吊扇 `div` 的快照

roof fan

    var srotate = "rotate(" + renderStep * 10 + "deg)";
    $("#roofFan").css({ "-moz-transform": srotate, 
    "-webkit-transform": srotate, msTransform: srotate });

旋转球杆

rotating the cue

球杆动画是另一个并非游戏真正必需的功能,但肯定增加了许多乐趣。一旦您将鼠标悬停在游戏桌上,您就会注意到球杆实际上会“跟随”鼠标光标。也就是说,球杆会一直瞄准鼠标光标,就像在真实游戏中一样。由于玩家除了自己的眼睛没有其他东西可以瞄准,因此球杆的方向可以提供极大的帮助。

球杆由一个 PNG 图像组成。图像本身不是作为 `img` 元素渲染的,也不是作为其他元素的背景图像渲染的。相反,图像直接渲染在一个专用的 `canvas` 上。我们可以通过动画化一个 `div` 并应用CSS 3 变换来获得相同的结果,但我认为这将有助于展示如何在 `canvas` 上渲染图像。

首先,`canvas` 元素被拉伸到几乎覆盖整个页面宽度。需要注意的是,这个特定的 `canvas` 具有更高的 z-index,因此它可以显示在专用于球的 `canvas` 之上。每当鼠标悬停在桌面上时,目标点就会更新,并且球杆图像会通过 2 种变换进行渲染:首先,通过将球杆平移到球杆球的位置,然后,通过围绕球杆球旋转球杆,以便鼠标指针、球杆球中心和球杆完全对齐。

#cue { position:absolute; }
.
.
.
if (drawingtopCanvas.getContext) {
	var cueContext = drawingtopCanvas.getContext('2d');
}
.
.
.
var cueCenter = [15, -4];
var cue = new Image;
cue.src = '<%: Url.Content("../Content/Images/cue.PNG") %>';

var shadowCue = new Image;
shadowCue.src = '<%: Url.Content("../Content/Images/shadowCue.PNG") %>';
cueContext.clearRect(0, 0, topCanvasWidth, topCanvasHeight);

	if (isReady) {
		cueContext.save();
		cueContext.translate(cueBall.position.x + 351, cueBall.position.y + 145);
		cueContext.rotate(shadowRotationAngle - Math.PI / 2);
		cueContext.drawImage(shadowCue, cueCenter[0] + cueDistance, cueCenter[1]);
		cueContext.restore();
		cueContext.save();
		cueContext.translate(cueBall.position.x + 351, cueBall.position.y + 140);
		cueContext.rotate(angle - Math.PI / 2);
		cueContext.drawImage(cue, cueCenter[0] + cueDistance, cueCenter[1]);
		cueContext.restore();
	}

此外,当我们先渲染球杆阴影时,球杆会变得更加真实。但是,由于阴影位于游戏和球杆本身之间,因此记住在渲染球杆之前渲染阴影很重要。球杆阴影的旋转角度故意与球杆的角度不同。我们这样做是为了给球杆一个3-D 效果。最终结果是一个酷炫、逼真、类似 3-D 的游戏渲染。

球杆阴影只是一个与球杆大小/形状完全相同的图像,但具有平滑的黑色背景。

推/拉球杆

pushing / pulling the cue

pushing / pulling the cue

pushing / pulling the cue

球杆动画也模仿了一个常见的人类特征:您是否见过斯诺克选手在瞄准时推/拉球杆?我们在 Html5 台球俱乐部中通过及时改变球杆球和球杆之间的距离来实现这种效果。球杆会向后拉,直到达到极限。然后它向前推,直到达到另一个极限。只要玩家不移动鼠标光标,这种情况就会一直持续下去。

var cueDistance = 0;
var cuePulling = true;
.
.
.
		function render() {
            .
            .
            .

		    if (cuePulling) {
		        if (lastMouseX == mouseX ||
                lastMouseY == mouseY) {
		            cueDistance += 1;
		        }
		        else {
		            cuePulling = false;
		            getMouseXY();
		        }
		    }
		    else {

		        cueDistance -= 1;
		    }

		    if (cueDistance > 40) {
		        cueDistance = 40;
		        cuePulling = false;
		    }
		    else if (cueDistance < 0) {
		        cueDistance = 0;
		        cuePulling = true;
		    }
            .
            .
            .

显示目标路径

balls

当玩家四处移动鼠标光标时,我们从球杆球的中心到鼠标光标绘制一条虚线。这对于试图进行远距离瞄准的玩家来说特别有用。

这条目标线仅在游戏等待玩家击球时绘制

	if (!cueBall.pocketIndex) {
		context.strokeStyle = '#888';
		context.lineWidth = 4;
		context.lineCap = 'round';
		context.beginPath();

        //here we draw the line
		context.dashedLine(cueBall.position.x, cueBall.position.y, targetX, targetY);

		context.closePath();
		context.stroke();
	}

需要注意的是,HTML5 `canvas` 中没有内置的绘制虚线的功能。幸运的是,该项目包含了一个由用户phrogz 在 StackOverflow 网站上发布的函数。

//function kindly provided by phrogz at:
//http://stackoverflow.com/questions/4576724/dotted-stroke-in-canvas
var CP = window.CanvasRenderingContext2D && CanvasRenderingContext2D.prototype;
if (CP && CP.lineTo) {
    CP.dashedLine = function (x, y, x2, y2, dashArray) {
        if (!dashArray) dashArray = [10, 5];
        var dashCount = dashArray.length;
        this.moveTo(x, y);
        var dx = (x2 - x), dy = (y2 - y);
        var slope = dy / dx;
        var distRemaining = Math.sqrt(dx * dx + dy * dy);
        var dashIndex = 0, draw = true;
        while (distRemaining >= 0.1) {
            var dashLength = dashArray[dashIndex++ % dashCount];
            if (dashLength > distRemaining) dashLength = distRemaining;
            var xStep = Math.sqrt(dashLength * dashLength / (1 + slope * slope));

            var signal = (x2 > x ? 1 : -1);

            x += xStep * signal;
            y += slope * xStep * signal;
            this[draw ? 'lineTo' : 'moveTo'](x, y);
            distRemaining -= dashLength;
            draw = !draw;
        }
    }
}

显示轨迹路径

balls

一旦玩家击球,球杆就会留下一个浅绿色的轨迹,指示其先前位置的路径。

创建此路径比创建我们之前看到的“目标路径”要复杂一些。首先,我们必须实例化一个 `Queue` 对象。本项目中包含的 `Queue` 原型由Stephen Morley 创建。

    var tracingQueue = new Queue();

只要球被渲染,我们就将球杆球的位置存储在队列中

	if (renderStep % 2 == 0) {
		draw();
		enqueuePosition(new Vector2D(cueBall.position.x, cueBall.position.y));
	}

`enqueuePosition` 函数确保我们不存储超过 20 个位置。这是因为我们只想显示球杆球路径的最近部分。

	function enqueuePosition(position) {
		tracingQueue.enqueue(position);
		var len = tracingQueue.getLength();

		if (len > 20) {
		    tracingQueue.dequeue();
		}
	}

接下来,我们遍历队列数组以创建一条虚线路径

	//drawing the tracing line
	var lastPosX = cueBall.position.x;
	var lastPosY = cueBall.position.y;

	var arr = tracingQueue.getArray();

	if (!cueBall.pocketIndex) {
		context.strokeStyle = '#363';
		context.lineWidth = 8;
		context.lineCap = 'round';

		context.beginPath();
		var i = arr.length;
		while (--i > -1) {
		    var posX = arr[i].x;
            var posY = arr[i].y;
		    context.dashedLine(lastPosX, lastPosY, posX, posY, [10,200,10,20]);
		    lastPosX = posX;
		    lastPosY = posY;
		}

		context.closePath();
		context.stroke();
	}

绘制球

balls

球及其阴影绘制在桌边图像下方的特定 `canvas` 上(位于球杆 `canvas` 下方)。

在绘制球之前,我们必须绘制球的阴影。同样,这是为了保持我们试图模拟的伪 3-D 环境。每个球都必须有一个阴影,我们将每个阴影定位在其对应的球的位置,仅存在微小差异。这种差异表明球正在朝特定方向投射阴影,并且还显示了光源的位置。

balls

每个球都通过一个通用函数绘制,该函数接受两个参数

  1. 画布上下文,以及
  2. 球对象

然后该函数创建完整的弧线并使用渐变填充它们,使用分配给球的颜色。

每个球对象都有三种颜色:亮色、中色和暗色。这些颜色用于创建渐变,因此(再次) 3-D 效果也在此处应用。

作为此技术的替代方案,我们可以使用 `canvas` 上的图像,或在普通 `div` 上的图像。但在这种情况下,我们将无法获得与在 `canvas` 上绘制相同的可伸缩、平滑渲染。

    function drawBall(context, ball, newPosition, newSize) {
	    var position = ball.position;
	    var size = ball.size;

        if (newPosition != null)
            position = newPosition;

        if (newSize != null)
            size = newSize;

	    //main circle
	    context.beginPath();
	    context.fillStyle = ball.color;
	    context.arc(position.x, position.y, size, 0, Math.PI * 2, true);

	    var gradient = context.createRadialGradient(
            position.x - size / 2, position.y - size / 2, 0, position.x,
            position.y, size );

	    //bright spot
	    gradient.addColorStop(0, ball.color);
	    gradient.addColorStop(1, ball.darkColor);
	    context.fillStyle = gradient;
	    context.fill();
	    context.closePath();

	    context.beginPath();
	    context.arc(position.x, position.y, size * 0.85, (Math.PI / 180) * 270, 
        (Math.PI / 180) * 200, true);
	    context.lineTo(ball.x, ball.y);
	    var gradient = context.createRadialGradient(
            position.x - size * .5, position.y - size * .5,
            0, position.x, position.y, size);

	    gradient.addColorStop(0, ball.lightColor);
	    gradient.addColorStop(0.5, 'transparent');
	    context.fillStyle = gradient;
	    context.fill();
    }

    function drawBallShadow(context, ball) {
	    //main circle
	    context.beginPath();
	    context.arc(ball.position.x + ball.size * .25, ball.position.y + ball.size * .25,
         ball.size * 2, 0, Math.PI * 2, true);

	    try {
		    var gradient = context.createRadialGradient(
                ball.position.x + ball.size * .25, ball.position.y + ball.size * .25,
                0, ball.position.x + ball.size * .25, ball.position.y + ball.size * .25,
                ball.size * 1.5 );
	    }
	    catch (err) {
		    alert(err);
		    alert(ball.position.x + ',' + ball.position.y);
	    }

	    gradient.addColorStop(0, '#000000');
	    gradient.addColorStop(1, 'transparent');
	    context.fillStyle = gradient;
	    context.fill();
	    context.closePath();
    }

检测球与球之间的碰撞

ball-to-ball collision

球在 `canvas` 上以快速连续的方式渲染:首先,我们清除 `canvas`。然后我们绘制阴影。然后我们绘制球。然后我们更新球的位置。如此反复。在此期间,我们需要检查一个球是否正在撞击另一个球。我们通过检测球与球之间的碰撞来做到这一点。

是时候回忆我们在学校学过的(或没学过的)知识了。三角学告诉我们检查 2 个球之间的距离是否小于半径的 2 倍。在这种情况下,那么是的,我们发生了碰撞,是的,我们必须解决它。

通常,我们会通过计算由每个球的 x 和 y 值(差值)创建的三角形的斜边来计算 2 个球之间的距离。然后,我们将此距离与半径 x 2 进行比较。但幸运的是,有一种更简单、更快捷的方法可以做到这一点。我们只需要比较:(A) (2 x 半径) 的平方与 (B) 差值平方和。如果 (A) 小于 (B),则表示发生了碰撞。

	function isColliding(ball1, ball2) {
		if (ball1.pocketIndex == null && ball2.pocketIndex == null) {
		    var xd = (ball1.position.x - ball2.position.x);
		    var yd = (ball1.position.y - ball2.position.y);

		    var sumRadius = ball1.size + ball2.size;
		    var sqrRadius = sumRadius * sumRadius;

		    var distSqr = (xd * xd) + (yd * yd);

		    if (Math.round(distSqr) <= Math.round(sqrRadius)) {

		        if (ball1.Points == 0) {
		            strokenBalls[strokenBalls.length] = ball2;
		        }
		        else if (ball2.Points == 0) {
		            strokenBalls[strokenBalls.length] = ball1;
		        }
		        return true;
		    }
		}
		return false;
	}

解决球与球之间的碰撞

Resolving Ball-To-Ball Collisions

维基百科图片

我认为球与球之间的碰撞解决是这个项目的核心:如果它不起作用,游戏中的其他一切都将毫无用处。

在我看来,球与球之间的碰撞解决是其中最难的部分。首先,我们比较每个 2 球组合(球 1 和球 2)。然后我们“解决交集”,也就是说,我们将它们移动到碰撞发生时的确切位置。我们通过一些矢量计算来做到这一点。下一步是计算最终的碰撞冲量,最后我们“改变两个球的动量”,也就是说,我们使用生成的冲量矢量来加/减它们的速度矢量。当碰撞解决后,球不再发生碰撞,但它们的位置和速度已因碰撞而改变。

	function resolveCollision(ball1, ball2) {
		// get the mtd (minimum translation distance)
		var delta = ball1.position.subtract(ball2.position);
		var r = ball1.size + ball2.size;
		var dist2 = delta.dot(delta);

		var d = delta.length();

		var mtd = delta.multiply(((ball1.size + ball2.size + 0.1) - d) / d);

		// resolve intersection --
		// inverse mass quantities

		var mass = 0.5;

		var im1 = 1.0 / mass;
		var im2 = 1.0 / mass;

		// push-pull them apart based off their mass
		if (!ball1.isFixed)
		    ball1.position = ball1.position.add((mtd.multiply(im1 / (im1 + im2))));
		if (!ball2.isFixed)
		    ball2.position = ball2.position.subtract(mtd.multiply(im2 / (im1 + im2)));

		// impact speed
		var v = ball1.velocity.subtract(ball2.velocity);
		var vn = v.dot(mtd.normalize());

		// sphere intersecting but moving away from each other already
		//                if (vn > 0)
		//                    return;

		// collision impulse
		var i = (-(0.0 + 0.08) * vn) / (im1 + im2);
		var impulse = mtd.multiply(0.5);

		var totalImpulse = Math.abs(impulse.x) + Math.abs(impulse.y);

            //Do some collision audio effects here...

		// change in momentum
		if (!ball1.isFixed)
		    ball1.velocity = ball1.velocity.add(impulse.multiply(im1));
		if (!ball2.isFixed)
		    ball2.velocity = ball2.velocity.subtract(impulse.multiply(im2));
	}

检测球与角之间的碰撞

乍一看,检测球与角之间的碰撞似乎是一项复杂的任务。但幸运的是,有一种简单有效的方法可以做到这一点:由于角是圆弧段,我们可以将它们想象成固定球。如果我们知道如何正确确定这些固定球的大小和位置,我们就可以像处理球与球之间的检测一样来处理碰撞。事实上,我们应用完全相同的函数来执行这两种检测类别。唯一的区别在于角永远不会移动。

下图显示了如果角球是真实的,它们会在哪里

corner balls

解决球与角之间的碰撞

如前所述,球与球碰撞和球与角碰撞解决之间的唯一区别是,在第二种情况下,我们必须确保不改变代表角的“虚拟球”的位置(或速度向量)。

	function resolveCollision(ball1, ball2) {
        .
        .
        .
		// push-pull them apart based off their mass
		if (!ball1.isFixed)
		    ball1.position = ball1.position.add((mtd.multiply(im1 / (im1 + im2))));
		if (!ball2.isFixed)
		    ball2.position = ball2.position.subtract(mtd.multiply(im2 / (im1 + im2)));

        .
        .
        .

		// change in momentum
		if (!ball1.isFixed)
		    ball1.velocity = ball1.velocity.add(impulse.multiply(im1));
		if (!ball2.isFixed)
		    ball2.velocity = ball2.velocity.subtract(impulse.multiply(im2));
	}

检测球与矩形之间的碰撞

我们使用球与矩形之间的碰撞检测来知道球是否已到达我们桌面的左、右、上、下边界。这种类型的碰撞检测非常直接;为此,每个球都有 4 个点需要检查:我们可以通过向球的中心点 x 和 y 坐标添加和减去这些点来计算这些点。然后我们比较它们,看看它们是否落在我们游戏桌定义的矩形边界之内。

解决球与矩形之间的碰撞

Resolving Ball-To-Rectangle Collisions

维基百科图片

处理球与矩形之间的碰撞比处理球与球之间的碰撞容易得多。我们必须找到矩形上离球中心最近的点。如果该点在球的半径内,则表示发生碰撞。在这种情况下,我们沿着归一化向量(`ball.center - point`)反射球的方向(即速度向量)。

播放音频

audio

没有音频的游戏是不完整的。就连Pong也有音频!不同的平台处理音频文件的方式不同。幸运的是,HTML5 为我们提供了漂亮的 `audio` 标签,这极大地简化了定义音频文件、加载、设置音量和播放的工作。

通常,HTML5 演示会以标准方式显示 `audio` 元素,即显示播放控件(播放/暂停/停止按钮、进度条和时间指示器)。Html5 台球俱乐部采取了不同的方法,隐藏了这些控件。这是有道理的,因为这里的音频不由我们的用户直接控制。相反,音频仅在发生某些游戏事件(如射击、击打或进球)时播放。

页面包含 8 个 `audio` 标签,其中 6 个用于播放击球声音(按强度变化),一个用于射击声音,另一个用于球落入口袋的声音。这些声音可以同时播放,因此我们无需处理并发。

当玩家射击球杆球时,我们以取决于所选力度的音量播放“射击”声音

	$('#topCanvas').click(function (e) {
    .
    .
    .
	audioShot.volume = strength / 100.0;
	audioShot.play();
    .
    .
    .
	});

当一个球撞击另一个球时,我们计算撞击的强度并为该音效选择 `volume`/`audio` 标签的属性

	function resolveCollision(ball1, ball2) {
        .
        .
        .
		var totalImpulse = Math.abs(impulse.x) + Math.abs(impulse.y);

		var audioHit;
		var volume = 1.0;

		if (totalImpulse > 5) {
		    audioHit = audioHit06;
		    volume = totalImpulse / 60.0;
		}
		else if (totalImpulse > 4) {
		    audioHit = audioHit05;
		    volume = totalImpulse / 12.0;
		}
		else if (totalImpulse > 3) {
		    audioHit = audioHit04;
		    volume = totalImpulse / 8.0;
		}
		else if (totalImpulse > 2) {
		    audioHit = audioHit03;
		    volume = totalImpulse / 5.0;
		}
		else {
		    audioHit = audioHit02;
		    volume = totalImpulse / 5.0;
		}

		if (audioHit != null) {
		    if (volume > 1)
		        volume = 1.0;

		    //audioHit.volume = volume;
		    audioHit.play();
		}
        .
        .
        .
	}

最后,当球落入口袋时,我们播放“fall.mp3”声音

	function pocketCheck() {
		for (var ballIndex = 0; ballIndex < balls.length; ballIndex++) {

		    var ball = balls[ballIndex];

		    for (var pocketIndex = 0; pocketIndex < pockets.length; pocketIndex++) {
                .
                . some code here...
                .                
		        if (Math.round(distSqr) < Math.round(sqrRadius)) {
		            if (ball.pocketIndex == null) {
		                ball.velocity = new Vector2D(0, 0);

		                ball.pocketIndex = pocketIndex;

		                pottedBalls[pottedBalls.length] = ball;

		                if (audioFall != null)
		                    audioFall.play();
		            }
		        }
		    }
		}
	}

使用本地存储保存游戏状态

有时称为Web 存储DOM 存储本地存储是 HTML5 引入的用于在本地持久化数据的机制的名称。本文顶部的浏览器均支持本地存储,因此无需额外的 JavaScript 框架。

在本文中,我们将使用本地存储作为在用户会话之间持久化游戏状态的手段。简单来说,我们将允许用户玩一段时间,关闭浏览器,然后在另一天打开它,恢复相同的游戏状态,再玩一会儿,依此类推。

游戏开始时,我们总是检查本地存储中是否保存了任何数据,并在需要时检索它

jQuery(document).ready(function () {
    ...
    retrieveGameState();
    ...

另一方面,我们总是在处理完每次击球的得分后保存游戏状态

    function render() {
        ...
        processFallenBalls();
        saveGameState();
        ...
    }

本地存储实现为一个 `string` 字典。这种简单的结构已准备好使用(我们无需初始化本地存储),并且可以接受我们的 `string` 和数字条目。我们只需要 `setItem` 来开始在本地存储中存储值。这就是我们存储保存的日期和时间、球的位置、玩家数据以及正在玩游戏的玩家和等待的玩家的 ID 的方式。

    function saveGameState() {
        //we use this to check whether the browser supports local storage
        if (Modernizr.localstorage) {
            localStorage["lastGameSaveDate"] = new Date();
            lastGameSaveDate = localStorage["lastGameSaveDate"];
            localStorage.setItem("balls", $.toJSON(balls));
            localStorage.setItem("teams", $.toJSON(teams));
            localStorage.setItem("playingTeamID", playingTeamID);
            localStorage.setItem("awaitingTeamID", awaitingTeamID);
        }
    }

我认为上面的代码不言自明,除了这部分

    localStorage.setItem("balls", $.toJSON(balls));
    localStorage.setItem("teams", $.toJSON(teams));

到目前为止,本地存储似乎不支持对象,因此我们必须将它们“字符串化”。上面的几行显示我们使用了有用的 `toJSON` jQuery 方法,该方法将复杂结构(如球对象数组)转换为 JSON(Javascript Simple Object Notation),一种基于 `string` 的序列化结构。每次击球后,代表 `balls` 数组的 JSON 值将如下所示:

[{"isFixed":false,"color":"#ff0000","lightColor":"#ffffff","darkColor":"#400000","bounce":0.5,
"velocity":{"x":0,"y":0},"size":10,"position":{"x":190,"y":150},"pocketIndex":null,"points":1,
"initPosition":{"x":190,"y":150},"id":0},
 {"isFixed":false,"color":"#ff0000","lightColor":"#ffffff",
"darkColor":"#400000","bounce":0.5,"velocity":{"x":0,"y":0},
"size":10,"position":{"x":172,"y":138},
"pocketIndex":null,"points":1,"initPosition":{"x":172,"y":138},"id":1},........

一旦我们将序列化结构放入本地存储中,我们就可以以类似的方式检索它。这次,我们使用 `getItem` 从存储中检索值。

现在最大的问题是我们一旦获取了反序列化后的对象。由于 `Ball` 已原型化了方法,因此从本地存储中提取的对象根本无法工作,因为它们是普通的、缺少原型化的对象。所以,我们现在将它们的数据传输到完全原型化的对象,这些对象将在以后用于游戏中。

    function retrieveGameState() {
        // We use this to check whether the browser supports local storage.
        if (Modernizr.localstorage) {

            lastGameSaveDate = localStorage["lastGameSaveDate"];
            if (lastGameSaveDate) {
                var jsonBalls = $.evalJSON(localStorage.getItem("balls"));
                balls = [];

                var ballsOnTable = 0;

                for (var i = 0; i < jsonBalls.length; i++) {
                    var jsonBall = jsonBalls[i];
                    var ball = {};
                    ball.position = new Vector2D(jsonBall.position.x, jsonBall.position.y);
                    ball.velocity = new Vector2D(0, 0);

                    ball.isFixed = jsonBall.isFixed;
                    ball.color = jsonBall.color;
                    ball.lightColor = jsonBall.lightColor;
                    ball.darkColor = jsonBall.darkColor;
                    ball.bounce = jsonBall.bounce;
                    ball.size = jsonBall.size;
                    ball.pocketIndex = jsonBall.pocketIndex;
                    ball.points = jsonBall.points;
                    ball.initPosition = jsonBall.initPosition;
                    ball.id = jsonBall.id;
                    balls[balls.length] = ball;

                    if (ball.points > 0 && ball.pocketIndex == null) {
                        ballsOnTable++;
                    }
                }

                // If there are no more balls on the table, clear local storage
                // and reload the game
                if (ballsOnTable == 0) {
                    localStorage.clear();
                    window.location.reload();
                }

                var jsonTeams = $.evalJSON(localStorage.getItem("teams"));
                teams = jsonTeams;
                if (jsonTeams[0].BallOn)
                    teams[0].BallOn = balls[jsonTeams[0].BallOn.id];
                if (jsonTeams[1].BallOn)
                    teams[1].BallOn = balls[jsonTeams[1].BallOn.id];

                playingTeamID = localStorage.getItem("playingTeamID");
                awaitingTeamID = localStorage.getItem("awaitingTeamID");
            }
        }

最终考虑

毫无疑问,HTML5 将彻底改变 Web。这场革命已经发生,我希望您将本文视为加入这场革命的邀请。在这里,我们看到了 HTML5 中的 `canvas`、CSS 3 变换、音频和本地存储是如何工作的。虽然台球游戏引擎可能看起来很复杂,但我使用的 HTML5 技术却很简单。而且我承认我没想到会有这么好的结果。

如果您喜欢、不喜欢,对文章有任何意见,请留言!我愿意根据您的经验修改文章,所以请告诉我您的想法。

历史

  • 2011 年 6 月 28 日:初始版本
  • 2011 年 6 月 30 日:目标路径和轨迹路径
  • 2011 年 7 月 2 日:实时演示
  • 2011 年 7 月 7 日:添加了本地存储功能
© . All rights reserved.