個月前,JS1k游戲制作節(jié)(JS1K game jam)傳出不再舉辦消息后,許多游戲迷開始哀嚎。
Frank Force 也是其中一位,但他還有另一層身份——一位德克薩斯州奧斯汀的獨(dú)立游戲設(shè)計師。Frank Force 在游戲行業(yè)工作了20年,參與過9款主流游戲、47個獨(dú)立游戲的設(shè)計。在聽到這個消息后,他馬上和其他開發(fā)朋友討論了這個問題,并決定做點什么為此紀(jì)念。
在此期間,他們受到三重因素的啟發(fā)。一是賽車游戲,包括懷舊向的80年代賽車游戲,他們在非常早期的硬件上推動實時 3D 圖形,所以作者沿用了相同的技術(shù),用純 JavaScript 從頭開始實現(xiàn)做 3D 圖形和物理引擎;還有一些現(xiàn)代賽車游戲帶來了視覺設(shè)計的靈感,比如《Distance》和《Lonely Mountains: Downhill》;二是之前 Jake Gordon 用 JavaScript 創(chuàng)建一個虛擬3D賽車的項目,并分享了代碼;三是 Chris Glover 曾經(jīng)做過一款小到只有 1KB 的 JS1k 賽車游戲《Moto1kross by Chris Glover》。
于是 Frank 和他的朋友們決定做一個壓縮后只有 2KB 的 3D 賽車游戲。2KB 到底有多小呢?提供一個參考,一個3.5英寸軟盤可以容納700多個這樣的游戲。
他給這個游戲取名 Hue Jumper。關(guān)于名字的由來,F(xiàn)rank 表示,游戲的核心操作是移動。當(dāng)玩家通過一個關(guān)卡時,游戲世界就會換一個顏色色調(diào)。“在我想象中,每通過過一個關(guān)卡,玩家都會跳轉(zhuǎn)到另一個維度,有著完全不同的色調(diào)?!?/p>
做完這個游戲后,F(xiàn)rank 將包含了游戲的全部 JavaScript 代碼都發(fā)布在他的個人博客上,其中用到的軟件主要也是免費(fèi)或開源軟件的。游戲代碼發(fā)布在CodePen,可以在 iframe 中試玩,有興趣的朋友可以去看看。
以下是原博內(nèi)容,AI源創(chuàng)評論進(jìn)行了不改變原意的編譯:
因為嚴(yán)格的大小限制,我需要非常仔細(xì)對待我的程序。我的總體策略是盡可能保持一切簡單,為最終目標(biāo)服務(wù)。
為了幫助壓縮代碼,我使用了 Google Closure Compiler,它刪除了所有空格,將變量重命名為1個字母字符,并進(jìn)行了一些輕量級優(yōu)化。
用戶可以通過 Google Closure Compiler 官網(wǎng)在線跑代碼。不幸的是,Closure Compiler 做了一些沒有幫助的事情,比如替換模板字符串、默認(rèn)參數(shù)和其他幫助節(jié)省空間的ES6特性。所以我需要手動撤銷其中一些事情,并執(zhí)行一些更“危險”的壓縮技術(shù)來擠出最后一個字節(jié)空間。在壓縮方面,這不算很成功,大部分?jǐn)D出的空間來自代碼本身的結(jié)構(gòu)優(yōu)化。
代碼需要壓縮到2KB。如果不是非要這么做不可,有一個類似的但功能沒那么強(qiáng)的工具叫做 RegPack 。
無論哪種方式,策略都是一樣的:盡最大可能重復(fù)代碼,然后用壓縮工具壓縮。最好的例子是 c.width,c.height和 Math。因此,在閱讀這段代碼時,請記住,你經(jīng)常會看到我不斷重復(fù)一些東西,最終目的就是為了壓縮。
其實我的游戲很少使用 html ,因為它主要用到的是 JavaScript 。但這是創(chuàng)建全屏畫布 Canvas ,也能將畫布 Canvas 設(shè)為窗口內(nèi)部大小的代碼最小方法。我不知道為什么在 CodePen 上有必要添加 overflow:hiddento the body,當(dāng)直接打開時按理說也可以運(yùn)行。
我將 JavaScript 封裝在一個 onload 調(diào)用,得到了一個更小的最終版本… 但是,在開發(fā)過程中,我不喜歡用這個壓縮設(shè)置,因為代碼存儲在一個字符串中,所以編輯器不能正確地高亮顯示語法。
有許多常量在各方面控制著游戲。當(dāng)代碼被 Google Closure 這樣的工具縮小時,這些常量將被替換,就像 C++ 中的 #define 一樣,把它們放在第一位會加快游戲微調(diào)的過程。
// 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
鼠標(biāo)是唯一的輸入系統(tǒng)。通過這段代碼,我們可以跟蹤鼠標(biāo)點擊和光標(biāo)位置,位置顯示為-1到1之間的值。
雙擊是通過 mouseUpFrames 實現(xiàn)的。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;
這個游戲使用了一些函數(shù)來簡化代碼和減少重復(fù),一些標(biāo)準(zhǔn)的數(shù)學(xué)函數(shù)用于 Clamp 和 Lerp 值。 ClampAngle 是有用的,因為它在 -PI 和 PI 之間 wrap angles,在許多游戲中已經(jīng)廣泛應(yīng)用。
R函數(shù)就像個魔術(shù)師,因為它生成隨機(jī)數(shù),通過取當(dāng)前隨機(jī)數(shù)種子的正弦,乘以一個大數(shù)字,然后看分?jǐn)?shù)部分來實現(xiàn)的。其實有很多方法可以做到,但這是最小的方法之一,而且對我們來說也是足夠隨機(jī)。
我們將使用這個隨機(jī)生成器來創(chuàng)建各種程序,且不需要保存任何數(shù)據(jù)。例如,山脈、巖石和樹木的變化不用存到內(nèi)存。在這種情況下,目標(biāo)不是減少內(nèi)存,而是去除存儲和檢索數(shù)據(jù)所需的代碼。
因為這是一個“真正的3D”游戲,所以有一個 3D vector class 非常有用,它也能減少代碼量。這個 class 只包含這個游戲必需的基本元素,一個帶有加法和乘法函數(shù)的 constructor 可以接受標(biāo)量或向量參數(shù)。為了確定標(biāo)量是否被傳入,我們只需檢查它是否小于一個大數(shù)。更正確的方法是使用 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 通過模板字符串生成一組標(biāo)準(zhǔn)的 HSLA (色調(diào)、飽和度、亮度、alpha)顏色,并且剛剛被重新排序,所以更常用的 component 排在第一位。每過一關(guān)換一個整體色調(diào)也是通過這設(shè)置的。
DrawPoly 繪制一個梯形形狀,用于渲染場景中的一切。使用 |0 將 Ycomponent 轉(zhuǎn)換為整數(shù),以確保每段多邊形道路都能無縫連接,不然路段之間就會有一條細(xì)線。
DrawText 則用于顯示時間、距離和游戲標(biāo)題等文本渲染。
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
}
首先,我們必須生成完整的軌道,而且準(zhǔn)備做到每次游戲軌道都是不同的。如何做呢?我們建立了一個道路段列表,存儲道路在軌道上每一關(guān)卡的位置和寬度。軌道生成器是非常基礎(chǔ)的操作,不同頻率、振幅和寬度的道路都會逐漸變窄,沿著跑道的距離決定這一段路有多難。
atan2 函數(shù)可以用來計算道路俯仰角,據(jù)此來設(shè)計物理運(yùn)動和光線。
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;
}
現(xiàn)在跑道就緒,我們只需要預(yù)置一些變量就可以開始游戲了。
// 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
這是主要的更新功能,用來更新和渲染游戲中的一切!一般來說,如果你的代碼中有一個很大的函數(shù),這不是好事,為了更簡潔易懂,我們會把它分幾個成子函數(shù)。
首先,我們需要得到一些玩家所在位置的道路信息。為了使物理和渲染感覺平滑,需要在當(dāng)前和下一個路段之間插入一些數(shù)值。
玩家的位置和速度是 3D 向量,并受重力、dampening 和其他因素等影響更新。如果玩家跑在地面上時,會受到加速度影響;當(dāng)他離開這段路時,攝像機(jī)還會抖動。另外,在對游戲測試后,我決定讓玩家在空中時仍然可以跑。
接下來要處理輸入指令,涉及加速、剎車、跳躍和轉(zhuǎn)彎等操作。雙擊通過 mouseUpFrames 測試。還有一些代碼是來跟蹤玩家在空中停留了多少幀,如果時間很短,游戲允許玩家還可以跳躍。
當(dāng)玩家加速、剎車和跳躍時,我通過spring system展示相機(jī)的俯仰角以給玩家動態(tài)運(yùn)動的感覺。此外,當(dāng)玩家駕車翻越山丘或跳躍時,相機(jī)還會隨著道路傾斜而傾斜。
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 每當(dāng)高度或?qū)挾缺恢卦O(shè)時,畫布內(nèi)容就會被清空。這也適用于自適應(yīng)窗口的畫布。
我們還計算了將世界點轉(zhuǎn)換到畫布的投影比例。cameraDepth 值代表攝像機(jī)的視場(FOV)。這個游戲是90度。計算結(jié)果是 1/Math.tan(fovRadians/2) ,F(xiàn)OV 是90度的時候,計算結(jié)果正好是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 (徑向漸變)繪制的,它還會根據(jù)太陽的位置改變顏色。
為了節(jié)省存儲空間,太陽和月亮在同一個循環(huán)中,使用了一個帶有透明度的全屏 radial gradient(線性漸變)。
線性和徑向漸變相結(jié)合,形成一個完全包圍場景的天空背景。
// 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個三角形,然后根據(jù)程序自己生成的。
因為用了光線照明,山脈在面對太陽時會更暗,因為它們處于陰影中。此外,越近的山脈顏色越暗,我想以此來模擬霧氣。這里我有個訣竅,就是微調(diào)大小和顏色的隨機(jī)值。
背景的最后一部分是繪制地平線,再用純綠填充畫布的底部。
// 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 值需要轉(zhuǎn)換成世界空間位置。為了使道路看起來蜿蜒曲折,我們把x值作為二階導(dǎo)數(shù)。這就是為什么有奇怪的代碼“x+=w+=”出現(xiàn)的原因。由于這種工作方式,路段沒有固定的世界空間位置,每一幀都是根據(jù)玩家的位置重新計算。
一旦我們有了世界空間位置,我們就可以從道路位置中知道玩家的位置,從而得到本地攝像機(jī)空間位置。代碼的其余部分,首先通過旋轉(zhuǎn)標(biāo)題、俯仰角來應(yīng)用變換,然后通過投影變換,做到近大遠(yuǎn)小的效果,最后將其移動到畫布空間。
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
}
現(xiàn)在我們有了每個路段的畫布空間點,渲染就相當(dāng)簡單了。我們需要從后向前畫出每一個路段,或者更具體地說,連接上一路段的梯形多邊形。
為了創(chuàng)建道路,這里有4層渲染:地面,條紋路邊緣,道路本身和白色虛線。每一個都是基于路段的俯仰角和方向來加陰影,并且根據(jù)該層的表現(xiàn)還有一些額外的邏輯。
有必要檢查路段是在近還是遠(yuǎn)剪輯范圍,以防止渲染出現(xiàn) bug 。此外,還有一個很好的優(yōu)化方法是,當(dāng)?shù)缆纷兊煤苷瓡r,可以通過 distance 來減小道路的分辨率。如此,不僅減少了 draw count 一半以上,而且沒有明顯的質(zhì)量損失,這是一次性能勝利。
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 函數(shù)來確定是否加一個對象。這是隨機(jī)數(shù)和隨機(jī)數(shù)種子特別有意思的地方。我們還將使用 R 為對象隨機(jī)添加不同的形狀和顏色。
最初我還想涉及其他車型,但為了達(dá)到 2KB 的要求,必須要進(jìn)行特別多的削減,因此我最后放棄了這個想法,用風(fēng)景作為障礙。這些位置是隨機(jī)的,也比較靠近道路,不然它們太稀疏,就很容易行駛。為了節(jié)省空間,對象高度還決定了對象的類型。
這是通過比較玩家和物體在 3D 空間中的位置來檢查它們之間的碰撞位置。當(dāng)玩家撞到一個物體時,玩家減速,該物體被標(biāo)記為“ hit ”,這樣它就可以安全通過。
為了防止對象突然出現(xiàn)在地平線上,透明度會隨著距離的接近而削弱。梯形繪圖函數(shù)定義物體的形狀和顏色,另外隨機(jī)函數(shù)會改變這兩個屬性。
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));
}
}
}
}
游戲的標(biāo)題、時間和距離是用一個非?;A(chǔ)的字體渲染系統(tǒng)顯示出來的,就是之前設(shè)置的 DrawText 函數(shù)。在玩家點擊鼠標(biāo)之前,它會在屏幕中央顯示標(biāo)題。
按下鼠標(biāo)后,游戲開始,然后 HUD 會顯示剩余時間和當(dāng)前距離。時間也在這塊更新,玩過此類游戲的都知道,時間只在比賽開始后減少。
在這個 massive Update function 結(jié)束后,它調(diào)用 requestAnimationFrame (Update) 來觸發(fā)下一次更新。
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 需要一個結(jié)束腳本標(biāo)簽來讓所有的代碼能夠跑起來。
Update; // kick off update loop
這就是整個游戲啦!下方的一小段代碼就是壓縮后的最終結(jié)果,我用不同的顏色標(biāo)注了不同的部分。完成所有這些工作后,你能感受到我在2KB內(nèi)就做完了整個游戲是多么讓我滿意了嗎?而這還是在zip之前的工作,zip還可以進(jìn)一步壓縮大小。
當(dāng)然,還有很多其他 3D 渲染方法可以同時保證性能和視覺效果。如果我有更多的可用空間,我會更傾向于使用一個 WebGL API 比如 three.js ,我在去年制作的一個類似游戲“Bogus Roads”中用過這個框架。此外,因為它使用的是 requestAnimationFrame ,所以需要一些額外的代碼來確保幀速率不超過60 fps,增強(qiáng)版本中我會這么用,盡管我更喜歡使用 requestAnimationFrame 而不是 setInterval ,因為它是垂直同期的(VSyn,VerticalSynchronization),所以渲染更絲滑。這種代碼的一個主要好處是它非常兼容,可以在任何設(shè)備上運(yùn)行,盡管在我舊 iPhone 上運(yùn)行有點慢。
游戲代碼被我放到了 GitHub 上的 GPL-3.0 下(https://github.com/KilledByAPixel/HueJumper2k),所以你可以在自己的項目中自由使用它。該庫中還包含 2KB 版本的游戲,準(zhǔn)確說是2031字節(jié)!歡迎你添加一些其他的功能,比如音樂和音效到“增強(qiáng)”版本中。
雷鋒網(wǎng)
今天小編教大家如何用Python編程語言創(chuàng)建Web游戲,如果你能完成,你就可以算是一個能力相當(dāng)不錯的Python初學(xué)者了。雖然還需要多讀一些書,多寫一些程序,不過你已經(jīng)具備進(jìn)一步學(xué)習(xí)的功底了。接下來的學(xué)習(xí)就只是時間、動力及資源的問題了。
在這個習(xí)題中,我們不會去創(chuàng)建一個完整的游戲,相反,我們會為習(xí)題42中的游戲創(chuàng)建一個“引擎”(engine),讓這個游戲能夠在瀏覽器中運(yùn)行起來。這會涉及重構(gòu)習(xí)題42中的游戲,混合習(xí)題47中的結(jié)構(gòu),添加自動測試代碼,最后創(chuàng)建一個可以運(yùn)行這個游戲的Web引擎。
這是一個很龐大的習(xí)題。預(yù)計你要花一周到一個月才能完成。最好的方法是一點一點來,每晚完成一點,在進(jìn)行下一步之前確認(rèn)上一步已經(jīng)正確完成。
你已經(jīng)在兩個習(xí)題中修改了gothonweb項目,這個習(xí)題中會再修改一次。你學(xué)習(xí)的這種修改的技術(shù)叫做“重構(gòu)”,或者用我喜歡的講法來說,叫“修理”。重構(gòu)是一個編程術(shù)語,它指的是清理舊代碼或者為舊代碼添加新功能的過程。你其實已經(jīng)做過這樣的事情了,只不過不知道這個術(shù)語而已。重構(gòu)是軟件開發(fā)中經(jīng)歷的最習(xí)以為常的事情。
在這個習(xí)題中你要做的是將習(xí)題47中的可以測試的房間地圖和習(xí)題43中的游戲這兩樣?xùn)|西合并到一起,創(chuàng)建一個新的游戲結(jié)構(gòu)。游戲的內(nèi)容不會發(fā)生變化,只不過我們會通過“重構(gòu)”讓它有一個更好的結(jié)構(gòu)而已。
第一步是將ex47/game.py的內(nèi)容復(fù)制到gothonweb/map.py中,然后將tests/ex47_tests.py的內(nèi)容復(fù)制到tests/map_tests.py中,然后再次運(yùn)行nosetests,確認(rèn)它們還能正常工作。
注意
從現(xiàn)在開始,我不會再展示運(yùn)行測試的輸出了,我假設(shè)你會回去運(yùn)行這些測試,而且知道什么樣的輸出是正確的。
將習(xí)題47的代碼復(fù)制完畢后,就該開始重構(gòu)它,讓它包含習(xí)題43中的地圖。我一開始會把基本結(jié)構(gòu)為你準(zhǔn)備好,然后你需要去完成map.py和map_tests.py里邊的內(nèi)容。
首先要做的是用Room這個類來構(gòu)建地圖的基本結(jié)構(gòu)。
map.py
1 class Room(object):
2
3 def __init__(self, name, description):
4 self.name = name
5 self.description = description
6 self.paths = []
7
8 def go(self, direction):
9 return self.paths.get(direction, None)
10
11 def add_paths(self, paths):
12 self.paths.update(paths)
13
14
15 central_corridor = Room("Central Corridor",
16 """
17 The Gothons of Planet Percal #25 have invaded your ship and destroyed
18 your entire crew. You are the last surviving member and your last
19 mission is to get the neutron destruct bomb from the Weapons Armory,
20 put it in the bridge, and blow the ship up after getting into an
21 escape pod.
22
23 You're running down the central corridor to the Weapons Armory when
24 a Gothon jumps out, red scaly skin, dark grimy teeth, and evil clown costume
25 flowing around his hate filled body. He's blocking the door to the
26 Armory and about to pull a weapon to blast you.
27 """)
28
29
30 laser_weapon_armory = Room("Laser Weapon Armory",
31 """
32 Lucky for you they made you learn Gothon insults in the academy.
33 You tell the one Gothon joke you know:
34 Lbhe zbgure vf fb sng, jura fur fvgf nebhaq gur ubhfr, fur fvgf nebhaq gur ubhfr.
35 The Gothon stops, tries not to laugh, then busts out laughing and can't move.
36 While he's laughing you run up and shoot him square in the head
37 putting him down, then jump through the Weapon Armory door.
38
39 You do a dive roll into the Weapon Armory, crouch and scan the room
40 for more Gothons that might be hiding. It's dead quiet, too quiet.
41 You stand up and run to the far side of the room and find the
42 neutron bomb in its container. There's a keypad lock on the box
43 and you need the code to get the bomb out. If you get the code
44 wrong 10 times then the lock closes forever and you can't
45 get the bomb. The code is 3 digits.
46 """)
47
48
49 the_bridge = Room("The Bridge",
50 """
51 The container clicks open and the seal breaks, letting gas out.
52 You grab the neutron bomb and run as fast as you can to the
53 bridge where you must place it in the right spot.
54
55 You burst onto the Bridge with the netron destruct bomb
56 under your arm and surprise 5 Gothons who are trying to
57 take control of the ship. Each of them has an even uglier
58 clown costume than the last. They haven't pulled their
59 weapons out yet, as they see the active bomb under your
60 arm and don't want to set it off.
61 """)
62
63
64 escape_pod = Room("Escape Pod",
65 """
66 You point your blaster at the bomb under your arm
67 and the Gothons put their hands up and start to sweat.
68 You inch backward to the door, open it, and then carefully
69 place the bomb on the floor, pointing your blaster at it.
70 You then jump back through the door, punch the close button
71 and blast the lock so the Gothons can't get out.
72 Now that the bomb is placed you run to the escape pod to
73 get off this tin can.
74
75 You rush through the ship desperately trying to make it to
76 the escape pod before the whole ship explodes. It seems like
77 hardly any Gothons are on the ship, so your run is clear of
78 interference. You get to the chamber with the escape pods, and
79 now need to pick one to take. Some of them could be damaged
80 but you don't have time to look. There's 5 pods, which one
81 do you take?
82 """)
83
84
85 the_end_winner = Room("The End",
86 """
87 You jump into pod 2 and hit the eject button.
88 The pod easily slides out into space heading to
89 the planet below. As it flies to the planet, you look
90 back and see your ship implode then explode like a
91 bright star, taking out the Gothon ship at the same
92 time. You won!
93 """)
94
95
96 the_end_loser = Room("The End",
97 """
98 You jump into a random pod and hit the eject button.
99 The pod escapes out into the void of space, then
100 implodes as the hull ruptures, crushing your body
101 into jam jelly.
102 """
103 )
104
105 escape_pod.add_paths({
106 '2': the_end_winner,
107 '*': the_end_loser
108 })
109
110 generic_death = Room("death", "You died.")
111
112 the_bridge.add_paths({
113 'throw the bomb': generic_death,
114 'slowly place the bomb': escape_pod
115 })
116
117 laser_weapon_armory.add_paths({
118 '0132': the_bridge,
119 '*': generic_death
120 })
121
122 central_corridor.add_paths({
123 'shoot!': generic_death,
124 'dodge!': generic_death,
125 'tell a joke': laser_weapon_armory
126 })
127
128 START = central_corridor
你會發(fā)現(xiàn)Room類和地圖有一些問題。
1.我們必須把以前放在if-else結(jié)構(gòu)中的房間描述做成每個房間的一部分。這樣房間的次序就不會被打亂了,這對我們的游戲是一件好事。這是你后面要修改的東西。
2.原版游戲中我們使用了專門的代碼來生成一些內(nèi)容,如炸彈的激活鍵碼、艦艙的選擇等,這次我們做游戲時就先使用默認(rèn)值好了,不過后面的附加練習(xí)里,我會要求你把這些功能再加到游戲中。
3.我為游戲中所有錯誤決策的失敗結(jié)尾寫了一個generic_death,你需要去補(bǔ)全這個函數(shù)。你需要把原版游戲中所有的場景結(jié)局都加進(jìn)去,并確保代碼能正確運(yùn)行。
4.我添加了一種新的轉(zhuǎn)換模式,以"*"為標(biāo)記,用來在游戲引擎中實現(xiàn)“捕獲所有操作”的功能。
等把上面的代碼基本寫好以后,接下來就是你必須繼續(xù)寫的自動測試tests/map_test.py了。
map_tests.py
1 from nose.tools import *
2 from gothonweb.map import *
3
4 def test_room():
5 gold = Room("GoldRoom",
6 """This room has gold in it you can grab. There's a
7 door to the north.""")
8 assert_equal(gold.name, "GoldRoom")
9 assert_equal(gold.paths, {})
10
11 def test_room_paths():
12 center = Room("Center", "Test room in the center.")
13 north = Room("North", "Test room in the north.")
14 south = Room("South", "Test room in the south.")
15
16 center.add_paths({'north': north, 'south': south})
17 assert_equal(center.go('north'), north)
18 assert_equal(center.go('south'), south)
19
20 def test_map():
21 start = Room("Start", "You can go west and down a hole.")
22 west = Room("Trees", "There are trees here, you can go east.")
23 down = Room("Dungeon", "It's dark down here, you can go up.")
24
25 start.add_paths({'west': west, 'down': down})
26 west.add_paths({'east': start})
27 down.add_paths({'up': start})
28
29 assert_equal(start.go('west'), west)
30 assert_equal(start.go('west').go('east'), start)
31 assert_equal(start.go('down').go('up'), start)
32
33 def test_gothon_game_map():
34 assert_equal(START.go('shoot!'), generic_death)
35 assert_equal(START.go('dodge!'), generic_death)
36
37 room = START.go('tell a joke')
38 assert_equal(room, laser_weapon_armory)
你在這個習(xí)題中的任務(wù)是完成地圖,并且讓自動測試可以完整地檢查整個地圖。這包括將所有的generic_death對象修正為游戲中實際的失敗結(jié)尾。讓你的代碼成功運(yùn)行起來,并讓你的測試越全面越好。后面我們會對地圖做一些修改,到時候這些測試將用來確保修改后的代碼還可以正常工作。
在Web應(yīng)用程序運(yùn)行的某個位置,你需要追蹤一些信息,并將這些信息和用戶的瀏覽器關(guān)聯(lián)起來。在HTTP協(xié)議的框架中,Web環(huán)境是“無狀態(tài)”的,這意味著你的每一次請求和你的其他請求都是相互獨(dú)立的。如果你請求了頁面A,輸入了一些數(shù)據(jù),然后點了一個頁面B的鏈接,那你發(fā)送給頁面A的數(shù)據(jù)就全部消失了。
解決這個問題的方法是為Web應(yīng)用程序建立一個很小的數(shù)據(jù)存儲,給每個瀏覽器進(jìn)程賦予一個獨(dú)一無二的數(shù)字,用來跟蹤瀏覽器所做的事情。這個存儲通常用數(shù)據(jù)庫或者存儲在磁盤上的文件來實現(xiàn)。在lpthw.web這個小框架中實現(xiàn)這樣的功能是很容易的,下面就是一個這樣的例子。
session.sample.py
1 import web
2
3 web.config.debug = False
4
5 urls = (
6 "/count", "count",
7 "/reset", "reset"
8 )
9 app = web.application(urls, locals())
10 store = web.session.DiskStore('sessions')
11 session = web.session.Session(app, store, initializer=['count': 0])
12
13 class count:
14 def GET(self):
15 session.count += 1
16 return str(session.count)
17
18 class reset:
19 def GET(self):
20 session.kill()
21 return ""
22
23 if __name__ == "__main__":
24 app.run()
為了實現(xiàn)這個功能,需要創(chuàng)建一個sessions/文件夾作為程序的會話存儲位置,創(chuàng)建好以后運(yùn)行這個程序,然后檢查/count頁面,刷新一下這個頁面,看計數(shù)會不會累加上去。關(guān)掉瀏覽器后,程序就會“忘掉”之前的位置,這也是我們的游戲所需的功能。有一種方法可以讓瀏覽器永遠(yuǎn)記住一些信息,不過這會讓測試和開發(fā)變得更難。如果你回到/reset頁面,然后再訪問/count頁面,你可以看到你的計數(shù)器被重置了,因為你已經(jīng)關(guān)掉了這個會話。
你需要花點時間弄懂這段代碼,注意會話開始時count的值是如何設(shè)為0的,另外再看看sessions/下面的文件,看能不能打開。下面是我打開一個Python會話并解碼的過程:
>>> import pickle
>>> import base64
>>> base64.b64decode(open("sessions/XXXXX").read())
"(dp1\nS'count'\np2\nI1\nsS'ip'\np3\nV127.0.0.1\np4\nsS'session_id'\np5\nS'XXXX'\np6\ns."
>>>
>>> x = base64.b64decode(open("sessions/XXXXX").read())
>>>
>>> pickle.loads(x)
{'count': 1, 'ip': u'127.0.0.1', 'session_id': 'XXXXX'}
所以,會話其實就是使用pickle和base64這些庫寫到磁盤上的字典。存儲和管理會話的方法很多,大概和Python的Web框架那么多,所以了解它們的工作原理并不是很重要。當(dāng)然如果你需要調(diào)試或者清空會話,知道點兒原理還是有用的。
你應(yīng)該已經(jīng)寫好了游戲地圖和它的單元測試代碼。現(xiàn)在要你制作一個簡單的游戲引擎,用來讓游戲中的各個房間運(yùn)轉(zhuǎn)起來,從玩家收集輸入,并且記住玩家所在的位置。我們將用到你剛學(xué)過的會話來制作一個簡單的引擎,讓它可以:
1.為新用戶啟動新的游戲;
2.將房間展示給用戶;
3.接收用戶的輸入;
4.在游戲中處理用戶的輸入;
5.顯示游戲的結(jié)果,繼續(xù)游戲,直到玩家角色死亡為止。
為了創(chuàng)建這個引擎,你需要將bin/app.py搬過來,創(chuàng)建一個功能完備的、基于會話的游戲引擎。這里的難點是,我會先使用基本的HTML文件創(chuàng)建一個非常簡單的版本,接下來將由你完成它。基本的引擎是下面這個樣子的:
app.py
1 import web
2 from gothonweb import map
3
4 urls = (
5 '/game', 'GameEngine',
6 '/', 'Index',
7 )
8
9 app = web.application(urls, globals())
10
11 # little hack so that debug mode works with sessions
12 if web.config.get('_session') is None:
13 store = web.session.DiskStore('sessions')
14 session = web.session.Session(app, store,
15 initializer=['room': None])
16 web.config._session = session
17 else:
18 session = web.config._session
19
20 render = web.template.render('templates/', base="layout")
21
22
23 class Index(object):
24 def GET(self):
25 # this is used to "setup" the session with starting values
26 session.room = map.START
27 web.seeother("/game")
28
29
30 class GameEngine(object):
31
32 def GET(self):
33 if session.room:
34 return render.show_room(room=session.room)
35 else:
36 # why is there here? do you need it?
37 return render.you_died()
38
39 def POST(self):
40 form = web.input(action=None)
41
42 # there is a bug here, can you fix it?
43 if session.room and form.action:
44 session.room = session.room.go(form.action)
45
46 web.seeother("/game")
47
48 if __name__ == "__main__":
49 app.run()
在這個腳本里你可以看到更多的新東西,不過了不起的事情是,整個基于網(wǎng)頁的游戲引擎只要一個小文件就可以做到了。這段腳本里最有技術(shù)含量的就是將會話帶回來的那幾行,這對于調(diào)試模式下的代碼重載是必需的,否則每次刷新網(wǎng)頁,會話就會消失,游戲也不會再繼續(xù)了。
在運(yùn)行bin/app.py之前,你需要修改PYTHONPATH環(huán)境變量。不知道什么是環(huán)境變量?要運(yùn)行一個最基本的Python程序,你就得學(xué)會環(huán)境變量,用Python的人就喜歡這樣:
在終端輸入下面的內(nèi)容:
export PYTHONPATH=$PYTHONPATH:.
如果用的是Windows,那就在PowerShell中輸入以下內(nèi)容:
$env:PYTHONPATH = "$env:PYTHONPATH;."
你只要針對每一個shell會話輸入一次就可以了,不過如果你運(yùn)行Python代碼時看到了導(dǎo)入錯誤,那就需要去執(zhí)行一下上面的命令,或者是因為你上次執(zhí)行的有錯才導(dǎo)致導(dǎo)入錯誤的。
接下來需要刪掉templates/hello_form.html和templates/index.html,然后重新創(chuàng)建上面代碼中提到的兩個模板。下面是一個非常簡單的templates/show_room.html,供你參考。
show_room.html
$def with (room)
<h1> $room.name </h1>
<pre>
$room.description
</pre>
$if room.name == "death":
<p><a href="/">Play Again?</a></p>
$else:
<p>
<form action="/game" method="POST">
- <input type="text" name="action"> <input type="SUBMIT">
</form>
</p>
這就用來顯示游戲中的房間的模板。接下來,你需要在用戶跑到地圖的邊界時,用一個模板告訴用戶,他的角色的死亡信息,也就是templates/you_died.html這個模板。
you_died.html
<h1>You Died!</h1>
<p>Looks like you bit the dust.</p>
<p><a href="/">Play Again</a></p>
準(zhǔn)備好這些文件就可以做下面的事情了。
1.再次運(yùn)行測試代碼tests/app_tests.py,這樣就可以測試這個游戲。由于會話的存在,你可能頂多只能實現(xiàn)幾次點擊,不過你應(yīng)該可以做出一些基本的測試來。
2.刪除sessions/*下的文件,再重新運(yùn)行一遍游戲,確認(rèn)游戲是從一開始運(yùn)行的。
3. 運(yùn)行python bin/app.py腳本,試玩一下你的游戲。
你需要和往常一樣刷新和修正你的游戲,慢慢修改游戲的HTML文件和引擎,直到實現(xiàn)游戲需要的所有功能為止。
你有沒有覺得我一下子給了你超多的信息呢?那就對了,我想要你在學(xué)習(xí)技能的同時有一些可以用來鼓搗的東西。為了完成這個習(xí)題,我將給你最后一套需要你自己完成的練習(xí)。你會注意到,到目前為止你寫的游戲并不是很好,這只是你的第一版代碼而已,你現(xiàn)在的任務(wù)就是讓游戲更加完善,實現(xiàn)下面的這些功能。
1.修正代碼中所有我提到和沒提到的bug,如果你發(fā)現(xiàn)了新bug,你可以告訴我。
2.改進(jìn)所有的自動測試,以便可以測試更多的內(nèi)容,直到你可以不用瀏覽器就能測到所有的內(nèi)容為止。
3.讓HTML頁面看上去更美觀一些。
4.研究一下網(wǎng)頁登錄系統(tǒng),為這個程序創(chuàng)建一個登錄界面,這樣人們就可以登錄這個游戲,并且可以保存游戲高分。
5.完成游戲地圖,盡可能地把游戲做大,功能做全。
6.給用戶一個“幫助系統(tǒng)”,讓他們可以查詢每個房間里可以執(zhí)行哪些命令。
7.為游戲添加新功能,想到什么功能就添加什么功能。
8.創(chuàng)建多個地圖,讓用戶可以選擇他們想要玩的一張地圖來進(jìn)行游戲。你的bin/app.py應(yīng)該可以運(yùn)行提供給它的任意地圖,這樣你的引擎就可以支持多個不同的游戲。
9.最后,使用在習(xí)題48和習(xí)題49中學(xué)到的東西創(chuàng)建一個更好的輸入處理器。你手頭已經(jīng)有了大部分必要的代碼,只需要改進(jìn)語法,讓它和你的輸入表單以及游戲引擎掛鉤即可。
祝你好運(yùn)!
你需要閱讀并了解帶reloader的會話:http://webpy.org/cookbook/session_with_reloader。
錯誤路徑,錯誤Python版本,PYTHONPATH沒設(shè)置對,漏了__init__.py文件,拼寫錯誤,都檢查一下吧。
者 | huangjianke
責(zé)編 | 伍杏玲
出品 | CSDN(ID:CSDNnews)
【CSDN 編者按】據(jù)微信最新數(shù)據(jù),微信小游戲累計注冊用戶量已突破10億。那么初學(xué)者如何開發(fā)一款好玩又燒腦的微信小游戲呢?本文作者將詳細(xì)為大家講解。
“啟邏輯之高妙,因想象而自由?!睂盈B拼圖Plus是一款需要空間想象力和邏輯推理能力完美結(jié)合的微信小游戲,偶消奇不消,在簡單的游戲規(guī)則下卻有著無數(shù)種可能性,需要你充分發(fā)揮想象力去探索,看似簡單卻具有極大的挑戰(zhàn)性和趣味性,Talk is cheap. Show me the code!
層疊拼圖Plus微信小游戲采用JavaScript+Canvas實現(xiàn),沒有使用任何游戲引擎,對于初學(xué)者來說,也比較容易入門。下面是小游戲頁面:
Canvas 繪圖時,會從兩個物理像素的中間位置開始繪制并向兩邊擴(kuò)散 0.5 個物理像素。當(dāng)設(shè)備像素比為 1 時,一個 1px 的線條實際上占據(jù)了兩個物理像素(每個像素實際上只占一半),由于不存在 0.5 個像素,所以這兩個像素本來不應(yīng)該被繪制的部分也被繪制了,于是 1 物理像素的線條變成了 2 物理像素,視覺上就造成了模糊
繪圖模糊的原因知道了,在微信小游戲里面又該如何解決呢?
可以看到,我們先通過 wx.getSystemInfoSync.pixelRatio 獲取設(shè)備的像素比ratio,然后將在屏 Canvas 的寬度和高度按照所獲取的像素比ratio進(jìn)行放大,在繪制文字、圖片的時候,坐標(biāo)點 x、y 和所要繪制圖形的 width、height均需要按照像素比 ratio 進(jìn)行縮放,這樣我們就可以清晰的在高清屏中繪制想要的文字、圖片。
可參考微信官方縮放策略調(diào)整
另外,需要注意的是,這里的 canvas 是由 weapp-adapter 預(yù)先調(diào)用 wx.createCanvas 創(chuàng)建一個上屏 Canvas,并暴露為一個全局變量 canvas。
任意一個多邊形圖形,是由多個平面坐標(biāo)點所組成的圖形區(qū)域。
在游戲畫布內(nèi),我們以左上角為坐標(biāo)原點 {x: 0, y: 0} ,一個多邊形包含多個單位長度的平面坐標(biāo)點,如:[{ x: 1, y: 3 }, { x: 5, y: 3 }, { x: 3, y: 5 }] 表示為一個三角形的區(qū)域,需要注意的是,x、y 并不是真實的平面坐標(biāo)值,而是通過屏幕寬度計算出來的單位長度,在畫布內(nèi)的真實坐標(biāo)值則為 {x: x * itemWidth, y: y * itemWidth} 。
繪制多邊形代碼實現(xiàn)如下:
使用:
效果如下圖:
CanvasRenderingContext2D其他使用方法可參考:CanvasRenderingContext2D API 列表
1 + 1 = 0,是層疊拼圖Plus小游戲玩法的精髓所在。
有經(jīng)驗的同學(xué),也許一眼就發(fā)現(xiàn)了,1 + 1 = 0 剛好符合通過異或運(yùn)算得出的結(jié)果。當(dāng)然,細(xì)心的同學(xué)也可能已經(jīng)發(fā)現(xiàn),上文有一句特殊的代碼:this.ctx.globalCompositeOperation = 'xor',也正是通過設(shè)置 CanvasContext 的 globalCompositeOperation 屬性值為 xor 便實現(xiàn)了「偶消奇不消」的神奇效果。
globalCompositeOperation 是指 在繪制新形狀時應(yīng)用的合成操作的類型,其他效果可參考:globalCompositeOperation 示例
當(dāng)回轉(zhuǎn)數(shù)為 0 時,點在閉合曲線外部。
講到這里,我們已經(jīng)知道如何在Canvas畫布內(nèi)繪制出偶消奇不消效果的層疊圖形了,接下來我們來看下玩家如何移動選中的圖形。我們發(fā)現(xiàn)繪制出的圖形對象并沒有提供點擊事件綁定之類的操作,那又如何判斷玩家選中了哪個圖形呢?這里我們就需要去實現(xiàn)如何判斷玩家觸摸事件的x,y坐標(biāo)在哪個多邊形圖形內(nèi)部區(qū)域,從而判斷出玩家選中的是哪一個多邊形圖形。
判斷一個點是否在任意多邊形內(nèi)部有多種方法,比如:
射線法
面積判別法
叉乘判別法
回轉(zhuǎn)數(shù)法
...
在層疊拼圖Plus小游戲內(nèi),采用的是回轉(zhuǎn)數(shù)法來判斷玩家觸摸點是否在多邊形內(nèi)部?;剞D(zhuǎn)數(shù)是拓?fù)鋵W(xué)中的一個基本概念,具有很重要的性質(zhì)和用途。當(dāng)然,展開討論回轉(zhuǎn)數(shù)的概念并不在該文的討論范圍內(nèi),我們僅需了解一個概念:當(dāng)回轉(zhuǎn)數(shù)為 0 時,點在閉合曲線外部。
圖源:http://www.html-js.com/article/1538
上面面這張圖動態(tài)演示了回轉(zhuǎn)數(shù)的概念:圖中紅色曲線關(guān)于點(人所在位置)的回轉(zhuǎn)數(shù)為 2。
對于給定的點和多邊形,回轉(zhuǎn)數(shù)應(yīng)該怎么計算呢?
用線段分別連接點和多邊形的全部頂點
圖源:http://www.html-js.com/article/1538
計算所有點與相鄰頂點連線的夾角
圖源:http://www.html-js.com/article/1538
計算所有夾角和。注意每個夾角都是有方向的,所以有可能是負(fù)值
圖源:http://www.html-js.com/article/1538
最后根據(jù)角度累加值計算回轉(zhuǎn)數(shù)。360°(2π)相當(dāng)于一次回轉(zhuǎn)。
在使用 JavaScript 實現(xiàn)時,需要注意以下問題:
JavaScript 的數(shù)只有 64 位雙精度浮點這一種。對于三角函數(shù)產(chǎn)生的無理數(shù),浮點數(shù)計算不可避免會造成一些誤差,因此在最后計算回轉(zhuǎn)數(shù)需要做取整操作。
通常情況下,平面直角坐標(biāo)系內(nèi)一個角的取值范圍是 -π 到 π 這個區(qū)間,這也是 JavaScript 三角函數(shù) Math.atan2 返回值的范圍。但 JavaScript 并不能直接計算任意兩條線的夾角,我們只能先計算兩條線與 x 正軸夾角,再取兩者差值。這個差值的結(jié)果就有可能超出 -π 到 π 這個區(qū)間,因此我們還需要處理差值超出取值區(qū)間的情況。
代碼實現(xiàn):
探索的過程固然精彩,而結(jié)果卻更令我們期待
通過前面的介紹我們可以知道,判斷游戲結(jié)果是否正確其實就是比對玩家組合圖形的 xor 結(jié)果與目標(biāo)圖形的 xor 結(jié)果。那么如何求多個多邊形 xor 的結(jié)果呢?polygon-clipping 正是為此而生的。它不僅支持 xor 操作,還有其他的比如:union, intersection, difference 等操作。在層疊拼圖Plus游戲內(nèi)通過 polygon-clipping 又是怎樣實現(xiàn)游戲結(jié)果判斷的呢?
目標(biāo)圖形
多邊形平面坐標(biāo)點集合:
獲取 多個多邊形 xor 結(jié)果:
xor結(jié)果:
同理計算出玩家操作圖形的xor結(jié)果進(jìn)行比對即可得出答案正確與否。
需要注意的是,獲取玩家的 xor 結(jié)果并不能直接拿來與目標(biāo)圖形xor 結(jié)果進(jìn)行比較,我們需要將xor 的結(jié)果以左上角為參考點將圖形平移至原點內(nèi),然后再進(jìn)行比較,如果結(jié)果一致,則代表玩家答案正確。
有人的地方就有江湖,有江湖的地方就有排行
在看本章節(jié)內(nèi)容之前,建議先瀏覽一遍排行榜相關(guān)的官方文檔:好友排行榜、關(guān)系鏈數(shù)據(jù),以便對相關(guān)內(nèi)容有個大概的了解。
開放數(shù)據(jù)域
開放數(shù)據(jù)域是一個封閉、獨(dú)立的 JavaScript 作用域。要讓代碼運(yùn)行在開放數(shù)據(jù)域,需要在 game.json 中添加配置項 openDataContext 指定開放數(shù)據(jù)域的代碼目錄。添加該配置項表示小游戲啟用了開放數(shù)據(jù)域,這將會導(dǎo)致一些限制。
在游戲內(nèi)使用 wx.setUserCloudStorage(obj) 對玩家游戲數(shù)據(jù)進(jìn)行托管。
在開放數(shù)據(jù)域內(nèi)使用 wx.getFriendCloudStorage(obj)拉取當(dāng)前用戶所有同玩好友的托管數(shù)據(jù)
展示關(guān)系鏈數(shù)據(jù)
如果想要展示通過關(guān)系鏈 API 獲取到的用戶數(shù)據(jù),如繪制排行榜等業(yè)務(wù)場景,需要將排行榜繪制到 sharedCanvas 上,再在主域?qū)?sharedCanvas 渲染上屏。
sharedCanvas 是主域和開放數(shù)據(jù)域都可以訪問的一個離屏畫布。在開放數(shù)據(jù)域調(diào)用 wx.getSharedCanvas 將返回 sharedCanvas。
在主域中可以通過開放數(shù)據(jù)域?qū)嵗L問 sharedCanvas,通過 drawImage 方法可以將 sharedCanvas 繪制到上屏畫布。
sharedCanvas 本質(zhì)上也是一個離屏 Canvas,而重設(shè) Canvas 的寬高會清空 Canvas 上的內(nèi)容。所以要通知開放數(shù)據(jù)域去重繪 sharedCanvas。
需要注意的是:sharedCanvas 的寬高只能在主域設(shè)置,不能在開放數(shù)據(jù)域中設(shè)置。
性能優(yōu)化,簡而言之,就是在不影響系統(tǒng)運(yùn)行正確性的前提下,使之運(yùn)行地更快,完成特定功能所需的時間更短。
一款能讓人心情愉悅的游戲,性能問題必然不能成為絆腳石。那么可以從哪些方面對游戲進(jìn)行性能優(yōu)化呢?
在層疊拼圖Plus小游戲內(nèi),針對需要大量使用且繪圖繁復(fù)的靜態(tài)場景,都是使用離屏 Canvas進(jìn)行繪制的,如首頁網(wǎng)格背景、關(guān)卡列表、排名列表等。在微信內(nèi) wx.createCanvas 首次調(diào)用創(chuàng)建的是顯示在屏幕上的畫布,之后調(diào)用創(chuàng)建的都是離屏畫布。初始化時將靜態(tài)場景繪制完備,需要時直接拷貝離屏Canvas的圖像即可。Canvas 繪制本身就是不斷的更新幀從而達(dá)到動畫的效果,通過使用離屏 Canvas,就大大減少了一些靜態(tài)內(nèi)容在上屏Canvas的繪制,從而提升了繪制性能。
內(nèi)存優(yōu)化
玩家在游戲過程中拖動方塊的移動其實就是不斷更新多邊形圖形的坐標(biāo)信息,然后不斷的清空畫布再重新繪制,可以想象,這個繪制是非常頻繁的,按照普通的做法就需要不斷去創(chuàng)建多個新的 Block 對象。針對游戲中需要頻繁更新的對象,我們可以通過使用對象池的方法進(jìn)行優(yōu)化,對象池維護(hù)一個裝著空閑對象的池子,如果需要對象的時候,不是直接new,而是從對象池中取出,如果對象池中沒有空閑對象,則新建一個空閑對象,層疊拼圖Plus小游戲內(nèi)使用的是官方demo內(nèi)已經(jīng)實現(xiàn)的對象池類,實現(xiàn)如下:
垃圾回收
小游戲中,JavaScript 中的每一個 Canvas 或 Image 對象都會有一個客戶端層的實際紋理儲存,實際紋理儲存中存放著 Canvas、Image 的真實紋理,通常會占用相當(dāng)一部分內(nèi)存。
每個客戶端實際紋理儲存的回收時機(jī)依賴于 JavaScript 中的 Canvas、Image 對象回收。在 JavaScript 的 Canvas、Image 對象被回收之前,客戶端對應(yīng)的實際紋理儲存不會被回收。通過調(diào)用 wx.triggerGC 方法,可以加快觸發(fā) JavaScriptCore Garbage Collection(垃圾回收),從而觸發(fā) JavaScript 中沒有引用的 Canvas、Image 回收,釋放對應(yīng)的實際紋理儲存。
但 GC 具體觸發(fā)時機(jī)還要取決于 JavaScriptCore 自身機(jī)制,并不能保證調(diào)用 wx.triggerGC 能馬上觸發(fā)回收,層疊拼圖Plus小游戲在每局游戲開始或結(jié)束都會觸發(fā)一下,及時回收內(nèi)存垃圾,以保證最良好的游戲體驗。
對于游戲來說,每幀 16ms 是極其寶貴的,如果有一些可以異步處理的任務(wù),可以放置于 Worker 中運(yùn)行,待運(yùn)行結(jié)束后,再把結(jié)果返回到主線程。Worker 運(yùn)行于一個單獨(dú)的全局上下文與線程中,不能直接調(diào)用主線程的方法,Worker 也不具備渲染的能力。Worker與主線程之間的數(shù)據(jù)傳輸,雙方使用 Worker.postMessage 來發(fā)送數(shù)據(jù),Worker.onMessage 來接收數(shù)據(jù),傳輸?shù)臄?shù)據(jù)并不是直接共享,而是被復(fù)制的。
需要注意的是:Worker 最大并發(fā)數(shù)量限制為 1 個,創(chuàng)建下一個前請用 Worker.terminate 結(jié)束當(dāng)前 Worker
其他 Worker
相關(guān)的內(nèi)容請參考微信官方文檔:多線程 Worker
短短的一篇文章,定不能將層疊拼圖Plus小游戲的前前后后講明白講透徹。其實最讓人心累的還是軟著的申請過程,由于各種原因前前后后花了將近三個月的時間,后續(xù)可以給大家分享軟著申請相關(guān)的內(nèi)容,希望可以幫助到需要的童鞋。
江湖不遠(yuǎn),我們游戲里見!
作者簡介:huangjianke,高級iOS開發(fā)/前端開發(fā)工程師,五年開發(fā)經(jīng)驗。
需要體驗小游戲的童鞋可在微信小程序搜索層疊拼圖Plus。
【END】
*請認(rèn)真填寫需求信息,我們會在24小時內(nèi)與您取得聯(lián)系。