小时候,我很少去街机游戏厅,因为我真的不需要它们,因为我在家里玩过很棒的C64游戏……但是我一直有钱玩的三款街机游戏-《金刚》,《龙穴》和《 Outrun》 ......我真的很喜欢Outrun-速度,山丘,棕榈树和音乐,甚至在C64的较弱版本上也是如此。因此,我决定尝试以Outrun,Pitstop或Pole位置的风格编写老式的伪三维赛车游戏。我不打算组建一个完整而完整的游戏,但是在我看来,重新审视这些游戏实现其花招的机制将很有趣。曲线,山丘,小精灵和速度感……所以,这是我的“周末项目”,最终在周末花费了五六周可玩版本更像是技术演示,而不是真实游戏。实际上,如果您想创建一个真实的伪三维种族,那么这将是您逐步成为游戏所需要的最小基础。它没有打磨,有点丑陋,但是功能齐全。我将通过四个简单的步骤向您展示如何自行实现它。你也可以玩关于表现
这款游戏的性能非常依赖于机器/浏览器。在现代浏览器中,它运行良好,尤其是在具有画布GPU加速的浏览器中,但是不良的图形驱动程序可能导致其冻结。在游戏中,您可以更改渲染分辨率和渲染距离。关于代码结构
碰巧该项目是用Javascript实现的(由于原型设计的简单性),但它并不旨在演示Javascript的技术或推荐的技术。实际上,为了便于理解,每个示例的Javascript直接嵌入到HTML页面中(恐怖!);更糟糕的是,它使用全局变量和函数。如果我要创建一个真实的游戏,那么代码会更加结构化和简化,但是由于这是赛车游戏的技术演示,因此我决定坚持使用KISS。第1部分。直路。
那么,我们如何开始创建伪三维赛车游戏呢?好吧,我们需要- 重复三角法
- 回顾3D投影的基础知识
- 创建游戏循环
- 下载精灵图片
- 建立道路几何
- 渲染背景
- 渲染路
- 渲染车
- 实现对机器控制的键盘支持
但是在开始之前,让我们阅读Lou的Pseudo 3d Page [ 翻译 Habré]-(我可以找到)有关如何创建psevdotrohmernuyu赛车游戏的唯一信息源。阅读完娄的文章?精细!我们将使用3d投影片段技术创建他的逼真的丘陵的变形。我们将在接下来的四个部分中逐步进行此操作。但是我们将从版本v1开始,通过将其投影到HTML5 canvas元素上来创建非常简单的直线几何。演示可以在这里看到。
一点三角学
在开始实施之前,让我们使用三角学的基础知识来记住如何将3D世界中的点投影到2D屏幕上。在最简单的情况下,如果您不触摸向量和矩阵,则相似的三角形定律将用于3D投影。我们使用以下符号:- h =相机高度
- d =相机到屏幕的距离
- z =相机到汽车的距离
- y =屏幕y坐标
然后我们可以使用相似三角形的定律来计算y = h * d / z
如图所示:您还可以在顶视图中而不是侧视图中绘制相似的图,并导出相似的公式来计算屏幕的X坐标:x = w * d / z
其中w =道路宽度的一半(从摄像头到道路边缘)。如您所见,对于x和y,我们按比例缩放d / z
坐标系
以图表的形式,它看起来很漂亮,很简单,但是当您开始编码时,可能会有点困惑,因为我们选择了任意名称,并且不清楚我们指示3D世界的坐标以及2D屏幕的坐标是什么。我们还假设相机位于世界起源的中心,尽管实际上它将跟随机器。如果您采取更正式的方法,那么我们需要执行:- 从世界坐标到屏幕坐标的转换
- 将相机坐标投影到标准化的投影平面上
- 将投影坐标缩放为物理屏幕的坐标(在我们的例子中是画布)
注意:在当前的3d系统中,旋转阶段是在阶段1和2之间执行的,但是由于我们将模拟曲线,因此不需要旋转。
投影
形式投影方程可以表示为:- 转换方程(平移)点是相对于腔室计算的
- 投影方程(project)是上述“相似三角形定律”的变体。
- 缩放方程式(scale)考虑到以下两者之间的差异:
- math,其中0,0在中心,y轴在上,并且
- , 0,0 , y :
: 3d- Vector
Matrix
3d-, , WebGL ( )… . Outrun.
难题的最后一部分将是一种计算d的方法 -相机到投影平面的距离。与其仅写一个d的硬设置值,不如从所需的垂直视场中计算出来。因此,如有必要,我们将能够“缩放”摄像机。如果我们假设要投影到一个规范化的投影平面上,其坐标范围在-1到+1之间,则d可以如下计算:d = 1 /棕褐色(fov / 2)
通过将fov定义为变量中的一个,我们可以调整范围以微调渲染算法。JavaScript代码结构
在本文的开头,我已经说过该代码并不完全符合编写Javascript的准则-它是一个“快速而肮脏的”演示,带有简单的全局变量和函数。但是,由于我要创建四个单独的版本(直线,曲线,坡度和子画面),因此我将common.js
在以下模块中存储一些可重用的方法:- Dom是一些次要的DOM帮助器功能。
- UTIL -通用工具,主要是辅助数学函数。
- 游戏 -常规游戏支持功能,例如图像下载器和游戏循环。
- 渲染 -画布上的助手渲染功能。
我将common.js
仅从与游戏本身相关的方法中详细说明方法,而这些方法不仅是辅助数学或DOM函数。希望从名称和上下文中可以清楚知道方法应该做什么。像往常一样,源代码在最终文档中。
简单的游戏循环
在渲染之前,我们需要一个游戏循环。如果您阅读过我以前有关游戏的任何文章(乒乓球,突破,俄罗斯方块,蛇或巨石),那么您已经看到了我最喜欢的具有固定时间步长的游戏周期的示例。我不会深入探讨细节,只是简单地重用以前游戏中的部分代码,以使用requestAnimationFrame的固定时间步长创建游戏循环。原理是我的四个示例中的每个示例都可以调用Game.run(...)
并使用其自己的版本update
-以固定的时间步更新游戏世界。render
-当浏览器允许时更新游戏世界。
run: function(options) {
Game.loadImages(options.images, function(images) {
var update = options.update,
render = options.render,
step = options.step,
now = null,
last = Util.timestamp(),
dt = 0,
gdt = 0;
function frame() {
now = Util.timestamp();
dt = Math.min(1, (now - last) / 1000);
gdt = gdt + dt;
while (gdt > step) {
gdt = gdt - step;
update(step);
}
render();
last = now;
requestAnimationFrame(frame);
}
frame();
});
}
同样,这是我以前的画布游戏的构想的翻版,因此,如果您不了解游戏循环的工作原理,请返回上一篇文章。图片和精灵
在游戏周期开始之前,我们加载两个单独的Spritesheets(精灵表):- 背景 -天空,丘陵和树木的三个视差层
- Sprites-机器Sprites(以及要添加到最终版本中的树木和广告牌)
使用一个小的任务Rake和Ruby Gem sprite-factory生成了精灵表。此任务将生成组合的Sprite表以及坐标x,y,w,h,这些坐标将存储在常量BACKGROUND
和中SPRITES
。注意:我使用Inkscape创建了背景,大多数精灵是从旧的Outrun版本获取的Genesis 图形,并用作训练示例。
游戏变量
除了背景和精灵的图像外,我们还需要几个游戏变量,即:var fps = 60;
var step = 1/fps;
var width = 1024;
var height = 768;
var segments = [];
var canvas = Dom.get('canvas');
var ctx = canvas.getContext('2d');
var background = null;
var sprites = null;
var resolution = null;
var roadWidth = 2000;
var segmentLength = 200;
var rumbleLength = 3;
var trackLength = null;
var lanes = 3;
var fieldOfView = 100;
var cameraHeight = 1000;
var cameraDepth = null;
var drawDistance = 300;
var playerX = 0;
var playerZ = null;
var fogDensity = 5;
var position = 0;
var speed = 0;
var maxSpeed = segmentLength/step;
var accel = maxSpeed/5;
var breaking = -maxSpeed;
var decel = -maxSpeed/5;
var offRoadDecel = -maxSpeed/2;
var offRoadLimit = maxSpeed/4;
其中一些可以使用UI控件自定义,以在程序执行期间更改关键值,以便您可以看到它们如何影响道路的渲染。其他方法是根据该方法从自定义UI值重新计算的reset()
。我们管理法拉利
我们为进行键绑定Game.run
,它提供了简单的键盘输入,可以设置或重置报告玩家当前操作的变量:Game.run({
...
keys: [
{ keys: [KEY.LEFT, KEY.A], mode: 'down', action: function() { keyLeft = true; } },
{ keys: [KEY.RIGHT, KEY.D], mode: 'down', action: function() { keyRight = true; } },
{ keys: [KEY.UP, KEY.W], mode: 'down', action: function() { keyFaster = true; } },
{ keys: [KEY.DOWN, KEY.S], mode: 'down', action: function() { keySlower = true; } },
{ keys: [KEY.LEFT, KEY.A], mode: 'up', action: function() { keyLeft = false; } },
{ keys: [KEY.RIGHT, KEY.D], mode: 'up', action: function() { keyRight = false; } },
{ keys: [KEY.UP, KEY.W], mode: 'up', action: function() { keyFaster = false; } },
{ keys: [KEY.DOWN, KEY.S], mode: 'up', action: function() { keySlower = false; } }
],
...
}
玩家的状态由以下变量控制:- 速度 -当前速度。
- position-轨道上的当前Z位置。请注意,这是摄影机位置,而不是法拉利。
- playerX-玩家在道路X上的当前位置。标准化为-1到+1,以不依赖于实际值
roadWidth
。
这些变量在method内部设置,该方法update
执行以下操作:position
根据当前更新speed
。playerX
在您按向左或向右键时更新。speed
如果按向上键增加。speed
如果按下向下键,则减小。speed
如果未按下向上和向下键,则减小。- 降低
speed
如果playerX
关闭位于马路边上,并在草地上。
在直路的情况下,该方法update
非常简单明了:function update(dt) {
position = Util.increase(position, dt * speed, trackLength);
var dx = dt * 2 * (speed/maxSpeed);
if (keyLeft)
playerX = playerX - dx;
else if (keyRight)
playerX = playerX + dx;
if (keyFaster)
speed = Util.accelerate(speed, accel, dt);
else if (keySlower)
speed = Util.accelerate(speed, breaking, dt);
else
speed = Util.accelerate(speed, decel, dt);
if (((playerX < -1) || (playerX > 1)) && (speed > offRoadLimit))
speed = Util.accelerate(speed, offRoadDecel, dt);
playerX = Util.limit(playerX, -2, 2);
speed = Util.limit(speed, 0, maxSpeed);
}
不用担心,当在最终版本中添加精灵和碰撞识别时,它将变得更加困难。道路几何
在渲染游戏世界之前,我们需要segments
在方法中构建in 的数组resetRoad()
。最终,这些路段中的每个路段都将从其世界坐标投影出来,以使其在屏幕坐标中变为2d多边形。对于每个线段,我们存储两个点,p1是最靠近摄影机的边缘的中心,p2是最远离摄影机的边缘的中心。严格来说,每个段的p2与上一个段的p1相同,但是在我看来,将它们存储为单独的点并分别转换每个段会更容易。我们保持分开rumbleLength
是因为我们可以拥有美丽的详细曲线和丘陵,但同时具有水平条纹。如果随后的每个片段具有不同的颜色,则将产生不良的频闪效果。因此,我们希望有许多小段,但将它们分组在一起以形成单独的水平条纹。function resetRoad() {
segments = [];
for(var n = 0 ; n < 500 ; n++) {
segments.push({
index: n,
p1: { world: { z: n *segmentLength }, camera: {}, screen: {} },
p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
});
}
trackLength = segments.length * segmentLength;
}
我们仅使用世界坐标z初始化p1和p2,因为我们只需要直行道路。y坐标将始终为0,x坐标将始终取决于比例值。稍后,当我们添加曲线和山丘时,这部分将发生变化。
我们还将设置空对象,以将这些点的表示形式存储在相机和屏幕上,以免在每个对象中创建一堆临时对象。为了最大程度地减少垃圾回收,我们必须避免在游戏循环内分配对象。+/- roadWidth
render
当汽车到达路的尽头时,我们只需回到循环的起点即可。为了简化此过程,我们将创建一种方法来找到任意Z值的线段,即使它超出了道路的长度:function findSegment(z) {
return segments[Math.floor(z/segmentLength) % segments.length];
}
背景渲染
该方法render()
开始于渲染背景图像。在下面的部分中,我们将添加曲线和山丘,我们将需要背景来执行视差滚动,因此我们现在将开始朝这个方向移动,将背景渲染为三个单独的层:function render() {
ctx.clearRect(0, 0, width, height);
Render.background(ctx, background, width, height, BACKGROUND.SKY);
Render.background(ctx, background, width, height, BACKGROUND.HILLS);
Render.background(ctx, background, width, height, BACKGROUND.TREES);
...
道路渲染
然后,render函数遍历所有片段,并将每个片段的p1和p2从世界坐标投影到屏幕坐标,并在必要时修剪该片段,否则进行渲染: var baseSegment = findSegment(position);
var maxy = height;
var n, segment;
for(n = 0 ; n < drawDistance ; n++) {
segment = segments[(baseSegment.index + n) % segments.length];
Util.project(segment.p1, (playerX * roadWidth), cameraHeight, position, cameraDepth, width, height, roadWidth);
Util.project(segment.p2, (playerX * roadWidth), cameraHeight, position, cameraDepth, width, height, roadWidth);
if ((segment.p1.camera.z <= cameraDepth) ||
(segment.p2.screen.y >= maxy))
continue;
Render.segment(ctx, width, lanes,
segment.p1.screen.x,
segment.p1.screen.y,
segment.p1.screen.w,
segment.p2.screen.x,
segment.p2.screen.y,
segment.p2.screen.w,
segment.color);
maxy = segment.p2.screen.y;
}
上面,我们已经看到了投影点所需的计算; javascript版本将转换,投影和缩放合并为一种方法:project: function(p, cameraX, cameraY, cameraZ, cameraDepth, width, height, roadWidth) {
p.camera.x = (p.world.x || 0) - cameraX;
p.camera.y = (p.world.y || 0) - cameraY;
p.camera.z = (p.world.z || 0) - cameraZ;
p.screen.scale = cameraDepth/p.camera.z;
p.screen.x = Math.round((width/2) + (p.screen.scale * p.camera.x * width/2));
p.screen.y = Math.round((height/2) - (p.screen.scale * p.camera.y * height/2));
p.screen.w = Math.round( (p.screen.scale * roadWidth * width/2));
}
除了为每个点p1和p2计算屏幕x和y之外,我们使用相同的投影计算来计算段的投影宽度(w)。
有了点p1和p2的屏幕x和y坐标,以及道路w的投影宽度,我们可以使用辅助功能很容易地借助辅助功能来计算渲染草地,道路,水平条纹和分界线所需的所有多边形(请参见。)。Render.segment
Render.polygon
common.js
汽车渲染
最后,该方法需要的最后一件事render
是Ferrari渲染: Render.player(ctx, width, height, resolution, roadWidth, sprites, speed/maxSpeed,
cameraDepth/playerZ,
width/2,
height);
此方法称为player
,而不是car
,因为在游戏的最终版本中,路上还会有其他赛车,并且我们希望将Ferrari玩家与其他赛车分开。helper函数Render.player
使用被称为canvas的方法drawImage
来渲染精灵,之前已使用与之前相同的投影比例对其进行了缩放:d / z
这里的z是从机器到摄像机的相对距离,存储在变量playerZ中。此外,该函数会高速晃动汽车,从而使比例方程式增加一些随机性,具体取决于速度/ maxSpeed。这是我们得到的:结论
我们仅创建了一条直线道路的系统就做了很多工作。我们加了- 通用辅助模块dom
- UTIL一般数学模块
- 渲染通用画布助手模块...
- ......包括
Render.segment
,Render.polygon
和Render.sprite
- 定距游戏周期
- 图片下载器
- 键盘处理器
- 视差背景
- 雪碧板与汽车,树木和广告牌
- 道路的基本几何形状
update()
机器的控制方法- 方法
render()
渲染背景,道路和用车 <audio>
带有赛车音乐的HTML5标签(隐藏奖金!)
……为我们的进一步发展奠定了良好的基础。第2部分。曲线。
在这一部分中,我们将更详细地解释曲线的工作方式。在上一部分中,我们以线段数组的形式编译了道路的几何形状,每个线段都具有相对于摄影机进行转换然后投影到屏幕上的世界坐标。我们只需要每个点的世界坐标z,因为在直行道路上x和y都等于零。如果要创建功能齐全的3d系统,则可以通过计算上面显示的多边形的x和z条纹来实现曲线。但是,这种类型的几何形状将很难计算,因此有必要在投影方程式中添加3d旋转平台...如果采用这种方式,最好使用WebGL或其类似物,但是该项目没有其他任务要完成。我们只想使用老式的伪三维技巧来模拟曲线。因此,您可能会惊讶地发现,我们根本不会计算路段的x坐标...而是使用Lu的建议:“要弯曲道路,只需更改曲线形状的中心线的位置...从屏幕的底部开始,道路中心向左或向右的偏移量会逐渐增加。”
在我们的例子中,中心线是cameraX
传递给投影计算的值。这意味着,当我们执行render()
道路的每个路段时,可以通过将值cameraX
逐渐增加来移动来模拟曲线。要知道要移动多少,我们需要在每个段中存储一个值curve
。该值指示应将片段从相机的中心线偏移多少。她将会是:- 左转曲线为负
- 曲线右转时为正
- 更少的平滑曲线
- 更多用于锐利曲线
自身的价值是任意选择的; 通过反复试验,我们可以找到曲线似乎是``正确的''的好值:var ROAD = {
LENGTH: { NONE: 0, SHORT: 25, MEDIUM: 50, LONG: 100 },
CURVE: { NONE: 0, EASY: 2, MEDIUM: 4, HARD: 6 }
};
除了为曲线选择合适的值外,我们还需要避免在直线变成曲线时(或反之亦然)过渡中的任何间隙。这可以通过在进入和退出曲线时软化来实现。我们将通过curve
使用传统的平滑函数逐渐增加(或减小)每个分段的值,直到达到所需值,来做到这一点:easeIn: function(a,b,percent) { return a + (b-a)*Math.pow(percent,2); },
easeOut: function(a,b,percent) { return a + (b-a)*(1-Math.pow(1-percent,2)); },
easeInOut: function(a,b,percent) { return a + (b-a)*((-Math.cos(percent*Math.PI)/2) + 0.5); },
也就是说,现在考虑到向几何图形添加一个线段的功能...function addSegment(curve) {
var n = segments.length;
segments.push({
index: n,
p1: { world: { z: n *segmentLength }, camera: {}, screen: {} },
p2: { world: { z: (n+1)*segmentLength }, camera: {}, screen: {} },
curve: curve,
color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
});
}
我们可以创建一种用于平滑进入,查找和平滑离开弯道的方法:function addRoad(enter, hold, leave, curve) {
var n;
for(n = 0 ; n < enter ; n++)
addSegment(Util.easeIn(0, curve, n/enter));
for(n = 0 ; n < hold ; n++)
addSegment(curve);
for(n = 0 ; n < leave ; n++)
addSegment(Util.easeInOut(curve, 0, n/leave));
}
...,然后您可以强加其他几何形状,例如S形曲线:function addSCurves() {
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, -ROAD.CURVE.EASY);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.CURVE.MEDIUM);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.CURVE.EASY);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, -ROAD.CURVE.EASY);
addRoad(ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, ROAD.LENGTH.MEDIUM, -ROAD.CURVE.MEDIUM);
}
对update()方法的更改
该方法唯一需要做的改变就是update()
当机器沿曲线移动时施加某种离心力。我们设置了一个任意因子,可以根据自己的喜好进行调整。var centrifugal = 0.3;
然后,我们将playerX
根据其当前速度,曲线值和离心力乘数来更新位置:playerX = playerX - (dx * speedPercent * playerSegment.curve * centrifugal);
曲线渲染
上面我们说过,您可以通过cameraX
在render()
每个路段执行期间移动投影计算中使用的值来渲染模拟曲线。为此,我们将存储驱动变量dx(每个分段增加一个值curve
)以及变量x(将cameraX
用作投影计算中使用的值的偏移量)。要实现曲线,我们需要以下内容:- 将每个线段的投影p1移x
- 将每个线段的投影p2移x + dx
- 增加X由下一个段DX
最后,为了避免在跨越线段边界时出现过渡撕裂,我们必须使用当前基本线段曲线的插值对dx进行初始化。更改方法render()
,如下所示:var baseSegment = findSegment(position);
var basePercent = Util.percentRemaining(position, segmentLength);
var dx = - (baseSegment.curve * basePercent);
var x = 0;
for(n = 0 ; n < drawDistance ; n++) {
...
Util.project(segment.p1, (playerX * roadWidth) - x, cameraHeight, position - (segment.looped ? trackLength : 0), cameraDepth, width, height, roadWidth);
Util.project(segment.p2, (playerX * roadWidth) - x - dx, cameraHeight, position - (segment.looped ? trackLength : 0), cameraDepth, width, height, roadWidth);
x = x + dx;
dx = dx + segment.curve;
...
}
视差滚动背景
最后,我们需要滚动视差背景图层,存储每个图层的偏移量...var skySpeed = 0.001;
var hillSpeed = 0.002;
var treeSpeed = 0.003;
var skyOffset = 0;
var hillOffset = 0;
var treeOffset = 0;
...并update()
根据当前播放器片段的曲线值及其速度在一段时间内增加它...skyOffset = Util.increase(skyOffset, skySpeed * playerSegment.curve * speedPercent, 1);
hillOffset = Util.increase(hillOffset, hillSpeed * playerSegment.curve * speedPercent, 1);
treeOffset = Util.increase(treeOffset, treeSpeed * playerSegment.curve * speedPercent, 1);
...,然后在做render()
背景图层时使用此偏移量。Render.background(ctx, background, width, height, BACKGROUND.SKY, skyOffset);
Render.background(ctx, background, width, height, BACKGROUND.HILLS, hillOffset);
Render.background(ctx, background, width, height, BACKGROUND.TREES, treeOffset);
结论
因此,在这里我们得到了伪伪三维曲线:我们添加的代码的主要部分是使用相应的值构建道路的几何形状curve
。意识到这一点,在这段时间内增加离心力update()
要容易得多。曲线渲染仅需几行代码即可完成,但可能很难理解(并描述)这里到底发生了什么。有许多种模拟曲线的方法,当将它们实施到死胡同时,很容易徘徊。摆脱外部任务并尝试“正确地”做所有事情甚至更容易。在意识到这一点之前,您将开始创建一个具有矩阵,旋转和真实3d几何形状的功能齐全的3d系统……正如我所说,这不是我们的任务。当我写这篇文章时,我确信在曲线的实现中肯定存在问题。为了使算法可视化,我不明白为什么我需要dx和x驱动器的两个值而不是一个...如果我不能完全解释某些内容,那么某处出现了问题...但是项目时间在“周末”快要到期了,坦白说,曲线对我来说似乎很漂亮,最后,这是最重要的。