您当前所在位置: > 爆料站 > 游戏小抄

如何用2KB代码实现3D赛车游戏?2kPlusJam大赛了解一下_https

时间:2020-04-30 14:05:14  来源:  作者:网络

原标题:如何用2 KB代码实现3D赛车游戏?2kPlus Jam大赛了解一下

选自frankforce

作者:Frank

机器之心编译

参与:王子嘉、Geek AI

控制复杂度一直是软件开发的核心问题之一,一代代的计算机从业者纷纷贡献着自己的智慧,试图降低程序的计算复杂度。然而,将一款 3D 赛车游戏的代码压缩到 2KB 以内,听起来是不是太夸张了?本文作者 Frank 是一名资深游戏开发者,在本文中,他详细介绍了如何灵活运用代码压缩、编译、随机数生成、代码复用、设计模式等十八般武艺仅仅通过 2KB 的代码就能实现一款强大的 3D 赛车游戏。

几个月前,当我听说传奇 JS1K 游戏编程竞赛将不再举办时,当即把这件事告诉了其他开发者,最后我们决定在 itch 上搞一个 2KB 版的编程竞赛以弥补这一遗憾,我们将其称之为「2kPlus Jam」。这个竞赛的主要目标是制作一个只需要 2KB 压缩文件就可以容纳的游戏。如果你知道一个 3.5 英寸软盘可以存超过 700 个这样的游戏,你也就知道这有多小了。

我的作品(Hue Jumper)是对 80 年代赛车游戏渲染技术的致敬。这里的 3D 图像和物理引擎是我纯粹地使用 Java 从零开始实现的,同时我还花了大量时间去调整游戏玩法和视觉效果。

游戏编程竞赛强调「变化」(shift),所以每当玩家通过关卡时,我就会改变整个世界的色调。我想在玩家通过关卡时,会感觉到像进入了一个色调不同的新的维度,这就是我为它取名为「Hue Jumper」的原因。

