使用 JavaScript 进行游戏的视觉隐藏






4.94/5 (9投票s)
使用 HTML5 和 JavaScript 隐藏游戏中的视野之外的区域
引言
在本文中,我将讨论如何进行二维俯视游戏中的视觉遮挡计算。视觉遮挡是指隐藏视野之外或被障碍物遮挡的事物。
这里有一个 JsFiddle 可供您随意尝试实现。
背景
在玩了一个 非常酷的游戏 后,我意识到我想为一款 Android 游戏做一些类似的事情。在观看了 Jonathan Blow 关于原型设计的演讲(顺便说一句,这是一个非常好的演讲)之后,我决定先制作一个简单的原型,并且我认为在 Android 平台上进行原型开发效率不高,因为那样的话我将不得不等待模拟器或调试器在物理设备上启动。我选择了 JavaScript 方法,虽然结果与我期望的最终 Android 游戏截然不同,但它证明了一种非常方便的方式来确保我能够实现所有必需的机制。
Using the Code
本文有两个下载项;visualconcealment.zip 包含用于测试此内容的基本代码,prototype.zip 包含一个(算是)可玩的 alpha 版本游戏。
代码全部是 HTML 和 JavaScript,组织在两个 Eclipse 项目中,一个用于展示基本数学,另一个是一个非常简单的游戏。无需安装 Eclipse 即可运行代码,只需在支持 HTML5 Canvas 的浏览器中打开 example.html 或 game.html 即可。
我尝试采用面向对象的方法,并且还尝试按照 C# 或 Java 项目的组织方式来组织我的类和文件。这意味着项目包含很多 .js 文件,比我看到的大多数同等规模的 Web 项目都要多。虽然这样会导致解决方案不太适合重新分发(例如作为库),但我认为这种结构确实有帮助。
除了本文将涵盖的部分之外,还有很多与碰撞检测和(某种程度上)基于物理的移动相关的内容。这些可能会使代码阅读起来有些混乱,但我保留了它们,因为这样可以更轻松地进行尝试并查看遮挡逻辑的效果。
为使用 QUnitJs 框架的数学密集型类包含有限数量的单元测试。
必备组件
原型将是一个二维俯视视角游戏(例如 吃豆人),障碍物将由两个点定义的线表示。
遮挡逻辑必须能够隐藏视野之外(基本上是玩家身后的区域)以及被障碍物遮挡的区域。玩家看不见的区域必须渲染成纯色,遮挡任何墙壁或其他实体(例如计算机控制的敌人),但如果实体没有完全被遮挡,则必须显示部分视图。
为了实现这一点,我采取的实现方法与上述 游戏中 的方法截然不同,将使用一个填充的多边形渲染在任何被发现遮挡的区域之上。这意味着动态光照(例如,视野区域离观察者越远越暗)更难实现,但我认为对于我目标的游戏来说,这效果应该很好。
方法
在计算多边形时,本质上存在两种不同的情况:
- 视野之外
- 障碍物后面
虽然两者非常相似,但它们之间存在一些差异,所以我将分别介绍它们。本节内容涉及大量数学知识,如果您有一些向量数学经验会很有帮助,因为我不会详细介绍如何添加、减去或缩放向量,例如,基本上所有这些逻辑都归结为一些向量运算。
我将尝试使用图示而不是方程来解释所使用的数学,而将实际公式留在代码中。我想描述的更多是如何找到相关的点和区域,而不是如何计算它们(如果这说得通的话)。
视野之外
给定一个在“房间”中的观察者(蓝色三角形),除了房间墙壁外没有其他障碍物,并且给定观察方向(绿色箭头),视野之外的区域是什么?
灰色区域即为被遮挡的区域。
要找到该多边形的顶点,将执行以下步骤:
- 沿视野的左边界延伸一条线,看看它与哪堵墙相交以及在何处相交,称该点为 A。
- 沿视野的右边界延伸一条线,看看它与哪堵墙相交以及在何处相交,称该点为 B。
- 从玩家向外墙上的每个“角落”延伸一条线,并选择落在视野之外的线,称这些点为 P1-Pn。
在下图中,红色箭头找到点 A 和 B,而黄色箭头找到点 P1 到 P3。虚线黄色线显示了第 3 步的结果,但线落在视野内,因此被忽略。
交点
为了找到两条线之间的交点,我依赖于两个类:Vector2D
和 Segment
。
Vector2D
类(显而易见)是二维向量的表示,包含 x
和 y
值。它提供了标准的向量运算,例如 add
和 length
。
Segment
类表示一条线或一个“段”,即从点 A 到点 B 的距离。它由两个 Vector2D
对象构建。Segment
类有一个查找交点的方法,恰当地命名为 findIntersection
,该方法实现了标准的线-线交点方程(可以在 这里 找到及其非常好的解释)。
Segment.findIntersection = function(a, b) {
var x1 = a.a.x;
var y1 = a.a.y;
var x2 = a.b.x;
var y2 = a.b.y;
var x3 = b.a.x;
var y3 = b.a.y;
var x4 = b.b.x;
var y4 = b.b.y;
var denominator = (x1 - x2)*(y3 - y4) - (y1 - y2)*(x3 - x4);
if (denominator == 0)
return new SegmentIntersection(a, b, false, null, false, false);
var xNominator = (x1*y2 - y1*x2)*(x3 - x4) - (x1 - x2)*(x3*y4 - y3*x4);
var yNominator = (x1*y2 - y1*x2)*(y3 - y4) - (y1 - y2)*(x3*y4 - y3*x4);
var px = xNominator / denominator;
var py = yNominator / denominator;
var point = new Vector2D(px, py);
return new SegmentIntersection(a, b, true, point, a.contains(point), b.contains(point));
};
findIntersection
方法返回 SegmentIntersection
对象而不是仅仅返回交点的原因是线-线交点可能会很复杂。
复杂的意思是,虽然方程会给出交点(如果直线平行则不返回任何内容),但计算出的交点可能不在直线的边界内,而是在该线的延长线上某处。SegmentIntersection
类用于记录此信息,因此除了实际的交点外,它还提供有关它是否与线 a
或线 b
相交,甚至与两者都相交的信息。
能够检测线-线交点非常方便,因为它使计算诸如敌人是否看到玩家之类的操作变得容易。这是通过创建一条从敌人位置到玩家位置的 Segment
,然后迭代所有障碍物来查看是否有任何交点来实现的;如果有交点;则玩家被遮挡。
作为旁注,我认为值得一提的是,聪明人会直接使用现有的数学库(有很多),但由于我喜欢这些东西,所以我全部手动实现了。
构建多边形
确定了多边形的所有顶点后,为了让多边形正确渲染,我们还需要做一件事。由于多边形的顶点必须“按顺序”出现才能将多边形渲染为单个表面,因此对找到的点进行排序非常重要。可以是顺时针或逆时针,哪种方式都可以。如果顶点没有按此方式排序,多边形可能会渲染不正确。
在此图中,这些点(按编号顺序)未按顺时针或逆时针方向排序,因此多边形与自身相交,导致覆盖区域不正确。
对顶点进行排序就是根据它们与玩家看向的方向(由绿色箭头指示)的夹角来排序。由于 JavaScript 数组支持方便的 sort
方法,因此可以这样完成:
var direction = Vector2D.normalize(entity.lookDirection);
var origin = entity.position;
polygonPoints.sort(function(lhs, rhs) {
if (lhs.equals(origin))
return 1;
if (rhs.equals(origin))
return -1;
if (lhs.equals(rPoint))
return -1;
if (rhs.equals(rPoint))
return 1;
if (lhs.equals(lPoint))
return 1;
if (rhs.equals(lPoint))
return -1;
var vl = Vector2D.normalize(Vector2D.sub(lhs, origin));
var vr = Vector2D.normalize(Vector2D.sub(rhs, origin));
var al = Vector2D.wrappedAngleBetween(vl, direction);
var ar = Vector2D.wrappedAngleBetween(vr, direction);
if (al < ar)
return 1;
else
return -1;
});
其中 wrappedAngleBetween
返回一个有符号角度,该角度被包装为所有正角度。
Vector2D.signedAngleBetween = function(lhs, rhs) {
var na = Vector2D.normalize(lhs);
var nb = Vector2D.normalize(rhs);
return Math.atan2(nb.y, nb.x) - Math.atan2(na.y, na.x);
};
Vector2D.wrappedAngleBetween = function(lhs, rhs) {
var angle = Vector2D.signedAngleBetween(lhs, rhs);
if (angle < 0)
angle += TWO_PI;
return angle;
};
墙壁和外边界属于 Level
类,因此该类最适合对每个实体(实体可以是玩家、怪物或其他任何东西)执行此计算。
Level.prototype.calculateOutOfViewArea = function(entity) {
var polygonPoints = [];
polygonPoints.push(entity.position);
var rDir = Vector2D.rotate(entity.lookDirection, entity.fieldOfView / 2);
var lDir = Vector2D.rotate(entity.lookDirection, -entity.fieldOfView / 2);
var rSegment = new Segment(entity.position, Vector2D.add(entity.position, rDir));
var lSegment = new Segment(entity.position, Vector2D.add(entity.position, lDir));
var lPoint = null;
var rPoint = null;
for(var i = 0; i < bounds.length; ++i) {
var bound = bounds[i];
var rBoundIntersection = Segment.findIntersection(bound, rSegment);
if (rBoundIntersection.isIntersection && rBoundIntersection.pointIsOnA &&
!fltEquals(0, Vector2D.angleBetween(rDir, Vector2D.sub(entity.position,
rBoundIntersection.point)))) {
rPoint = rBoundIntersection.point;
polygonPoints.push(rBoundIntersection.point);
}
var lBoundIntersection = Segment.findIntersection(bound, lSegment);
if (lBoundIntersection.isIntersection && lBoundIntersection.pointIsOnA &&
!fltEquals(0, Vector2D.angleBetween(lDir, Vector2D.sub(entity.position,
lBoundIntersection.point)))) {
lPoint = lBoundIntersection.point;
polygonPoints.push(lBoundIntersection.point);
}
var toCorner = Vector2D.sub(bound.a, entity.position);
var angle = Vector2D.angleBetween(toCorner, entity.lookDirection);
if (Math.abs(angle) > entity.fieldOfView / 2) {
polygonPoints.push(bound.a);
}
}
var direction = Vector2D.normalize(entity.lookDirection);
var origin = entity.position;
polygonPoints.sort(function(lhs, rhs) {
if (lhs.equals(origin))
return 1;
if (rhs.equals(origin))
return -1;
if (lhs.equals(rPoint))
return -1;
if (rhs.equals(rPoint))
return 1;
if (lhs.equals(lPoint))
return 1;
if (rhs.equals(lPoint))
return -1;
var vl = Vector2D.normalize(Vector2D.sub(lhs, origin));
var vr = Vector2D.normalize(Vector2D.sub(rhs, origin));
var al = Vector2D.wrappedAngleBetween(vl, direction);
var ar = Vector2D.wrappedAngleBetween(vr, direction);
if (al < ar)
return 1;
else
return -1;
});
return polygonPoints;
};
在上面的代码片段中,变量 rDir
和 lDir
表示视野的外部边界。这些是通过取观察方向,然后将该向量旋转 视野角度的一半 弧度来计算的,并且必须进行两次旋转。一次负向旋转以找到左边界,一次正向旋转以找到右边界。
var rDir = Vector2D.rotate(entity.lookDirection, entity.fieldOfView / 2);
var lDir = Vector2D.rotate(entity.lookDirection, -entity.fieldOfView / 2);
这些向量随后形成了上面图示中的两个红色向量。
使用上述方法计算顶点并对其进行排序,会得到一个如下所示的多边形:
障碍物后面
计算视野中障碍物后面的遮挡区域,基本上就是计算障碍物投下的阴影,如果观察者是光源的话。这与计算覆盖视野之外的区域的多边形非常相似,但不同之处在于它从障碍物开始,而不是从观察者的视野开始。
- 从观察者通过障碍物上的点 a(
Segment
)延伸一条线,看看它与哪个边界墙相交,称该点为 A。 - 从观察者通过障碍物上的点 b(
Segment
)延伸一条线,看看它与哪个边界墙相交,称该点为 B。 - 从玩家向外墙上的每个“角落”延伸一条线,并选择落在障碍物 a 和 b 之间的线,称这些点为 P1-Pn。
在下图中,红色箭头找到点 A 和 B,而黄色箭头找到角落,请注意,在此示例中,只有一个黄色箭头符合落在 A 和 B 之间的条件。
需要绘制的阴影多边形是那些由障碍物上的点以及红色箭头与外墙相交的点和黄色(非虚线)箭头与外边界角落相交的点组成的。
代码方面,这与查找视野之外区域的代码非常相似,主要区别在于它是基于障碍物的,并且每个障碍物都会调用一次。同样,也需要对顶点进行排序,因为多边形必须按顺序渲染。
Level.prototype.calculateShadowArea = function(entity, wall) {
var polygonPoints = [];
var endpoints = wall.getEndPoints();
for(var i = 0; i < endpoints.length; ++i) {
var wallPoint = endpoints[i];
var observerToWallEndPoint = new Segment(entity.position, wallPoint);
polygonPoints.push(wallPoint);
for(var j = 0; j < this.bounds.length; ++j) {
var bound = this.bounds[j];
var wallIntersection = Segment.findIntersection(observerToWallEndPoint, bound);
if (wallIntersection.isIntersection && wallIntersection.pointIsOnB &&
fltEquals(0, Vector2D.angleBetween(observerToWallEndPoint.getAtoB(),
Vector2D.sub(wallIntersection.point, entity.position)))) {
polygonPoints.push(wallIntersection.point);
}
}
}
for(var i = 0; i < this.bounds.length; ++i) {
var bound = this.bounds[i];
var observerToBoundryCornerA = new Segment(entity.position, bound.a);
var cornerIntersection = Segment.findIntersection(wall, observerToBoundryCornerA);
if (cornerIntersection.isIntersection && cornerIntersection.pointIsOnA &&
!fltEquals(0, Vector2D.angleBetween(observerToBoundryCornerA.getAtoB(),
Vector2D.sub(entity.position, cornerIntersection.point)))) {
polygonPoints.push(observerToBoundryCornerA.b);
}
}
var source = new Segment(entity.position, wall.a);
polygonPoints.sort(function(lhs, rhs) {
if (lhs.equals(wall.a))
return 1;
if (rhs.equals(wall.a))
return -1;
if (lhs.equals(wall.b))
return -1;
if (rhs.equals(wall.b))
return 1;
var p2lhs = Vector2D.sub(lhs, source.a);
var p2rhs = Vector2D.sub(rhs, source.a);
var lhsAngle = Vector2D.angleBetween(p2lhs, source.getAtoB());
var rhsAngle = Vector2D.angleBetween(p2rhs, source.getAtoB());
if (lhsAngle < rhsAngle) {
return 1;
}
else {
if (lhsAngle > rhsAngle) {
return -1;
}
}
return 0;
});
return polygonPoints;
为了方便起见,Segment
类通过 getEndPoints
方法将其两个端点(Vector2D
对象 a
和 b
)公开为一个数组,这可以简化处理所有端点的代码,因为它们可以表示为循环。
Segment
类还有一个名为 getAtoB
的方法,它返回一个向量 v
,使得 v = segment.b - segment.a,这自然代表了从 a
到 b
的线。或者用代码表示:
Segment.prototype.getAtoB = function() {
return Vector2D.sub(this.b, this.a);
};
同样,这主要是为了使使用 Segment
的代码更整洁,而适当的优化可能是缓存此值。
摘要
细心的读者可能会指出,这种方法效率不高,如果一个障碍物完全被另一个障碍物挡住,它仍然会计算并渲染其阴影!这是一个正确的观察,我故意省略了这个优化,因为我将为我的 Android 版本重新做所有这些工作。解决方案是检查一个障碍物的两个端点是否都在一个已计算的阴影区域内。
渲染
由于地图的非可见区域被计算为要填充纯色的多边形,因此应按以下顺序渲染关卡:
- 关卡背景
- 敌人和其他实体
- 视野排除和障碍物阴影区域
- 墙壁/障碍物
- 玩家
这种渲染顺序允许敌人在从被遮挡的区域出来时部分可见。
下图展示了一个示例渲染,分别是无遮挡、障碍物遮挡以及障碍物和视野(红线为障碍物)都遮挡的情况;
关注点
我使用 JavaScript 进行原型开发,但在 JavaScript 中编写简单游戏的体验非常愉快,我可能会考虑直接将这个小原型变成一个真正的游戏。
我给我使用的测试数学密集型部分的单元测试框架 QUnitJs 留下了深刻的印象。我还没有真正使用过其他 JavaScript 测试框架,所以没有什么可供比较的,但它运行得非常好,并且使用起来也很容易创建测试用例。
如果您还没有对代码进行单元测试,您应该在 qunitjs.com 上查看它。
prototype.zip 压缩包中的游戏原型远未完成,可以进行尝试,但无法获胜。
使用 WASD 控制蓝色小人,躲避红色小人,吃绿色的闪光食物,并在粉红色区域放下。用 空格键 射击红色小人,但这会消耗你的能量。
历史
- 2013-02-13:初始版本