Categories
科技報導

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲?



幾個月前,JS1k遊戲製作節(JS1K game jam)傳出不再舉辦消息後,許多遊戲迷開始哀嚎。 Frank Force 也是其中一位,但他還有另一層身份——一位德克薩斯州奧斯汀的獨立遊戲設計師。 Frank Force 在遊戲行業工作了20年,參與過9款主流遊戲、47個獨立遊戲的設計。

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 1

在聽到這個消息後,他馬上和其他開發朋友討論了這個問題,並決定做點什麼為此紀念。

在此期間,他們受到三重因素的啟發。一是賽車遊戲,包括懷舊向的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多個這樣的遊戲。

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 2

他給這個遊戲取名 Hue Jumper。關於名字的由來,Frank 表示,遊戲的核心操作是移動。當玩家通過一個關卡時,遊戲世界就會換一個顏色色調。 “在我想像中,每通過過一個關卡,玩家都會跳轉到另一個維度,有著完全不同的色調。”

做完這個遊戲後,Frank 將包含了遊戲的全部 JavaScript 代碼都發佈在他的個人博客上,其中用到的軟件主要也是免費或開源軟件的。遊戲代碼發佈在 CodePen,可以在 iframe 中試玩,有興趣的朋友可以去看看。

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 3

以下是原博內容,AI源創評論進行了不改變原意的編譯:

確定最高目標

因為嚴格的大小限制,我需要非常仔細對待我的程序。我的總體策略是盡可能保持一切簡單,為最終目標服務。

為了幫助壓縮代碼,我使用了 Google Closure Compiler,它刪除了所有空格,將變量重命名為1個字母字符,並進行了一些輕量級優化。

用戶可以通過 Google Closure Compiler 官網在線跑代碼。不幸的是,Closure Compiler 做了一些沒有幫助的事情,比如替換模板字符串、默認參數和其他幫助節省空間的ES6特性。所以我需要手動撤銷其中一些事情,並執行一些更“危險”的壓縮技術來擠出最後一個字節空間。在壓縮方面,這不算很成功,大部分擠出的空間來自代碼本身的結構優化。

代碼需要壓縮到2KB。如果不是非要這麼做不可,有一個類似的但功能沒那麼強的工具叫做 RegPack 。

無論哪種方式,策略都是一樣的:盡最大可能重複代碼,然後用壓縮工具壓縮。最好的例子是 c.width,c.height和 Math。因此,在閱讀這段代碼時,請記住,你經常會看到我不斷重複一些東西,最終目的就是為了壓縮。

HTML

其實我的遊戲很少使用 html ,因為它主要用到的是 JavaScript 。但這是創建全屏畫布 Canvas ,也能將畫布 Canvas 設為窗口內部大小的代碼最小方法。我不知道為什麼在 CodePen 上有必要添加 overflow:hiddento the body,當直接打開時按理說也可以運行。

我將JavaScript 封裝在一個onload 調用,得到了一個更小的最終版本… 但是,在開發過程中,我不喜歡用這個壓縮設置,因為代碼存儲在一個字符串中,所以編輯器不能正確地高亮顯示語法。

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

}

設計軌道

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 4

首先,我們必須生成完整的軌道,而且準備做到每次遊戲軌道都是不同的。如何做呢?我們建立了一個道路段列表,存儲道路在軌道上每一關卡的位置和寬度。軌道生成器是非常基礎的操作,不同頻率、振幅和寬度的道路都會逐漸變窄,沿著跑道的距離決定這一段路有多難。

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 < roadEnd*2; ++i )          // build road past end

{

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 < roadY)

{

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++<6 && time

&& mouseDown && mouseUpFrames && mouseUpFrames<9)

{

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);

給世界畫上天空、太陽和月亮

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 5

空氣背景是用全屏的 linear gradient (徑向漸變)繪製的,它還會根據太陽的位置改變顏色。

為了節省存儲空間,太陽和月亮在同一個循環中,使用了一個帶有透明度的全屏 radial gradient(線性漸變)。

線性和徑向漸變相結合,形成一個完全包圍場景的天空背景。

// get horizo​​n, offset, and light amount

horizo​​n = 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,horizo​​n-c.height/2,0,horizo​​n);

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 = horizo​​n – 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);

}

給世界畫上山峰、地平線

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 6

山脈是通過在地平線上畫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 = horizo​​n,

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 horizo​​n

DrawPoly(

c.width/2, horizo​​n, 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 = 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

}

繪製路段

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 7

現在我們有了每個路段的畫布空間點,渲染就相當簡單了。我們需要從後向前畫出每一個路段,或者更具體地說,連接上一路段的梯形多邊形。

為了創建道路,這裡有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<9? 50: 20) + light));

// 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 < 300 ?

70 : 7) + light));

// dashed lines if wide and close enough

if ((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 segment

segment2 = segment1;

}

繪製路邊的樹和石頭

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 8

遊戲有兩種不同類型的物體:樹和石頭。首先,我們通過使用 R() 函數來確定是否加一個對象。這是隨機數和隨機數種子特別有意思的地方。我們還將使用 R() 為對象隨機添加不同的形狀和顏色。

最初我還想涉及其他車型,但為了達到 2KB 的要求,必須要進行特別多的削減,因此我最後放棄了這個想法,用風景作為障礙。這些位置是隨機的,也比較靠近道路,不然它們太稀疏,就很容易行駛。為了節省空間,對象高度還決定了對象的類型。

這是通過比較玩家和物體在 3D 空間中的位置來檢查它們之間的碰撞位置。當玩家撞到一個物體時,玩家減速,該物體被標記為“ hit ”,這樣它就可以安全通過。

為了防止對象突然出現在地平線上,透明度會隨著距離的接近而削弱。梯形繪圖函數定義物體的形狀和顏色,另外隨機函數會改變這兩個屬性。

if (R()29)                  // is there an object?

{

// 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)<200  >

&& Math.abs(position.z-(s+i)*segmentLength)<200  >

&& 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));

}

}

}

}

畫上 HUD,更新時間,請求下一次更新

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 9

遊戲的標題、時間和距離是用一個非常基礎的字體渲染系統顯示出來的,就是之前設置的 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還可以進一步壓縮大小。

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 10

如何用不到 2KB的JavaScript代碼寫一個3D賽車遊戲? 11

警告 Caveats

當然,還有很多其他 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字節!歡迎你添加一些其他的功能,比如音樂和音效到“增強”版本中。

後記

Frank Force 在個人博客發了這篇文章後,在內容、標題的加持下,這篇文章後來被不少國外媒體轉載。在盛讚之餘,也有質疑的聲音。網友“Anon”在原文下評論:你是如何在2KB 安裝一個完整的javascript 的,除非你可以隨意忽略dependencies 插件庫的大小,或者你將整個遊戲作為dependency,大小才有可能控製到2KB,否則就是欺騙。

Frank 回复表示,大多數 small demos 都需要某種運行環境,即使它是可執行的。在這種情況下,就是 javascript 運行時環境,沒有其他 dependencies.。因為 javascript 是解釋的,所以也可以說壓縮後的代碼是在2KB以內的。