本文包含了这个游戏的完整 Java 代码,所以可能会有点长。不过代码的注释很友好,所以我不打算一行一行解读,也不要求你现在就通读所有代码。我的目的是向你解释它的工作原理,还有为什么我要这样做,以及这个项目的整个结构。你也可以在 CodePen上找到这份代码 (https://codepen.io/KilledByAPixel/pen/poJdLwB),并进行现场调试。

那么,系好安全带,坐稳,我们要开始啦!

灵感来源

我的灵感主要来源于 80 年代的经典赛车游戏,比如《Out Run》。使用相似的技术,他们能够在非常早期的硬件上实现实时三维图形。我最近也在玩一些现代的赛车游戏,比如《Distance》和《Lonely Mountains: Downhill》,这些游戏也对我的视觉设计和游戏体验有所帮助。

Jake Gordon 用 Java 写的一个伪 3D 赛车的项目(https://github.com/jakesgordon/java-racer/)帮我减轻了很多负担。他还为此写了一系列解释其工作原理的博文。尽管我的项目是从零开始的,但是他的代码助我解决了遇到的包括数学在内的一些问题。

我还看了 Chris Glover 开发的一款名为「Moto1kross」的 JS1k 游戏(https://js1k.com/2019-x/demo/4006)。这款简单的 1KB 赛车游戏给了我一个参考,让我知道什么是可能实现的。现在我有额外的 1KB 可用空间,因此我得远远超过它。

总体策略

由于游戏大小有严格的限制,程序的架构就显得尤为重要。我的总体策略是让一切尽可能的简单,以实现创造一款视觉感受和游戏体验都很棒的游戏的最终目标。

为了压缩代码,我用 Google Closure Compiler (https://closure-compiler.appspot.com/home) 来运行它。这个编译器会删掉所有空白,把变量重命名为 1 个字母的字符,并且做了一些简单的优化。你可以通过下面的链接使用这个编译器:https://clocompiler.appspot.com/home。

不过,这个编译器还做了一些无用的事,比如替换模板字符串、缺省参数和其它有助于节省空间的 ES6 特性。所以我需要手动撤销某些无用的工作,并预先准备一些更「冒险」的压缩技术,以节省每一个字节。但这并不是最主要的成功之处,大部分文件体积的压缩还是归功于代码本身的架构。

代码需要被压缩到 2KB 以内。如果你不想选用上一种方案,还有一个类似的、但功能较弱的工具——RegPack,它可以在严格遵守规定的情况下编译 Java。无论哪种方式,策略都是一样的,尽可能使用重复的代码,然后在压缩的时候压缩它们。例如,某些字符串经常出现,因此它们的压缩比很大。「c.width」、「c.height」和「Math」就是一些很好的例子,但还有很多其它的小问题。因此,在阅读这段代码时,请记住,你经常会看到一些重复代码,这些重复是有目的的——便于压缩。

CodePen

下面我们将给出一款在 Codepen 上运行的游戏。你可以在 iframe 上玩这个游戏,但是为了获得最佳的游戏体验,我建议你使用链接(https://codepen.io/KilledByAPixel/pen/poJdLwB)打开它,这样你还可以编辑或是创建代码分支。

HTML

我的游戏使用到 html 的部分很少,因为它主要是基于 Java 开发的。Java 创建全屏画布的方法和与后面将画布大小设置为窗口内部大小的代码都是最节省空间的。我不能确定为什么 CodePen 中需要将「overflow:hidden」添加到「body」标签中,但是直接打开应该也可以正常工作。

最终的压缩版本使用了更小的设置——把 Java 包在一个「」事件的「call」方法里(<body style=margin:0 =」code_goes_here」><canvas id=c>)。但是,我不喜欢在开发的时候用这种压缩的设置,因为代码是以字符串形式存储的,这样编译器也就无法正常地强调语法。

< bodystyle= margin:0> < canvasid= c> < >

常量

游戏中的很多东西都是由常量来控制的。当我们用类似 Google Closure 这样的工具来进行压缩时,这些常量就会被替换成类似于 C++ 中的「#define」的形式。将它们放在开头能够更快地调整游戏玩法。

// draw settings constcontext = c.getContext `2d`; // canvas context constdrawDistance = 800; // how far ahead to draw constcameraDepth = 1; // FOV of camera constsegmentLength = 100; // length of each road segment constroadWidth = 500; // how wide is road constcurbWidth = 150; // with of warning track constdashLineWidth = 9; // width of the dashed line constmaxPlayerX = 2e3; // limit player offset constmountainCount = 30; // how many mountains are there consttimeDelta = 1/ 60; // inverse frame rate constPI = Math.PI; // shorthand for Math.PI

// player settingsconstheight = 150; // high of player above groundconstmaxSpeed = 300; // limit max player speedconstplayerAccel = 1; // player forward accelerationconstplayerBrake = -3; // player breaking accelerationconstturnControl = .2; // player turning rateconstjumpAccel = 25; // z speed added for jumpconstspringConstant = .01; // spring players pitchconstcollisionSlow = .1; // slow down from collisionsconstpitchLerp = .1; // rate camera pitch changesconstpitchSpringDamp = .9; // dampen the pitch springconstelasticity = 1.2; // bounce elasticityconstcentrifugal = .002; // how much turns pull playerconstforwardDamp = .999; // dampen player z speedconstlateralDamp = .7; // dampen player x speedconstoffRoadDamp = .98; // more damping when off roadconstgravity = -1; // gravity to apply in y axisconstcameraTurnScale = 2; // how much to rotate cameraconstworldRotateScale = .00005; // how much to rotate world// level settingsconstmaxTime = 20; // time to startconstcheckPointTime = 10; // add time at checkpointsconstcheckPointDistance = 1e5; // how far between checkpointsconstmaxDifficultySegment = 9e3; // how far until max difficultyconstroadEnd = 1e4; // how far until end of road

鼠标控制

输入系统是非常简单的,只用到了鼠标。使用下面这段代码,我们可以跟踪鼠标点击和水平光标位置,并将其表示为 -1 到 1 之间的值。双击是通过「mouseUpFrames」实现的。「mousePressed」变量只在玩家第一次点击开始游戏时使用一次。

mouseDown= mousePressed= mouseUpFrames= mouseX= 0; onmouseup=e=> mouseDown = 0; onmousedown=e=> mousePressed ? mouseDown = 1: mousePressed = 1; onmousemove=e=> mouseX = e.x/window.innerWidth* 2- 1;

数学函数

这个游戏使用了一些函数来简化代码并减少冗余。一些标准的数学函数可以用来对值进行限定(Clamp)并进行线性差值操作(Lerp)。「ClampAngle」函数就非常有用,因为很多游戏需要将将角度限制在 -PI 和 PI 之间,而这个函数就可以做到。

随机测试样例

R 函数的工作原理就像魔法——生成种子随机数。它先取当前随机种子的正弦值,乘以一个很大的数,然后小数部分就是最终的随机数。有很多方法可以做到这一点,但这是最节约空间的方法之一。我不建议使用这项技术来做赌博软件,但在我们的项目里,它的随机性已经足够了。我们将使用这个随机生成器在不需要保存任何数据的情况下创建各种程序。例如,山脉、岩石和树木的变化并不储存在内存里。但我们这里的目标不是减少内存,而是消除存储和检索数据所需的代码。

由于这是一个「真 3D」游戏,一个 3D 向量类就显得极为有用了,而且它还能让代码容量更小。该类只包含这个游戏所必需的基本要素——一个带有加法和乘法函数的构造函数,它的参数既可以是标量,也可以是向量。要确定是否传入了一个标量,只需检查它是否小于一个大数。使用「isNan」或是检查它的类型是否是「Vec3」当然更好,但它们需要更多的空间。

Clamp = ( v, a, b) => Math.min( Math.max(v, a), b); ClampAngle= ( a) => (a+PI) % ( 2*PI) + (a+PI< 0? PI : -PI); Lerp = ( p, a, b) => a + Clamp(p, 0, 1) * (b-a); R = ( a=1, b=0) => Lerp(( Math.sin(++randSeed)+ 1)* 1e5% 1,a,b); classVec3 // 3d vector class{constructor( x=0, y=0, z=0) { this.x = x; this.y = y; this.z = z;} Add= ( v)=> ( v = v < 1e5? newVec3(v,v,v) : v, newVec3( this.x + v.x, this.y + v.y, this.z + v.z )); Multiply= ( v)=> ( v = v < 1e5? newVec3(v,v,v) : v, newVec3( this.x * v.x, this.y * v.y, this.z * v.z )); }

渲染函数

LSHA 使用模板字符串生成一个标准的 HSLA(色相、饱和度、光度、透明度)颜色,并且刚刚重新排序,以便将更多经常使用的组件排列在前面。在关卡处发生的全局色调变化也是在这里发生的。

DrawPoly 可以绘制梯形,它也会被用于渲染场景中的所有东西。使用「|0」将 Y 分量转换为整数,以确保道路多边形完全连接。如果进行这项操作,在路段之间就会有一条细线。出于同样的原因,这种渲染技术必须在对角线图形的组件帮助下处理相机的滚动。

DrawText 则被用来渲染显示时间、距离和游戏标题的概述文本。

LSHA= ( l,s= 0,h= 0,a= 1)=> `hsl( ${h+hueShift}, ${s}%, ${l}%, ${a})` ;

// draw a trapazoid shaped polyDrawPoly= ( x1, y1, w1, x2, y2, w2, fillStyle)=> {context.beginPath(context.fillStyle = fillStyle);context.lineTo(x1-w1, y1| 0); context.lineTo(x1+w1, y1| 0); context.lineTo(x2+w2, y2| 0); context.lineTo(x2-w2, y2| 0); context.fill;}

// draw outlined hud textDrawText= ( text, posX)=> {context.font = '9em impact'; // set font sizecontext.fillStyle = LSHA( 99, 0, 0, .5); // set font colorcontext.fillText(text, posX, 129); // fill textcontext.lineWidth = 3; // line widthcontext.strokeText(text, posX, 129); // outline text}

通过过程生成来构建轨道

在游戏开始之前,我们必须首先生成整个赛道,而每个游戏的赛道都不同。为此,我们构建一个路段列表,它存储了道路在轨道上每个点的位置和宽度。

轨道发生器很简单,它只是在不同频率、振幅和宽度的部分之间逐渐变细。赛道长度决定了这段赛道的难度。

这里的道路俯仰角是使用「atan2」函数计算出来的,它被用于用于物理效果和照明。

程序化的轨道生成器的示例结果。

roadGenLengthMax = // end of sectionroadGenLength = // distance leftroadGenTaper = // length of taperroadGenFreqX = // X wave frequencyroadGenFreqY = // Y wave frequencyroadGenScaleX = // X wave amplituderoadGenScaleY = 0; // Y wave amplituderoadGenWidth = roadWidth; // starting road widthstartRandSeed = randSeed = Date.now; // set random seedroad = []; // clear road

// generate the roadfor( i = 0; i < roadEnd* 2; ++i ) // build road past end{if(roadGenLength++ > roadGenLengthMax) // is end of section?{// calculate difficulty percentd = Math.min( 1, i/maxDifficultySegment); // randomize road settingsroadGenWidth = roadWidth*R( 1-d* .7, 3-2*d); // road widthroadGenFreqX = R(Lerp(d, .01, .02)); // X curvesroadGenFreqY = R(Lerp(d, .01, .03)); // Y bumpsroadGenScaleX = i>roadEnd ? 0: R(Lerp(d, .2, .6)); // X scaleroadGenScaleY = R(Lerp(d, 1e3, 2e3)); // Y scale// apply taper and move backroadGenTaper = R( 99, 1e3)| 0; // random taperroadGenLengthMax = roadGenTaper + R( 99, 1e3); // random lengthroadGenLength = 0; // reset lengthi -= roadGenTaper; // subtract taper}// make a wavy roadx = Math.sin(i*roadGenFreqX) * roadGenScaleX; y = Math.sin(i*roadGenFreqY) * roadGenScaleY; road[i] = road[i]? road[i] : { x:x, y:y, w:roadGenWidth}; // apply taper from last section and lerp valuesp = Clamp(roadGenLength / roadGenTaper, 0, 1); road[i].x = Lerp(p, road[i].x, x);road[i].y = Lerp(p, road[i].y, y);road[i].w = i > roadEnd ? 0: Lerp(p, road[i].w, roadGenWidth); // calculate road pitch angleroad[i].a = road[i -1] ? Math.atan2(road[i -1].y-road[i].y, segmentLength) : 0; }

开始游戏

现在轨道有了,剩下的启动过程就很简单了。我们只需要初始化几个变量。

// reseteverything velocity = newVec3 ( pitchSpring = pitchSpringSpeed = pitchRoad = hueShift = 0); position = new Vec3(0, height); // setplayer startpos nextCheckPoint = checkPointDistance; // init next checkpointtime = maxTime; // setthe starttimeheading= randSeed; // random world heading

更新玩家状态

本节将介绍主要的更新函数,它可以处理游戏中所有内容的更新和渲染!通常,在代码写一个超大的函数并不是一个好习惯,我们需要将其分解成子函数。因此,为了方便理解,下面的叙述将其分为几部分。

首先我们需要了解玩家所在位置的道路信息。为了使物理效果和渲染感觉平滑,在当前路段和下一个路段之间进行了插值操作。

玩家的位置和速度是 3D 向量,并通过动力学进行更新以体现重力,阻尼和其他因素。如果玩家在道路下方,位置将被固定在地面上,并且速度会相对于法线反射。同样,在地面上时会施加加速度,并且越野行驶时相机会震动。经过游戏测试后,我决定允许玩家在空降时仍可以进行调整。

在此处理输入以控制加速,刹车,跳跃和转弯。通过「mouseUpFrames」也可以检测到双击。有一些代码可以跟踪玩家在空中停留了多少帧,以便在玩家仍然可以跳跃的时候有一个短暂的宽限期。

相机的俯仰角使用了一个简单的弹簧系统,在玩家加速、刹车和跳跃时给人一种动态的感觉。当玩家驾车翻越山丘以及跳跃时,摄像机也会根据道路角度倾斜。

Update= => {

// get player road segments = position.z / segmentLength | 0; // current road segmentp = position.z / segmentLength % 1; // percent along segment

// get lerped values between last and current road segmentroadX = Lerp(p, road[s].x, road[s+ 1].x); roadY = Lerp(p, road[s].y, road[s+ 1].y) + height; roadA = Lerp(p, road[s].a, road[s+ 1].a);

// update player velocitylastVelocity = velocity.Add( 0); velocity.y += gravity;velocity.x *= lateralDamp;velocity.z = Math.max( 0, time?forwardDamp*velocity.z: 0);

// add velocity to positionposition = position.Add(velocity);// limit player x position (how far off road)position.x = Clamp(position.x, -maxPlayerX, maxPlayerX);

// check if on groundif(position.y < roadY) {position.y = roadY; // match y to ground planeairFrame = 0; // reset air frames// get the dot product of the ground normal and the velocitydp = Math.cos(roadA)*velocity.y + Math.sin(roadA)*velocity.z; // bounce velocity against ground normalvelocity = newVec3( 0, Math.cos(roadA), Math.sin(roadA)) .Multiply(-elasticity * dp).Add(velocity);// apply player brake and accelvelocity.z +=mouseDown? playerBrake :Lerp(velocity.z/maxSpeed, mousePressed*playerAccel, 0); // check if off roadif( Math.abs(position.x) > road[s].w) {velocity.z *= offRoadDamp; // slow downpitchSpring += Math.sin(position.z/ 99)** 4/ 99; // rumble}}

// update player turning and apply centrifugal forceturn = Lerp(velocity.z/maxSpeed, mouseX * turnControl, 0); velocity.x +=velocity.z * turn -velocity.z ** 2* centrifugal * roadX;

// update jumpif(airFrame++< 6&& time && mouseDown && mouseUpFrames && mouseUpFrames< 9) {velocity.y += jumpAccel; // apply jump velocityairFrame = 9; // prevent jumping again}mouseUpFrames = mouseDown? 0: mouseUpFrames+ 1;

// pitch down with vertical velocity when in airairPercent = (position.y-roadY) / 99; pitchSpringSpeed += Lerp(airPercent, 0, velocity.y/ 4e4);

// update player pitch springpitchSpringSpeed += (velocity.z - lastVelocity.z)/ 2e3; pitchSpringSpeed -= pitchSpring * springConstant;pitchSpringSpeed *= pitchSpringDamp;pitchSpring += pitchSpringSpeed;pitchRoad = Lerp(pitchLerp, pitchRoad, Lerp(airPercent,-roadA, 0)); playerPitch = pitchSpring + pitchRoad;

// update headingheading = ClampAngle(heading + velocity.z*roadX*worldRotateScale);cameraHeading = turn * cameraTurnScale;

// was checkpoint crossed?if(position.z > nextCheckPoint) {time += checkPointTime; // add more timenextCheckPoint += checkPointDistance; // set next checkpointhueShift += 36; // shift hue}

预渲染

在渲染之前,可以通过设置画布的宽度和高度来清除画布。这也适用于用画布填充窗口。

我们还计算了用于将世界点转换为画布空间的投影比例。「cameraDepth」值表示摄像机的视野(FOV),本游戏中其视野为 90 度。计算公式为「1/Math.tan((fovRadians/2))」,对于 90 度的 FOV 来说,其结果正好是 1。为了保持纵横比,投影按「c.width」进行缩放。

// clear the screen and set sizec.width = window.innerWidth, c.height = window.innerHeight;

// calculate projection scale, flip yprojectScale = ( newVec3( 1, -1, 1)).Multiply(c.width/ 2/cameraDepth);

画出天空、太阳和月亮

背景氛围是通过全屏线性渐变绘制的,它会根据太阳的方向更改颜色。

为了节省空间,我们使用具有透明度的全屏径向渐变在同一个 for 循环中绘制太阳和月亮。

线性和径向渐变相结合,构成了一个完全环绕场景的天空。

// get horizon, offset, and light amounthorizon = c.height/ 2- Math.tan(playerPitch)*projectScale.y; backgroundOffset = Math.sin(cameraHeading)/ 2; light = Math.cos(heading);

// create linear gradient for skyg = context.createLinearGradient( 0,horizon- c.height/ 2, 0,horizon); g.addColorStop( 0, LSHA( 39+light* 25, 49+light* 19, 230-light* 19)); g.addColorStop( 1, LSHA( 5, 79, 250-light* 9));

// draw sky as full screen polyDrawPoly( c.width/ 2, 0, c.width/ 2, c.width/ 2, c.height, c.width/ 2,g);

// draw sun and moon (0=sun, 1=moon)for( i = 2; i--; ) {// create radial gradientg = context.createRadialGradient(x = c.width*(. 5+ Lerp( (heading/ PI/ 2+. 5+i/ 2)% 1, 4, - 4)-backgroundOffset), y = horizon - c.width/ 5, c.width/ 25, x, y, i? c.width/ 23: c.width); g.addColorStop( 0, LSHA(i? 70: 99)); g.addColorStop( 1, LSHA( 0, 0, 0, 0)); // draw full screen polyDrawPoly( c.width/ 2, 0, c.width/ 2, c.width/ 2, c.height, c.width/ 2,g); }

画出山和地平线

山是通过在地平线上绘制 50 个三角形来程序化地生成的。当我们面向太阳时,由于山处于阴影中,所以山的光线会更暗。此外,附近的山更暗,以模拟雾的效果。这里真正的诀窍是调整大小和颜色的随机值以获得良好的结果。

绘制背景的最后一部分是绘制地平线,并用纯绿色填充地平线的下方。

// set random seed for mountainsrandSeed = startRandSeed;

// draw mountainsfor( i = mountainCount; i--; ) {angle = ClampAngle(heading+ R( 19)); light = Math.cos(angle-heading); DrawPoly( x = c.width*(. 5+ Lerp(angle/ PI/ 2+. 5, 4,- 4)-backgroundOffset), y = horizon,w = R(. 2,. 8)** 2* c.width/ 2, x + w* R(-. 5,. 5), y - R(. 5,. 8)*w, 0, LSHA( R( 15, 25)+i/ 3-light* 9, i/ 2+ R( 19), R( 220, 230))); }

// draw horizonDrawPoly( c.width/ 2, horizon, c.width/ 2, c.width/ 2, c.height, c.width/ 2, LSHA( 25, 30, 95));

将路段投影到画布空间

在渲染道路之前,我们必须首先获取投影后的道路点。第一部分比较复杂,因为我们的道路的 x 值需要转换为世界空间位置。为了使道路看起来是弯曲的,我们将 x 值作为二阶导数。这就是奇怪的代码「x+=w+=」的作用。由于这种工作方式,路段并没有固定的世界空间位置,而是基于玩家的位置重新计算每一帧。

有了世界空间位置后,我们便能够用道路位置减去玩家位置以获得当前的摄像头空间位置。代码的其余部分实现了不同的变换,首先旋转航向、俯仰角,然后进行投影变换,使更远的东西看起来更小,最后将其映射到画布空间。

for( x = w = i = 0; i < drawDistance+ 1; ) {p = newVec3(x+=w+=road[s+i].x, // sum local road offsetsroad[s+i].y, (s+i)*segmentLength) // road y and z pos.Add(position.Multiply( -1)); // get local camera space

// apply camera headingp.x = p.x* Math.cos(cameraHeading) - p.z* Math.sin(cameraHeading); // tilt camera pitch and invert zz = 1/(p.z* Math.cos(playerPitch) - p.y* Math.sin(playerPitch)); p.y = p.y* Math.cos(playerPitch) - p.z* Math.sin(playerPitch); p.z = z;// project road segment to canvas spaceroad[s+i++].p = // projected road pointp.Multiply( newVec3(z, z, 1)) // projection.Multiply(projectScale) // scale.Add( newVec3(c.width/ 2,c.height/ 2)); // center on canvas}

绘制路段

现在,我们有了每个路段的画布空间点,渲染就相当简单了。我们需要从后到前绘制每个路段,或者更具体地说,画出连接路段的梯形多边形。

为了创建道路,我们需要在每个路段上进行 4 层渲染:地面,条纹路缘,道路本身和虚线白线。根据道路线段的坡度和方向为每个阴影着色,并根据该图层的外观添加一些额外的逻辑。

我们需要检查路段是否在近/远剪辑范围中,以防止出现怪异的渲染伪像。此外,还有一个很好的优化方法,可以在道路变得很细时按距离缩小道路分辨率。这样就在没有明显的质量损失的情况下,将绘图次数减少了一半以上,从而获得了巨大的性能提升。

线框轮廓显示了每一个被渲染的多边形。

letsegment2 = road[s+drawDistance]; // store the last segmentfor( i = drawDistance; i--; ) // iterate in reverse{// get projected road pointssegment1 = road[s+i];p1 = segment1.p;p2 = segment2.p;// random seed and lightingrandSeed = startRandSeed + s + i;light = Math.sin(segment1.a) * Math.cos(heading) * 99; // check near and far clipif(p1.z < 1e5 && p1.z > 0) {// fade in road resolution over distanceif(i % ( Lerp(i/drawDistance, 1, 9)| 0) == 0) {// groundDrawPoly( c.width/ 2, p1.y, c.width/ 2, c.width/ 2, p2.y, c.width/ 2, LSHA( 25+ light, 30, 95));

// curb if wide enoughif(segment1.w > 400) DrawPoly(p1.x, p1.y, p1.z*(segment1.w+curbWidth), p2.x, p2.y, p2.z*(segment2.w+curbWidth),LSHA(((s+i)% 19< 9? 50: 20) + light)); // road and checkpoint markerDrawPoly(p1.x, p1.y, p1.z*segment1.w, p2.x, p2.y, p2.z*segment2.w,LSHA(((s+i)*segmentLength%checkPointDistance < 300? 70: 7) + light)); // dashed lines if wide and close enoughif((segment1.w > 300) && (s+i)% 9== 0&& i < drawDistance/ 3) DrawPoly(p1.x, p1.y, p1.z*dashLineWidth, p2.x, p2.y, p2.z*dashLineWidth,LSHA( 70+ light));

// save this segmentsegment2 = segment1;}

绘制道路上的树和石头

这个游戏只有两种不同类型的物体:树和石头,它们是被渲染在道路上的。首先,我们使用「R」函数来确定是否存在对象。这是种子随机数厉害的地方之一。我们还将使用「R」为对象添加随机形状和颜色变化。

一开始我想要其他的车辆,但如果不进行大幅裁剪,就不能满足空间限制,所以我使用风景作为障碍。这些风景的位置是随机的,而且倾向于接近道路,否则他们就会变得很稀疏,而且很容易通过。为了节省空间,对象的高度也决定了对象的类型。

在这里可以通过比较玩家和物体在 3D 空间中的位置来检查它们之间的碰撞。当一个物体被击中时,玩家会放慢速度,并将该物体标记为击中,这样它就可以安全地通过。

为了防止物体突然出现在地平线上,透明效果会随着距离的增加而减弱。由于我前面提到的神奇的种子随机函数,对象的形状和颜色使用了带有变化的梯形绘制函数。

if(R< .2&& s+i> 29) // is there an object?{// player object collision checkx = 2*roadWidth * R( 10, -10) * R( 9); // choose object posconstobjectHeight = (R( 2)| 0) * 400; // choose tree or rockif(!segment1.h // dont hit same object&& Math.abs(position.x-x)< 200// X&& Math.abs(position.z-(s+i)*segmentLength)< 200// Z&& position.y-height<segment1.y+objectHeight+ 200) // Y{// slow player and mark object as hitvelocity = velocity.Multiply(segment1.h = collisionSlow);}

// draw road objectconstalpha = Lerp(i/drawDistance, 4, 0); // fade in objectif(objectHeight) {// tree trunkDrawPoly(x = p1.x+p1.z * x, p1.y, p1.z* 29, x, p1.y -99*p1.z, p1.z* 29, LSHA( 5+R( 9), 50+R( 9), 29+R( 9), alpha)); // tree leavesDrawPoly(x, p1.y-R( 50, 99)*p1.z, p1.z*R( 199, 250), x, p1.y-R( 600, 800)*p1.z, 0, LSHA( 25+R( 9), 80+R( 9), 9+R( 29), alpha)); }else{// rockDrawPoly(x = p1.x+p1.z*x, p1.y, p1.z*R( 200, 250), x+p1.z*(R( 99, -99)), p1.y-R( 200, 250)*p1.z, p1.z*R( 99), LSHA( 50+R( 19), 25+R( 19), 209+R( 9), alpha)); }}}}

绘制 HUD,更新时间,请求下一个更新

游戏的标题、时间和距离是通过一个非常简单的字体渲染系统显示的,该系统使用了我们之前设置的 DrawText 函数。在玩家点击鼠标之前,它会将标题显示在屏幕中央。这是我非常自豪的部分——能够显示游戏标题并使用粗体的「impact」字体。如果我面临的空间上的要求更紧一些,这些东西会是第一个被我删掉的。

按下鼠标后,游戏就会开始,HUD 显示剩余的时间和当前距离。在这个条件语句块中,时间也会被更新,因为它只在比赛开始后才会减少。

在这个庞大的更新函数的最后,它调用「requestAnimationFrame(Update)」来触发下一次更新。

if(mousePressed) {time = Clamp(time - timeDelta, 0, maxTime); // update timeDrawText( Math.ceil(time), 9); // show timecontext.textAlign = 'right'; // right alignmentDrawText( 0|position.z/ 1e3, c.width -9); // show distance}else{context.textAlign = 'center'; // center alignmentDrawText( 'HUE JUMPER', c.width/ 2); // draw title text}

requestAnimationFrame(Update); // kick off next frame

} // end of update function

最后一点代码

我们需要调用一次上面巨大的更新函数,以启动更新循环。

此外,HTML 需要一个关闭脚本标签来让所有代码实际运行。

Update; // kick off updateloop</>

极致压缩

整个游戏的业务逻辑就是如此!以下是用彩色编码将其压缩以显示不同部分后的最终结果。完成所有这些工作之后,可以想象,在这样一小段代码中看到我的整个游戏是多么令人满足。之后的 zip 操作通过消除重复的代码将文件大小几乎又减少了一半。

  • HTML – Red
  • 函数 – Orange
  • 设置– Yellow
  • 玩家更新 – Green
  • 背景渲染 – Cyan
  • 道路渲染 – Purple
  • 对象渲染 – Pink
  • HUD 渲染 – Brown

说明

其他方法也可以实现同时提供性能和视觉效果的 3D 渲染。如果我有更多的空间,我更愿意使用像「three.js」这样的 WebGL API,我在我去年制作的游戏《Bogus Roads》中就用到了它。此外,因为它使用的是「requestAnimationFrame」,所以确实需要一些额外的代码来确保帧速率限制在 60 fps,我将它们添加到了增强版本中。我更喜欢使用「requestAnimationFrame」而不是「setInterval」,因为它的渲染结果更平滑,因为它将被垂直同步(让显卡的运算和显示器刷新率一致以稳定输出的画面质量)。这段代码的一个主要有点是它的兼容性非常好,可以在任何设备上运行,不过在我那台老旧的 iPhone 上运行速度有点慢。

结语

读完本文,希望大家有所收获。

这个游戏的代码在 GPL-3.0 开源协议下,在 GitHub 上已经开源了, 你可以在自己的项目中随意使用它。该 repo 还包含 2k 版本,该版本在发布时仅为 2031 字节!你也可以加入一些额外的功能如音乐和音效来实现「增强」版本。(https://killedbyapixel.itch.io/hue-jumper)

  • GitHub地址:https://github.com/KilledByAPixel/HueJumper2k

原文链接:http://frankforce.com/?p=7427

4 月 30 日,机器之心联合华为昇腾学院开设的线上公开课《轻松上手开源框架 MindSpore》第四课将正式开讲,主题为「k8s MindSpore Operator 介绍」,欢迎读者报名学习。游戏网

相关下载

玩家评论

特殊的“五一”,如何正确打开玩耍模式?

新华社重庆4月29日电 题:特殊的“五一”,如何正确打开玩耍模式?新华社“新华视点”记者柯高阳、邵鲁文、董雪2020年的“五一&rd详情>>

阅读: 0
日期: 2020-04-30
《山海镜花》如何正确培养SSR镜灵夸父

哈喽小伙伴们大家好,我是枕上江南,山海镜花即将上线啦!在游戏正式上线之前,江南会先给各位逐步介绍游戏中的镜灵,让各位能够在游戏上线前就能够了解到培养攻略。本详情>>

阅读: 0
日期: 2020-04-30
如何提高变现eCPM 实用指南分享

本文适用于广告变现的游戏和APP。不过具体实操的时还需要考虑产品的DAU量级。相对来说量级越大可优化的空间就越多。 说到广告变现,那就一定离不开eCPM价格。那么如详情>>

阅读: 0
日期: 2020-04-30
如何进入高端玩家俱乐部?差距在哪里?改掉这些小毛病成功具备高端素质!

很多玩家在看职业选手比赛或者高分段主播直播时,总感觉自己也能行,感觉他们打得很轻松,甚至有种不如你自己的感觉,可是当自己真的进游戏以后,发现自己详情>>

阅读: 0
日期: 2020-04-30
钉钉如何设置人脸识别登录?从此不用记密码

详情>>

阅读: 0
日期: 2020-04-30
如何使用企业微信微文档收集表?迅速收集员工信息

详情>>

阅读: 0
日期: 2020-04-30
如何查看手机的刷新率 一般手机刷新率多少

手机刷新率是指屏幕上面的图像看到的扫描次数,这样会显得你画面更流畅,如果你不知道在哪看自己手机刷新率,可以看看本站提供的详细介绍。如何查看手机的刷新率刷新率是指屏幕上详情>>

阅读: 3
日期: 2020-04-29
如何让更多人关注到你在Steam上的游戏?Valve官方是这么说的

如果你是一名独立游戏开发商,并且倾向于传统的付费制游戏。那么你的盈利大概率来自Steam平台。Valve的Steam平台是PC游戏板块最大的市场,同时也很有可能是一个最棒的环境:在详情>>

阅读: 3
日期: 2020-04-28
如何让玩家义无反顾地付费? 分享一套 “经济模块”设计公式

13年在腾讯做《斗战神》时,很多系统交互都无法满足玩家的心理诉求。 在这个大背景下,我提出了一个理论框架:游戏用户体验的情感化设计,希望能指导设计解决问题。 在详情>>

阅读: 3
日期: 2020-04-27
东方财富通有哪些功能,新手用户如何使用

东方财富通其实就是一款专门针对炒股爱好者打造的完全免费的软件,使用这款软件,不仅能够提升炒股爱好者在炒股过程中的成功率,同时也能降低一些风险。这款软件是由东方详情>>

阅读: 2
日期: 2020-04-26
公主连结黑骑培养建议抽到黑骑如何使用

公主连结中黑骑是非常重要的角色,本次就为大家带来了公主连结黑骑培养建议,告诉大家抽到黑骑如何使用,非常全面的内容,还不了解黑骑的朋友千万不要错过。黑骑培养建议1、因为你详情>>

阅读: 3
日期: 2020-04-24
如何利用微信视频号赚钱 用微信视频号赚钱攻略[多图]

微信是现在大家都在用的一个掌上聊天工具,现在累计用户已经有几十亿了,基本上现在人人都有用这个软件实现自己的日常沟通交流,而且在这里的各种项目服务都是非常靠谱贴心的,大详情>>

阅读: 1
日期: 2020-04-24
如何让射手座欲罢不能-让射手座对你死心塌地的方法

详情>>

阅读: 5
日期: 2020-04-23
如何解决netframework4.0安装未成功问题

net framework 4.0安装未成解决办法 清除时只清除微软的Net framework,其他的不要清除,比如我自己装NI时就有的NI Net framework 4.0 先清除较高版本,比如我电脑里有个4详情>>

阅读: 2
日期: 2020-04-23
隐藏的酒店游戏进不去应该如何解决?

隐藏的酒店游戏进不去这是不少游戏玩家在玩这款游戏的时候经常性遇到的一个问题,下面,笔者就简单的为大家介绍一下具体的解决方法,感兴趣的玩家接着往下看。 在为玩家详情>>

阅读: 2
日期: 2020-04-23
游戏老司机:如何打造一款爆款卡牌?从《Artifact》回炉重置探讨

  本文主要探讨的卡牌游戏类型是针对偏策略类的卡牌游戏,而非目前泛指的卡牌RPG等游戏类型。笔者写这篇文章的原因想必大家并不陌生。就在不久前,V社宣布了《Artifact 2.0》详情>>

阅读: 3
日期: 2020-04-20
如何用美图秀秀制作水印

美图秀秀      经常在网上分享图片,盗图的现象数见不鲜,很多网友也想给自己的图片添加一款精致的水印,可是不会制作水印怎么办呢?其实用美图秀秀制作水印就详情>>

阅读: 11
日期: 2020-04-17
贷款方式如何选择?

纯商贷,纯公积金,组合贷 , 如果可以的话,优先公积金贷款,毕竟利率低嘛!如果商贷的话,等额本金是最常用的,但是前期主要是利息,如果想提前还款的话,最好在第8年之前。等额本金不错,总详情>>

阅读: 3
日期: 2020-04-16
口述浦东30年丨李大来:通信巨头如何解决国外技术封锁难题

原标题:口述浦东30年丨李大来:通信巨头如何解决国外技术封锁难题 【编者按】 2020年4月18日,是浦东开发开放30周年纪念日。 三十而立,浦东告诉世界:中详情>>

阅读: 3
日期: 2020-04-16
如何用企业一方数据为程序化广告提效?

原标题:如何用企业一方数据为程序化广告提效? 引言 在现如今企业数字化转型的浪潮下,越来越多的品牌主已积累了一定规模的一方数据资产,并在IT技术上详情>>

阅读: 3
日期: 2020-04-16
三国志战略版如何正确的配将 配将思路分析

三国志战略版的配将思路用是大家需求的,这里介绍的就是如何正确的配将,那么主要是有什么思路呢?如果小伙伴有需要的话可以一起看看,也是希望这些思路能给你提供一些有用的帮助!一详情>>

阅读: 5
日期: 2020-04-14
如何选择?

山语隽府,项目拟建11栋8-11层小高层住宅及配套用房,计划在今年二季度上市。该地块在去年10月10日被被华侨城以总价9.1亿元竞得,楼面价13741元/㎡,由华侨城和路劲联合开发。 栖详情>>

阅读: 1
日期: 2020-04-13
《暴食大作战》兑换码如何使用 兑换码使用方法

《暴食大作战》兑换码如何使用 兑换码使用方法 作者:互联网来源:九游发表时间:2020-04-12 14:46:00详情>>

阅读: 1
日期: 2020-04-12
城志畅悦园商贷如何办理?

关注搜狐详情>>

阅读: 9
日期: 2020-04-11
消费者行为变化,零售企业如何应对?

原标题:消费者行为变化,零售企业如何应对? 当前,国际疫情持续蔓延,对于大多数零售企业而言,门店运营时间和流程的调整,员工的弹性安排,以及与消费者线下社详情>>

阅读: 3
日期: 2020-04-10
用色彩设计来与年轻消费者建立桥梁,苹果、诺记、a豆是如何做的

原标题:用色彩设计来与年轻消费者建立桥梁,苹果、诺记、a豆是如何做的 “颜值即正义”,对于目前的年轻消费者来说,这句话已经成为了真理!所以无论是在详情>>

阅读: 7
日期: 2020-04-08
如何实现无风险网购

原标题:如何实现无风险网购 现在网购已经被大家广泛接受。如果说网购有什么不满意的地方?那就是会遇到假货和商品质量与投诉问题。 今天给大家分享详情>>

阅读: 1
日期: 2020-04-08
产品经理,如何应对“老板需求”?

原标题:产品经理,如何应对“老板需求”? 在产品经理的工作中,如何应对“老板需求”是不可避免的问题,这也是产品经理的一个工作考验,如何解决这个问题详情>>

阅读: 7
日期: 2020-04-07
阴阳师:萌新们最头疼的御魂,如何正确配置御魂,你值得拥有

<<阴阳师>>是一款在游戏中玩家扮演一名阴阳师,其职责就是退治妖怪,玩家角色与被收服的式神作为作战单位一同参与到战斗中。游戏中玩家初始角色为安倍晴明,其余角色需要玩家角色详情>>

阅读: 3
日期: 2020-04-03
阴阳师:萌新们最头疼的御魂,如何正确配置御魂,你值得拥有

<<阴阳师>>是一款在游戏中玩家扮演一名阴阳师,其职责就是退治妖怪,玩家角色与被收服的式神作为作战单位一同参与到战斗中。游戏中玩家初始角色为安倍晴明,其余角色需要玩家角色详情>>

阅读: 7
日期: 2020-04-03
如何打造一场“超级CP日”燃爆天猫?(3)

原标题:如何打造一场“超级CP日”燃爆天猫?(3) 笔者围绕天猫聚划算的超级CP日的品牌活动,分析了这场活动的作用以及其中的玩法与要点。希望在一系列详情>>

阅读: 6
日期: 2020-04-03
如何用数据+算法,打造智能化的小微企业信用评估体系?

原标题:如何用数据+算法,打造智能化的小微企业信用评估体系? 一直以来,中小微企业普遍面临着现金流紧张、融资难和融资贵等问题。此次疫情更是加剧了详情>>

阅读: 3
日期: 2020-04-03
高考延期30天,1071万考生如何应对?

日前,教育部宣布2020年全国普通高等学校招生统一考试延期1个月至7月7日、8日举行。2020年全国高考报名人数为1071万,高考时间推迟后老师家长反应如何?考生详情>>

阅读: 3
日期: 2020-04-03
阴阳师:萌新们最头疼的御魂,如何正确配置御魂,你值得拥有

<<阴阳师>>是一款在游戏中玩家扮演一名阴阳师,其职责就是退治妖怪,玩家角色与被收服的式神作为作战单位一同参与到战斗中。游戏中玩家初始角色为安倍晴明,其余角色需要玩家角色详情>>

阅读: 4
日期: 2020-04-02
高端品牌如何打造直播销售场景

原标题:高端品牌如何打造直播销售场景 如何让直播玩出新花样?高端品牌如何巧妙地进入直播销售场? 随着线下活动受到一定阻碍,众多品牌开始转战线上。详情>>

阅读: 5
日期: 2020-04-02
《动物之森》amiibo如何使用 动森amiibo作用介绍

导 读 集合啦动物森友会是目前玩家比较关注的一款游戏,很多玩家都喜欢玩这款游戏,那么各位小伙伴们知道amiibo怎么用吗?接下来九游小编和大家一起分享一下动森amiibo作用介绍详情>>

阅读: 4
日期: 2020-04-01
加推、吉推、喜推百推大战AI智能名片如何选择?

原标题:加推、吉推、喜推百推大战 AI智能名片如何选择? 对企业来说,找客户是永远不变的主题。当时绝大部分企业都面临拓客难、转化难、办理难、易丢详情>>

阅读: 1
日期: 2020-04-01
“医疗器械唯一标识数据库”对外共享如何使用看这里

原标题:“医疗器械唯一标识数据库”对外共享 如何使用看这里 新京报讯(记者王卡拉)3月31日起,国家药监局开放医疗器械唯一标识数据库共享功能,以查询、详情>>

阅读: 3
日期: 2020-03-31
【安永观察】智能自动化:如何选择靠谱的RPA产品?

原标题:【安永观察】智能自动化:如何选择靠谱的RPA产品? 前言 2020新年伊始,我国与新型冠状病毒展开了一场没有硝烟的战争,突发的疫情打乱了每个人的详情>>

阅读: 2
日期: 2020-03-31
《集合啦!动物森友会》QR码导入攻略 动森如何将设计导入游戏

_contentraw">  在《集合啦!动物森友会》中通过多种方式的设计,可以让玩家在游戏中制作出各种不同的,同时用这些图案玩家可以更大地发挥自己的想象空间,但很多玩家不知道这些详情>>

阅读: 2
日期: 2020-03-31
精彩推荐