个月前,JS1k游戏制作节(JS1K game jam)传出不再举办消息后,许多游戏迷开始哀嚎。
Frank Force 也是其中一位,但他还有另一层身份——一位德克萨斯州奥斯汀的独立游戏设计师。Frank Force 在游戏行业工作了20年,参与过9款主流游戏、47个独立游戏的设计。在听到这个消息后,他马上和其他开发朋友讨论了这个问题,并决定做点什么为此纪念。
在此期间,他们受到三重因素的启发。一是赛车游戏,包括怀旧向的80年代赛车游戏,他们在非常早期的硬件上推动实时 3D 图形,所以作者沿用了相同的技术,用纯 JavaScript 从头开始实现做 3D 图形和物理引擎;还有一些现代赛车游戏带来了视觉设计的灵感,比如《Distance》和《Lonely Mountains: Downhill》;二是之前 Jake Gordon 用 JavaScript 创建一个虚拟3D赛车的项目,并分享了代码;三是 Chris Glover 曾经做过一款小到只有 1KB 的 JS1k 赛车游戏《Moto1kross by Chris Glover》。
于是 Frank 和他的朋友们决定做一个压缩后只有 2KB 的 3D 赛车游戏。2KB 到底有多小呢?提供一个参考,一个3.5英寸软盘可以容纳700多个这样的游戏。
他给这个游戏取名 Hue Jumper。关于名字的由来,Frank 表示,游戏的核心操作是移动。当玩家通过一个关卡时,游戏世界就会换一个颜色色调。“在我想象中,每通过过一个关卡,玩家都会跳转到另一个维度,有着完全不同的色调。”
做完这个游戏后,Frank 将包含了游戏的全部 JavaScript 代码都发布在他的个人博客上,其中用到的软件主要也是免费或开源软件的。游戏代码发布在CodePen,可以在 iframe 中试玩,有兴趣的朋友可以去看看。
以下是原博内容,AI源创评论进行了不改变原意的编译:
因为严格的大小限制,我需要非常仔细对待我的程序。我的总体策略是尽可能保持一切简单,为最终目标服务。
为了帮助压缩代码,我使用了 Google Closure Compiler,它删除了所有空格,将变量重命名为1个字母字符,并进行了一些轻量级优化。
用户可以通过 Google Closure Compiler 官网在线跑代码。不幸的是,Closure Compiler 做了一些没有帮助的事情,比如替换模板字符串、默认参数和其他帮助节省空间的ES6特性。所以我需要手动撤销其中一些事情,并执行一些更“危险”的压缩技术来挤出最后一个字节空间。在压缩方面,这不算很成功,大部分挤出的空间来自代码本身的结构优化。
代码需要压缩到2KB。如果不是非要这么做不可,有一个类似的但功能没那么强的工具叫做 RegPack 。
无论哪种方式,策略都是一样的:尽最大可能重复代码,然后用压缩工具压缩。最好的例子是 c.width,c.height和 Math。因此,在阅读这段代码时,请记住,你经常会看到我不断重复一些东西,最终目的就是为了压缩。
其实我的游戏很少使用 html ,因为它主要用到的是 JavaScript 。但这是创建全屏画布 Canvas ,也能将画布 Canvas 设为窗口内部大小的代码最小方法。我不知道为什么在 CodePen 上有必要添加 overflow:hiddento the body,当直接打开时按理说也可以运行。
我将 JavaScript 封装在一个 onload 调用,得到了一个更小的最终版本… 但是,在开发过程中,我不喜欢用这个压缩设置,因为代码存储在一个字符串中,所以编辑器不能正确地高亮显示语法。
有许多常量在各方面控制着游戏。当代码被 Google Closure 这样的工具缩小时,这些常量将被替换,就像 C++ 中的 #define 一样,把它们放在第一位会加快游戏微调的过程。
// draw settings
const context=c.getContext`2d`; // canvas context
const drawDistance=800; // how far ahead to draw
const cameraDepth=1; // FOV of camera
const segmentLength=100; // length of each road segment
const roadWidth=500; // how wide is road
const curbWidth=150; // with of warning track
const dashLineWidth=9; // width of the dashed line
const maxPlayerX=2e3; // limit player offset
const mountainCount=30; // how many mountains are there
const timeDelta=1/60; // inverse frame rate
const PI=Math.PI; // shorthand for Math.PI
// player settings
const height=150; // high of player above ground
const maxSpeed=300; // limit max player speed
const playerAccel=1; // player forward acceleration
const playerBrake=-3; // player breaking acceleration
const turnControl=.2; // player turning rate
const jumpAccel=25; // z speed added for jump
const springConstant=.01; // spring players pitch
const collisionSlow=.1; // slow down from collisions
const pitchLerp=.1; // rate camera pitch changes
const pitchSpringDamp=.9; // dampen the pitch spring
const elasticity=1.2; // bounce elasticity
const centrifugal=.002; // how much turns pull player
const forwardDamp=.999; // dampen player z speed
const lateralDamp=.7; // dampen player x speed
const offRoadDamp=.98; // more damping when off road
const gravity=-1; // gravity to apply in y axis
const cameraTurnScale=2; // how much to rotate camera
const worldRotateScale=.00005; // how much to rotate world
// level settings
const maxTime=20; // time to start
const checkPointTime=10; // add time at checkpoints
const checkPointDistance=1e5; // how far between checkpoints
const maxDifficultySegment=9e3; // how far until max difficulty
const roadEnd=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 之间 wrap angles,在许多游戏中已经广泛应用。
R函数就像个魔术师,因为它生成随机数,通过取当前随机数种子的正弦,乘以一个大数字,然后看分数部分来实现的。其实有很多方法可以做到,但这是最小的方法之一,而且对我们来说也是足够随机。
我们将使用这个随机生成器来创建各种程序,且不需要保存任何数据。例如,山脉、岩石和树木的变化不用存到内存。在这种情况下,目标不是减少内存,而是去除存储和检索数据所需的代码。
因为这是一个“真正的3D”游戏,所以有一个 3D vector class 非常有用,它也能减少代码量。这个 class 只包含这个游戏必需的基本元素,一个带有加法和乘法函数的 constructor 可以接受标量或向量参数。为了确定标量是否被传入,我们只需检查它是否小于一个大数。更正确的方法是使用 isNan 或者检查它的类型是否是 Vec3,但是这需要更多的存储。
Clamp=(v, a, b)=> Math.min(Math.max(v, a), b);
ClampAngle=(a)=> (a+PI) % (2*PI) + (a+PILerp=(p, a, b)=> a + Clamp(p, 0, 1) * (b-a);
R=(a=1, b=0)=> Lerp((Math.sin(++randSeed)+1)*1e5%1,a,b);
class Vec3 // 3d vector class
{
constructor(x=0, y=0, z=0) {this.x=x; this.y=y; this.z=z;}
Add=(v)=>(
v=v new Vec3( this.x + v.x, this.y + v.y, this.z + v.z ));
Multiply=(v)=>(
v=v new Vec3( this.x * v.x, this.y * v.y, this.z * v.z ));
}
LSHA 通过模板字符串生成一组标准的 HSLA (色调、饱和度、亮度、alpha)颜色,并且刚刚被重新排序,所以更常用的 component 排在第一位。每过一关换一个整体色调也是通过这设置的。
DrawPoly 绘制一个梯形形状,用于渲染场景中的一切。使用 |0 将 Ycomponent 转换为整数,以确保每段多边形道路都能无缝连接,不然路段之间就会有一条细线。
DrawText 则用于显示时间、距离和游戏标题等文本渲染。
LSHA=(l,s=0,h=0,a=1)=>`hsl(${h+hueShift},${s}%,${l}%,${a})`;
// draw a trapazoid shaped poly
DrawPoly=(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 text
DrawText=(text, posX)=>
{
context.font='9em impact'; // set font size
context.fillStyle=LSHA(99,0,0,.5); // set font color
context.fillText(text, posX, 129); // fill text
context.lineWidth=3; // line width
context.strokeText(text, posX, 129); // outline text
}
首先,我们必须生成完整的轨道,而且准备做到每次游戏轨道都是不同的。如何做呢?我们建立了一个道路段列表,存储道路在轨道上每一关卡的位置和宽度。轨道生成器是非常基础的操作,不同频率、振幅和宽度的道路都会逐渐变窄,沿着跑道的距离决定这一段路有多难。
atan2 函数可以用来计算道路俯仰角,据此来设计物理运动和光线。
roadGenLengthMax=// end of section
roadGenLength=// distance left
roadGenTaper=// length of taper
roadGenFreqX=// X wave frequency
roadGenFreqY=// Y wave frequency
roadGenScaleX=// X wave amplitude
roadGenScaleY=0; // Y wave amplitude
roadGenWidth=roadWidth; // starting road width
startRandSeed=randSeed=Date.now; // set random seed
road=; // clear road
// generate the road
for( i=0; i {
if (roadGenLength++ > roadGenLengthMax) // is end of section?
{
// calculate difficulty percent
d=Math.min(1, i/maxDifficultySegment);
// randomize road settings
roadGenWidth=roadWidth*R(1-d*.7,3-2*d); // road width
roadGenFreqX=R(Lerp(d,.01,.02)); // X curves
roadGenFreqY=R(Lerp(d,.01,.03)); // Y bumps
roadGenScaleX=i>roadEnd ? 0 : R(Lerp(d,.2,.6));// X scale
roadGenScaleY=R(Lerp(d,1e3,2e3)); // Y scale
// apply taper and move back
roadGenTaper=R(99, 1e3)|0; // random taper
roadGenLengthMax=roadGenTaper + R(99,1e3); // random length
roadGenLength=0; // reset length
i -=roadGenTaper; // subtract taper
}
// make a wavy road
x=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 values
p=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 angle
road[i].a=road[i-1] ?
Math.atan2(road[i-1].y-road[i].y, segmentLength) : 0;
}
现在跑道就绪,我们只需要预置一些变量就可以开始游戏了。
// reset everything
velocity=new Vec3
( pitchSpring=pitchSpringSpeed=pitchRoad=hueShift=0 );
position=new Vec3(0, height); // set player start pos
nextCheckPoint=checkPointDistance; // init next checkpoint
time=maxTime; // set the start time
heading=randSeed; // random world heading
这是主要的更新功能,用来更新和渲染游戏中的一切!一般来说,如果你的代码中有一个很大的函数,这不是好事,为了更简洁易懂,我们会把它分几个成子函数。
首先,我们需要得到一些玩家所在位置的道路信息。为了使物理和渲染感觉平滑,需要在当前和下一个路段之间插入一些数值。
玩家的位置和速度是 3D 向量,并受重力、dampening 和其他因素等影响更新。如果玩家跑在地面上时,会受到加速度影响;当他离开这段路时,摄像机还会抖动。另外,在对游戏测试后,我决定让玩家在空中时仍然可以跑。
接下来要处理输入指令,涉及加速、刹车、跳跃和转弯等操作。双击通过 mouseUpFrames 测试。还有一些代码是来跟踪玩家在空中停留了多少帧,如果时间很短,游戏允许玩家还可以跳跃。
当玩家加速、刹车和跳跃时,我通过spring system展示相机的俯仰角以给玩家动态运动的感觉。此外,当玩家驾车翻越山丘或跳跃时,相机还会随着道路倾斜而倾斜。
Update==>
{
// get player road segment
s=position.z / segmentLength | 0; // current road segment
p=position.z / segmentLength % 1; // percent along segment
// get lerped values between last and current road segment
roadX=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 velocity
lastVelocity=velocity.Add(0);
velocity.y +=gravity;
velocity.x *=lateralDamp;
velocity.z=Math.max(0, time?forwardDamp*velocity.z:0);
// add velocity to position
position=position.Add(velocity);
// limit player x position (how far off road)
position.x=Clamp(position.x, -maxPlayerX, maxPlayerX);
// check if on ground
if (position.y {
position.y=roadY; // match y to ground plane
airFrame=0; // reset air frames
// get the dot product of the ground normal and the velocity
dp=Math.cos(roadA)*velocity.y + Math.sin(roadA)*velocity.z;
// bounce velocity against ground normal
velocity=new Vec3(0, Math.cos(roadA), Math.sin(roadA))
.Multiply(-elasticity * dp).Add(velocity);
// apply player brake and accel
velocity.z +=
mouseDown? playerBrake :
Lerp(velocity.z/maxSpeed, mousePressed*playerAccel, 0);
// check if off road
if (Math.abs(position.x) > road[s].w)
{
velocity.z *=offRoadDamp; // slow down
pitchSpring +=Math.sin(position.z/99)**4/99; // rumble
}
}
// update player turning and apply centrifugal force
turn=Lerp(velocity.z/maxSpeed, mouseX * turnControl, 0);
velocity.x +=
velocity.z * turn -
velocity.z ** 2 * centrifugal * roadX;
// update jump
if (airFrame++ && mouseDown && mouseUpFrames && mouseUpFrames{
velocity.y +=jumpAccel; // apply jump velocity
airFrame=9; // prevent jumping again
}
mouseUpFrames=mouseDown? 0 : mouseUpFrames+1;
// pitch down with vertical velocity when in air
airPercent=(position.y-roadY) / 99;
pitchSpringSpeed +=Lerp(airPercent, 0, velocity.y/4e4);
// update player pitch spring
pitchSpringSpeed +=(velocity.z - lastVelocity.z)/2e3;
pitchSpringSpeed -=pitchSpring * springConstant;
pitchSpringSpeed *=pitchSpringDamp;
pitchSpring +=pitchSpringSpeed;
pitchRoad=Lerp(pitchLerp, pitchRoad, Lerp(airPercent,-roadA,0));
playerPitch=pitchSpring + pitchRoad;
// update heading
heading=ClampAngle(heading + velocity.z*roadX*worldRotateScale);
cameraHeading=turn * cameraTurnScale;
// was checkpoint crossed?
if (position.z > nextCheckPoint)
{
time +=checkPointTime; // add more time
nextCheckPoint +=checkPointDistance; // set next checkpoint
hueShift +=36; // shift hue
}
在渲染之前,canvas 每当高度或宽度被重设时,画布内容就会被清空。这也适用于自适应窗口的画布。
我们还计算了将世界点转换到画布的投影比例。cameraDepth 值代表摄像机的视场(FOV)。这个游戏是90度。计算结果是 1/Math.tan(fovRadians/2) ,FOV 是90度的时候,计算结果正好是1。另外为了保持屏幕长宽比,投影按 c.width 缩放。
// clear the screen and set size
c.width=window.innerWidth, c.height=window.innerHeight;
// calculate projection scale, flip y
projectScale=(new Vec3(1,-1,1)).Multiply(c.width/2/cameraDepth);
空气背景是用全屏的 linear gradient (径向渐变)绘制的,它还会根据太阳的位置改变颜色。
为了节省存储空间,太阳和月亮在同一个循环中,使用了一个带有透明度的全屏 radial gradient(线性渐变)。
线性和径向渐变相结合,形成一个完全包围场景的天空背景。
// get horizon, offset, and light amount
horizon=c.height/2 - Math.tan(playerPitch)*projectScale.y;
backgroundOffset=Math.sin(cameraHeading)/2;
light=Math.cos(heading);
// create linear gradient for sky
g=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 poly
DrawPoly(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 gradient
g=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 poly
DrawPoly(c.width/2,0,c.width/2,c.width/2,c.height,c.width/2,g);
}
山脉是通过在地平线上画50个三角形,然后根据程序自己生成的。
因为用了光线照明,山脉在面对太阳时会更暗,因为它们处于阴影中。此外,越近的山脉颜色越暗,我想以此来模拟雾气。这里我有个诀窍,就是微调大小和颜色的随机值。
背景的最后一部分是绘制地平线,再用纯绿填充画布的底部。
// set random seed for mountains
randSeed=startRandSeed;
// draw mountains
for( 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 horizon
DrawPoly(
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 {
p=new Vec3(x+=w+=road[s+i].x, // sum local road offsets
road[s+i].y, (s+i)*segmentLength) // road y and z pos
.Add(position.Multiply(-1)); // get local camera space
// apply camera heading
p.x=p.x*Math.cos(cameraHeading) - p.z*Math.sin(cameraHeading);
// tilt camera pitch and invert z
z=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 space
road[s+i++].p=// projected road point
p.Multiply(new Vec3(z, z, 1)) // projection
.Multiply(projectScale) // scale
.Add(new Vec3(c.width/2,c.height/2)); // center on canvas
}
现在我们有了每个路段的画布空间点,渲染就相当简单了。我们需要从后向前画出每一个路段,或者更具体地说,连接上一路段的梯形多边形。
为了创建道路,这里有4层渲染:地面,条纹路边缘,道路本身和白色虚线。每一个都是基于路段的俯仰角和方向来加阴影,并且根据该层的表现还有一些额外的逻辑。
有必要检查路段是在近还是远剪辑范围,以防止渲染出现 bug 。此外,还有一个很好的优化方法是,当道路变得很窄时,可以通过 distance 来减小道路的分辨率。如此,不仅减少了 draw count 一半以上,而且没有明显的质量损失,这是一次性能胜利。
let segment2=road[s+drawDistance]; // store the last segment
for( i=drawDistance; i--; ) // iterate in reverse
{
// get projected road points
segment1=road[s+i];
p1=segment1.p;
p2=segment2.p;
// random seed and lighting
randSeed=startRandSeed + s + i;
light=Math.sin(segment1.a) * Math.cos(heading) * 99;
// check near and far clip
if (p1.z 0)
{
// fade in road resolution over distance
if (i % (Lerp(i/drawDistance,1,9)|0)==0)
{
// ground
DrawPoly(c.width/2, p1.y, c.width/2,
c.width/2, p2.y, c.width/2,
LSHA(25 + light, 30, 95));
// curb if wide enough
if (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
// road and checkpoint marker
DrawPoly(p1.x, p1.y, p1.z*segment1.w,
p2.x, p2.y, p2.z*segment2.w,
LSHA(((s+i)*segmentLength%checkPointDistance 70 : 7) + light));
// dashed lines if wide and close enough
if ((segment1.w > 300) && (s+i)%9==0 && i DrawPoly(p1.x, p1.y, p1.z*dashLineWidth,
p2.x, p2.y, p2.z*dashLineWidth,
LSHA(70 + light));
// save this segment
segment2=segment1;
}
游戏有两种不同类型的物体:树和石头。首先,我们通过使用 R 函数来确定是否加一个对象。这是随机数和随机数种子特别有意思的地方。我们还将使用 R 为对象随机添加不同的形状和颜色。
最初我还想涉及其他车型,但为了达到 2KB 的要求,必须要进行特别多的削减,因此我最后放弃了这个想法,用风景作为障碍。这些位置是随机的,也比较靠近道路,不然它们太稀疏,就很容易行驶。为了节省空间,对象高度还决定了对象的类型。
这是通过比较玩家和物体在 3D 空间中的位置来检查它们之间的碰撞位置。当玩家撞到一个物体时,玩家减速,该物体被标记为“ hit ”,这样它就可以安全通过。
为了防止对象突然出现在地平线上,透明度会随着距离的接近而削弱。梯形绘图函数定义物体的形状和颜色,另外随机函数会改变这两个属性。
if (R
{
// player object collision check
x=2*roadWidth * R(10,-10) * R(9); // choose object pos
const objectHeight=(R(2)|0) * 400; // choose tree or rock
if (!segment1.h // dont hit same object
&& Math.abs(position.x-x) && Math.abs(position.z-(s+i)*segmentLength) && position.y-height {
// slow player and mark object as hit
velocity=velocity.Multiply(segment1.h=collisionSlow);
}
// draw road object
const alpha=Lerp(i/drawDistance, 4, 0); // fade in object
if (objectHeight)
{
// tree trunk
DrawPoly(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 leaves
DrawPoly(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
{
// rock
DrawPoly(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));
}
}
}
}
游戏的标题、时间和距离是用一个非常基础的字体渲染系统显示出来的,就是之前设置的 DrawText 函数。在玩家点击鼠标之前,它会在屏幕中央显示标题。
按下鼠标后,游戏开始,然后 HUD 会显示剩余时间和当前距离。时间也在这块更新,玩过此类游戏的都知道,时间只在比赛开始后减少。
在这个 massive Update function 结束后,它调用 requestAnimationFrame (Update) 来触发下一次更新。
if (mousePressed)
{
time=Clamp(time - timeDelta, 0, maxTime); // update time
DrawText(Math.ceil(time), 9); // show time
context.textAlign='right'; // right alignment
DrawText(0|position.z/1e3, c.width-9); // show distance
}
else
{
context.textAlign='center'; // center alignment
DrawText('HUE JUMPER', c.width/2); // draw title text
}
requestAnimationFrame(Update); // kick off next frame
} // end of update function
HTML 需要一个结束脚本标签来让所有的代码能够跑起来。
Update; // kick off update loop
这就是整个游戏啦!下方的一小段代码就是压缩后的最终结果,我用不同的颜色标注了不同的部分。完成所有这些工作后,你能感受到我在2KB内就做完了整个游戏是多么让我满意了吗?而这还是在zip之前的工作,zip还可以进一步压缩大小。
当然,还有很多其他 3D 渲染方法可以同时保证性能和视觉效果。如果我有更多的可用空间,我会更倾向于使用一个 WebGL API 比如 three.js ,我在去年制作的一个类似游戏“Bogus Roads”中用过这个框架。此外,因为它使用的是 requestAnimationFrame ,所以需要一些额外的代码来确保帧速率不超过60 fps,增强版本中我会这么用,尽管我更喜欢使用 requestAnimationFrame 而不是 setInterval ,因为它是垂直同期的(VSyn,VerticalSynchronization),所以渲染更丝滑。这种代码的一个主要好处是它非常兼容,可以在任何设备上运行,尽管在我旧 iPhone 上运行有点慢。
游戏代码被我放到了 GitHub 上的 GPL-3.0 下(https://github.com/KilledByAPixel/HueJumper2k),所以你可以在自己的项目中自由使用它。该库中还包含 2KB 版本的游戏,准确说是2031字节!欢迎你添加一些其他的功能,比如音乐和音效到“增强”版本中。
雷锋网
文: https://www.w3cplus.com/javascript/javascript-tips.html
? w3cplus.com
任何一门技术在实际中都会有一些属于自己的小技巧。同样的,在使用JavaScript时也有一些自己的小技巧,只不过很多时候有可能容易被大家忽略。而在互联网上,时不时的有很多同行朋友会总结(或收集)一些这方面的小技巧。作为一位JavaScript的菜鸟级的同学,更应该要留意这些小技巧,因为这些小技巧可以在实际业务的开发中帮助我们解决问题,而且会很容易的解决问题。在这篇文章中,会整理一些大家熟悉或不熟悉的有关于JavaScript的小技巧。
先来看使用数组中常用的一些小技巧。
ES6提供了几种简洁的数组去重的方法,但该方法并不适合处理非基本类型的数组。对于基本类型的数组去重,可以使用... new Set()来过滤掉数组中重复的值,创建一个只有唯一值的新数组。
const array=[1, 1, 2, 3, 5, 5, 1]
const uniqueArray=[...new Set(array)];
console.log(uniqueArray);
> Result:(4) [1, 2, 3, 5]
这是ES6中的新特性,在ES6之前,要实现同样的效果,我们需要使用更多的代码。该技巧适用于包含基本类型的数组:undefined、null、boolean、string和number。如果数组中包含了一个object,function或其他数组,那就需要使用另一种方法。
除了上面的方法之外,还可以使用Array.from(new Set())来实现:
const array=[1, 1, 2, 3, 5, 5, 1]
Array.from(new Set(array))
> Result:(4) [1, 2, 3, 5]
另外,还可以使用Array的.filter及indexOf()来实现:
const array=[1, 1, 2, 3, 5, 5, 1]
array.filter((arr, index)=> array.indexOf(arr)===index)
> Result:(4) [1, 2, 3, 5]
注意,indexOf()方法将返回数组中第一个出现的数组项。这就是为什么我们可以在每次迭代中将indexOf()方法返回的索引与当索索引进行比较,以确定当前项是否重复。
在处理网格结构时,如果原始数据每行的长度不相等,就需要重新创建该数据。为了确保每行的数据长度相等,可以使用Array.fill来处理:
let array=Array(5).fill('');
console.log(array);
> Result: (5) ["", "", "", "", ""]
不使用Array.map来映射数组值的方法。
const array=[
{
name: '大漠',
email: 'w3cplus@hotmail.com'
},
{
name: 'Airen',
email: 'airen@gmail.com'
}
]
const name=Array.from(array, ({ name })=> name)
> Result: (2) ["大漠", "Airen"]
如果你想从数组末尾删除值(删除数组中的最后一项),有比使用splice()更快的替代方法。
例如,你知道原始数组的大小,可以重新定义数组的length属性的值,就可以实现从数组末尾删除值:
let array=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
console.log(array.length)
> Result: 10
array.length=4
console.log(array)
> Result: (4) [0, 1, 2, 3]
这是一个特别简洁的解决方案。但是,slice()方法运行更快,性能更好:
let array=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
array=array.slice(0, 4);
console.log(array);
> Result: [0, 1, 2, 3]
如果你想过滤数组中的falsy值,比如0、undefined、null、false,那么可以通过map和filter方法实现:
const array=[0, 1, '0', '1', '大漠', 'w3cplus.com', undefined, true, false, null, 'undefined', 'null', NaN, 'NaN', '1' + 0]
array.map(item=> {
return item
}).filter(Boolean)
> Result: (10) [1, "0", "1", "大漠", "w3cplus.com", true, "undefined", "null", "NaN", "10"]
数组的slice()取值为正值时,从数组的开始处截取数组的项,如果取值为负整数时,可以从数组末属开始获取数组项。
let array=[1, 2, 3, 4, 5, 6, 7]
const firstArrayVal=array.slice(0, 1)
> Result: [1]
const lastArrayVal=array.slice(-1)
> Result: [7]
console.log(array.slice(1))
> Result: (6) [2, 3, 4, 5, 6, 7]
console.log(array.slice(array.length))
> Result: []
正如上面示例所示,使用array.slice(-1)获取数组的最后一项,除此之外还可以使用下面的方式来获取数组的最后一项:
console.log(array.slice(array.length - 1))
> Result: [7]
你可能有一个很多名字组成的列表,需要过滤掉重复的名字并按字母表将其排序。
在我们的例子里准备用不同版本语言的JavaScript 保留字的列表,但是你能发现,有很多重复的关键字而且它们并没有按字母表顺序排列。所以这是一个完美的字符串列表(数组)来测试我们的JavaScript小知识。
var keywords=['do', 'if', 'in', 'for', 'new', 'try', 'var', 'case', 'else', 'enum', 'null', 'this', 'true', 'void', 'with', 'break', 'catch', 'class', 'const', 'false', 'super', 'throw', 'while', 'delete', 'export', 'import', 'return', 'switch', 'typeof', 'default', 'extends', 'finally', 'continue', 'debugger', 'function', 'do', 'if', 'in', 'for', 'int', 'new', 'try', 'var', 'byte', 'case', 'char', 'else', 'enum', 'goto', 'long', 'null', 'this', 'true', 'void', 'with', 'break', 'catch', 'class', 'const', 'false', 'final', 'float', 'short', 'super', 'throw', 'while', 'delete', 'double', 'export', 'import', 'native', 'public', 'return', 'static', 'switch', 'throws', 'typeof', 'boolean', 'default', 'extends', 'finally', 'package', 'private', 'abstract', 'continue', 'debugger', 'function', 'volatile', 'interface', 'protected', 'transient', 'implements', 'instanceof', 'synchronized', 'do', 'if', 'in', 'for', 'let', 'new', 'try', 'var', 'case', 'else', 'enum', 'eval', 'null', 'this', 'true', 'void', 'with', 'break', 'catch', 'class', 'const', 'false', 'super', 'throw', 'while', 'yield', 'delete', 'export', 'import', 'public', 'return', 'static', 'switch', 'typeof', 'default', 'extends', 'finally', 'package', 'private', 'continue', 'debugger', 'function', 'arguments', 'interface', 'protected', 'implements', 'instanceof', 'do', 'if', 'in', 'for', 'let', 'new', 'try', 'var', 'case', 'else', 'enum', 'eval', 'null', 'this', 'true', 'void', 'with', 'await', 'break', 'catch', 'class', 'const', 'false', 'super', 'throw', 'while', 'yield', 'delete', 'export', 'import', 'public', 'return', 'static', 'switch', 'typeof', 'default', 'extends', 'finally', 'package', 'private', 'continue', 'debugger', 'function', 'arguments', 'interface', 'protected', 'implements', 'instanceof'];
因为我们不想改变我们的原始列表,所以我们准备用高阶函数叫做filter,它将基于我们传递的回调方法返回一个新的过滤后的数组。回调方法将比较当前关键字在原始列表里的索引和新列表中的索引,仅当索引匹配时将当前关键字push到新数组。
最后我们准备使用sort方法排序过滤后的列表,sort只接受一个比较方法作为参数,并返回按字母表排序后的列表。
在ES6下使用箭头函数看起来更简单:
const filteredAndSortedKeywords=keywords
.filter((keyword, index)=> keywords.lastIndexOf(keyword)===index)
.sort((a, b)=> a < b ? -1 : 1);
这是最后过滤和排序后的JavaScript保留字列表:
console.log(filteredAndSortedKeywords);
> Result: ['abstract', 'arguments', 'await', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'double', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'final', 'finally', 'float', 'for', 'function', 'goto', 'if', 'implements', 'import', 'in', 'instanceof', 'int', 'interface', 'let', 'long', 'native', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'short', 'static', 'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'transient', 'true', 'try', 'typeof', 'var', 'void', 'volatile', 'while', 'with', 'yield']
如果你定义了一个数组,然后你想清空它。通常,你会这样做:
let array=[1, 2, 3, 4];
function emptyArray() {
array=[];
}
emptyArray();
但是,这有一个效率更高的方法来清空数组。你可以这样写:
let array=[1, 2, 3, 4];
function emptyArray() {
array.length=0;
}
emptyArray();
使用...运算符,将多维数组拍平:
const arr=[1, [2, '大漠'], 3, ['blog', '1', 2, 3]]
const flatArray=[].concat(...arr)
console.log(flatArray)
> Result: (8) [1, 2, "大漠", 3, "blog", "1", 2, 3]
不过上面的方法只适用于二维数组。不过通过递归调用,可以使用它适用于二维以下的数组:
function flattenArray(arr) {
const flattened=[].concat(...arr);
return flattened.some(item=> Array.isArray(item)) ? flattenArray(flattened) : flattened;
}
const array=[1, [2, '大漠'], 3, [['blog', '1'], 2, 3]]
const flatArr=flattenArray(array)
console.log(flatArr)
> Result: (8) [1, 2, "大漠", 3, "blog", "1", 2, 3]
可以使用Math.max和Math.min取出数组中的最大小值和最小值:
const numbers=[15, 80, -9, 90, -99]
const maxInNumbers=Math.max.apply(Math, numbers)
const minInNumbers=Math.min.apply(Math, numbers)
console.log(maxInNumbers)
> Result: 90
console.log(minInNumbers)
> Result: -99
另外还可以使用ES6的...运算符来完成:
const numbers=[1, 2, 3, 4];
Math.max(...numbers)
> Result: 4
Math.min(...numbers)
> > Result: 1
在操作对象时也有一些小技巧。
同样使用ES的...运算符可以替代人工操作,合并对象或者合并数组中的对象。
// 合并对象
const obj1={
name: '大漠',
url: 'w3cplus.com'
}
const obj2={
name: 'airen',
age: 30
}
const mergingObj={...obj1, ...obj2}
> Result: {name: "airen", url: "w3cplus.com", age: 30}
// 合并数组中的对象
const array=[
{
name: '大漠',
email: 'w3cplus@gmail.com'
},
{
name: 'Airen',
email: 'airen@gmail.com'
}
]
const result=array.reduce((accumulator, item)=> {
return {
...accumulator,
[item.name]: item.email
}
}, {})
> Result: {大漠: "w3cplus@gmail.com", Airen: "airen@gmail.com"}
不再需要根据一个条件创建两个不同的对象,以使它具有特定的属性。为此,使用...操作符是最简单的。
const getUser=(emailIncluded)=> {
return {
name: '大漠',
blog: 'w3cplus',
...emailIncluded && {email: 'w3cplus@hotmail.com'}
}
}
const user=getUser(true)
console.log(user)
> Result: {name: "大漠", blog: "w3cplus", email: "w3cplus@hotmail.com"}
const userWithoutEmail=getUser(false)
console.log(userWithoutEmail)
> Result: {name: "大漠", blog: "w3cplus"}
你可以在使用数据的时候,把所有数据都放在一个对象中。同时想在这个数据对象中获取自己想要的数据。在这里可以使用ES6的Destructuring特性来实现。比如你想把下面这个obj中的数据分成两个部分:
const obj={
name: '大漠',
blog: 'w3cplus',
email: 'w3cplus@hotmail.com',
joined: '2019-06-19',
followers: 45
}
let user={}, userDetails={}
({name: user.name, email: user.email, ...userDetails}=obj)
> {name: "大漠", blog: "w3cplus", email: "w3cplus@hotmail.com", joined: "2019-06-19", followers: 45}
console.log(user)
> Result: {name: "大漠", email: "w3cplus@hotmail.com"}
console.log(userDetails)
> Result: {blog: "w3cplus", joined: "2019-06-19", followers: 45}
在过去,我们首先必须声明一个对象,然后在需要动态属性名的情况下分配一个属性。在以前,这是不可能以声明的方式实现的。不过在ES6中,我们可以实现:
const dynamicKey='email'
let obj={
name: '大漠',
blog: 'w3cplus',
[dynamicKey]: 'w3cplus@hotmail.com'
}
console.log(obj)
> Result: {name: "大漠", blog: "w3cplus", email: "w3cplus@hotmail.com"}
使用Object.prototype.toString配合闭包来实现对象数据类型的判断:
const isType=type=> target=> `[object ${type}]`===Object.prototype.toString.call(target)
const isArray=isType('Array')([1, 2, 3])
console.log(isArray)
> Result: true
上面的代码相当于:
function isType(type){
return function (target) {
return `[object ${type}]`===Object.prototype.toString.call(target)
}
}
isType('Array')([1,2,3])
> Result: true
或者:
const isType=type=> target=> `[object ${type}]`===Object.prototype.toString.call(target)
const isString=isType('String')
const res=isString(('1'))
console.log(res)
> Result: true
当你需要检查某属性是否存在于一个对象,你可能会这样做:
var obj={
name: '大漠'
};
if (obj.name) {
console.log(true) // > Result: true
}
这是可以的,但是你需要知道有两种原生方法可以解决此类问题。in 操作符 和 Object.hasOwnProperty,任何继承自Object的对象都可以使用这两种方法。
var obj={
name: '大漠'
};
obj.hasOwnProperty('name'); // > true
'name' in obj; // > true
obj.hasOwnProperty('valueOf'); // > false, valueOf 继承自原型链
'valueOf' in obj; // > true
两者检查属性的深度不同,换言之hasOwnProperty只在本身有此属性时返回true,而in操作符不区分属性来自于本身或继承自原型链。
这是另一个例子:
var myFunc=function() {
this.name='大漠';
};
myFunc.prototype.age='10 days';
var user=new myFunc();
user.hasOwnProperty('name');
> Result: true
user.hasOwnProperty('age');
> Result: false, 因为age来自于原型链
使用Object.create(null)可以创建一个纯对象,它不会从Object类继承任何方法(例如:构造函数、toString() 等):
const pureObject=Object.create(null);
console.log(pureObject); //=> {}
console.log(pureObject.constructor); //=> undefined
console.log(pureObject.toString); //=> undefined
console.log(pureObject.hasOwnProperty); //=> undefined
JavaScript中数据类型有Number、String、Boolean、Object、Array和Function等,在实际使用时会碰到数据类型的转换。在转换数据类型时也有一些小技巧。
布尔值除了true和false之外,JavaScript还可以将所有其他值视为“真实的”或“虚假的”。除非另有定义,JavaScript中除了0、''、null、undefined、NaN和false之外的值都是真实的。
我们可以很容易地在真和假之间使用!运算符进行切换,它也会将类型转换为Boolean。比如:
const isTrue=!0;
const isFasle=!1;
const isFasle=!!0 // !0=> true,true的反即是false
console.log(isTrue)
> Result: true
console.log(typeof isTrue)
> Result: 'boolean'
这种类型的转换在条件语句中非常方便,比如将!1当作false。
我们可以使用运算符+后紧跟一组空的引号''快速地将数字或布尔值转为字符串:
const val=1 + ''
const val2=false + ''
console.log(val)
> Result: "1"
console.log(typeof val)
> Result: "string"
console.log(val2)
> Result: "false"
console.log(typeof val2)
> Result: "string"
上面我们看到了,使用+紧跟一个空的字符串''就可以将数值转换为字符串。相反的,使用加法运算符+可以快速实现相反的效果。
let int='12'
int=+int
console.log(int)
> Result: 12
console.log(typeof int)
> Result: 'number'
用同样的方法可以将布尔值转换为数值:
console.log(+true)
> Return: 1
console.log(+false)
> Return: 0
在某些上下文中,+会被解释为连接操作符,而不是加法运算符。当这种情况发生时,希望返回一个整数,而不是浮点数,那么可以使用两个波浪号~~。双波浪号~~被称为按位不运算符,它和-n - 1等价。例如, ~15=-16。这是因为- (-n - 1) - 1=n + 1 - 1=n。换句话说,~ - 16=15。
我们也可以使用~~将数字字符串转换成整数型:
const int=~~'15'
console.log(int)
> Result: 15
console.log(typeof int)
> Result: 'number'
同样的,NOT操作符也可以用于布尔值: ~true=-2,~false=-1。
平常都会使用Math.floor()、Math.ceil()或Math.round()将浮点数转换为整数。在JavaScript中还有一种更快的方法,即使用|(位或运算符)将浮点数截断为整数。
console.log(23.9 | 0);
> Result: 23
console.log(-23.9 | 0);
> Result: -23
|的行为取决于处理的是正数还是负数,所以最好只在确定的情况下使用这个快捷方式。
如果n是正数,则n | 0有效地向下舍入。如果n是负数,它有效地四舍五入。更准确的说,该操作删除小数点后的内容,将浮点数截断为整数。还可以使用~~来获得相同的舍入效果,如上所述,实际上任何位操作符都会强制浮点数为整数。这些特殊操作之所以有效,是因为一旦强制为整数,值就保持不变。
|还可以用于从整数的末尾删除任意数量的数字。这意味着我们不需要像下面这样来转换类型:
let str="1553";
Number(str.substring(0, str.length - 1));
> Result: 155
我们可以像下面这样使用|运算符来替代:
console.log(1553 / 10 | 0)
> Result: 155
console.log(1553 / 100 | 0)
> Result: 15
console.log(1553 / 1000 | 0)
> Result: 1
有时候我们需要对一个变量查检其是否存在或者检查值是否有一个有效值,如果存在就返回true值。为了做这样的验证,我们可以使用!!操作符来实现是非常的方便与简单。对于变量可以使用!!variable做检测,只要变量的值为:0、null、" "、undefined或者NaN都将返回的是false,反之返回的是true。比如下面的示例:
function Account(cash) {
this.cash=cash;
this.hasMoney=!!cash;
}
var account=new Account(100.50);
console.log(account.cash);
> Result: 100.50
console.log(account.hasMoney);
> Result: true
var emptyAccount=new Account(0);
console.log(emptyAccount.cash);
> Result: 0
console.log(emptyAccount.hasMoney);
> Result: false
在这个示例中,只要account.cash的值大于0,那么account.hasMoney返回的值就是true。
还可以使用!!操作符将truthy或falsy值转换为布尔值:
!!"" // > false
!!0 // > false
!!null // > false
!!undefined // > false
!!NaN // > false
!!"hello" // > true
!!1 // > true
!!{} // > true
!![] // > true
文章主要收集和整理了一些有关于JavaScript使用的小技巧。既然是技巧在必要的时候能帮助我们快速的解决一些问题。如果你有这方面的相关积累,欢迎在下面的评论中与我们一起分享。后续将会持续更新,希望对大家有所帮助。
2024 年旋风式巡演期间,继专辑《Open Channel》发行之后,电子乐界著名的 Claude VonStroke(克劳德-冯-斯特罗克)通过他的新软件和采样公司 Imperial Dust 推出了一款尖端数字音频工作站插件 Nasty Channel。该插件旨在为制作人提供一种非常特殊的添加失真风格。
Barclay Crenshaw 讲述了这个插件的起源故事:
“在我孩子高中的一次社交活动中,我看到一个家伙躲在房间的角落里。他穿着一件会员专用夹克,独来独往。他的样子很吸引人,我就过去打招呼。后来我才知道,他是上世纪 80 年代末 IBM 某个秘密项目的前程序员。他因为恶搞老板而被解雇,现在正在为某个他讨厌的交友软件编写廉价的脚本以换取现金。我告诉他我是做什么工作的,他问我有没有想过做软件。我说想。他让我解释所有的功能。我当时没多想,但十天后,他给我发了一封邮件,把我在聚会上告诉他的想法原封不动地告诉了我,并给了我一个粗糙的测试版。”
"Nasty Channel "提供了一套简单的功能,可将声音转化为清脆糟糕的音频。添加功能的声音调色板由 Crenshaw 自己的模拟录音室设备制作,旨在为制作人提供多功能的添加失真功能。
这次发布与最近发行的 "Open Channel "专辑不谋而合。Crenshaw 在 "The Rebel "和 "Big In the Game "等曲目中使用了新插件的测试版来完成低音失真音效。Nasty Channel 的面板也与丹尼尔-马丁-迪亚兹(Daniel Martin Diaz)在《Open Channel》黑胶唱片封面上创作的图像相呼应。
Nasty Channel 售价 49 美元,支持 VST3 和 AU 格式插件
Barclay Crenshaw 发布加法失真插件 Nasty Channel
https://www.audioapp.cn/bbs/thread-224505-1-1.html
(出处: 音频应用)
*请认真填写需求信息,我们会在24小时内与您取得联系。