發(fā)布時間:2023-11-27 11:24:00 瀏覽量:123次
就在不久前,創(chuàng)建和部署游戲的唯一方法是選擇像 Unity 或 Unreal 這樣的游戲引擎,學(xué)習(xí)語言,然后打包游戲并將其部署到你選擇的平臺上。
試圖通過瀏覽器向用戶提供游戲的想法似乎是一項不可能完成的任務(wù)。
幸運的是,由于瀏覽器技術(shù)的進步和硬件加速在所有流行的瀏覽器中都可用,JavaScript 性能的改進以及可用處理能力的穩(wěn)步提高,為瀏覽器創(chuàng)建交互式游戲體驗變得越來越普遍。
在本文中,我們將了解如何使用 Three.js 創(chuàng)建游戲。但首先,讓我們回顧一下 Three.js 是什么以及為什么它是游戲開發(fā)的好選擇。
Three.js 在 GitHub 上的項目描述恰當(dāng)?shù)貙?Three.js 描述為“......一個易于使用、輕量級、跨瀏覽器的通用 3D 庫”。
Three.js 讓我們作為開發(fā)人員可以相對簡單地在屏幕上繪制 3D 對象和模型。如果沒有它,我們將需要直接與 WebGL 交互,雖然這并非不可能,但即使是最小的游戲開發(fā)項目也會花費大量時間。
傳統(tǒng)上,“游戲引擎”由多個部分組成。例如,Unity 和 Unreal 提供了一種將對象渲染到屏幕上的方法,但也提供了大量其他功能,如網(wǎng)絡(luò)、物理等等。
然而,Three.js 的方法更受限制,不包括物理或網(wǎng)絡(luò)之類的東西。但是,這種更簡單的方法意味著它更容易學(xué)習(xí)和更優(yōu)化以做它最擅長的事情:將對象繪制到屏幕上。
它還有一組很棒的示例,我們可以使用它們來了解如何在屏幕上繪制各種對象。最后,它提供了一種簡單且原生的方式將我們的模型加載到我們的場景中。
如果不希望用戶需要通過應(yīng)用商店下載應(yīng)用或進行任何設(shè)置來玩你的游戲,那么Three.js 作為游戲開發(fā)引擎可能是一個有吸引力的選擇。如果你的游戲在瀏覽器中運行,那么進入門檻最低,這只能是一件好事。
今天,我們將通過制作一個使用著色器、模型、動畫和游戲邏輯的游戲來瀏覽 Three.js。我們將創(chuàng)建的內(nèi)容如下所示:
這個概念很簡單。我們控制著一艘火箭飛船,穿越一個星球,我們的目標(biāo)是拾取能量晶體。我們還需要通過增加護盾來管理飛船的健康狀況,并盡量不要因為撞擊場景中的巖石而嚴(yán)重?fù)p壞我們的船。
在我們的運行結(jié)束時,火箭飛船返回天空中的母艦,如果用戶點擊NEXT LEVEL,他們會再次嘗試,這一次火箭要經(jīng)過更長的路徑。
隨著用戶玩游戲,火箭飛船的速度會增加,因此他們必須更快地躲避巖石并收集能量晶體。
要創(chuàng)建這樣的游戲,我們必須回答以下問題:
到我們制作這款游戲?時,我們將克服這些挑戰(zhàn)。
不過,在我們開始編碼之前,我們必須回顧一些簡短的理論,特別是與我們將如何在游戲中創(chuàng)造運動感有關(guān)。
想象一下,你在現(xiàn)實生活中控制著一架直升機,并且正在跟蹤地面上的一個物體。物體以逐漸增加的速度繼續(xù)前進。為了跟上,你必須逐漸提高你所在直升機的速度。
如果對直升機或地面上的物體的速度沒有限制,只要你想跟上地面上的物體,這種情況就會持續(xù)下去。
當(dāng)創(chuàng)建一個跟隨對象的游戲時,正如我們在本例中所做的那樣,應(yīng)用相同的邏輯可能很誘人。也就是說,在世界空間中隨著物體的加速移動物體,并更新后面跟隨的相機的速度。然而,這提出了一個直接的問題。
基本上,每個玩這個游戲的人都會在他們的手機或臺式電腦上玩它。這些設(shè)備資源有限。如果我們嘗試在相機移動時生成可能無限數(shù)量的對象,然后移動該相機,最終我們將耗盡所有可用資源,并且瀏覽器選項卡將變得無響應(yīng)或崩潰。
我們還需要創(chuàng)建一個代表海洋的平面(一個平面 2D 對象)。當(dāng)我們這樣做時,我們必須給出海洋的尺寸。
然而,我們不能創(chuàng)建一個無限大的平面,我們也不能創(chuàng)建一個巨大的平面,只是希望用戶永遠不會在我們的關(guān)卡中前進到足以讓他們離開平面的程度。
那是糟糕的設(shè)計,并且希望人們玩我們的游戲不足以體驗錯誤似乎違反直覺。
我們不是在一個方向上無限期地移動我們的相機,而是讓相機保持靜止并移動它周圍的環(huán)境。這有幾個好處。
一是我們總是知道火箭飛船在哪里,因為火箭的位置不會移到遠處;它只會左右移動。這讓我們很容易判斷物體是否在相機后面,并且可以從場景中移除以釋放資源。
另一個好處是我們可以選擇遠處的一個點來創(chuàng)建對象。這意味著當(dāng)物體接近玩家時,新的物品或物體將不斷地在玩家視野之外的距離創(chuàng)建。
當(dāng)它們從視野中消失時,無論是玩家與它們發(fā)生碰撞還是從玩家身后消失,這些物品都會從場景中移除,以降低內(nèi)存使用量。
要創(chuàng)建這種效果,我們需要做兩件事:首先,我們需要在程序上沿深度軸移動每個項目,以將對象移向相機。其次,我們必須為我們的水面提供一個可以抵消的值,并隨著時間的推移增加這個偏移量。
這將產(chǎn)生水面移動越來越快的效果。
現(xiàn)在我們已經(jīng)解決了如何在場景中向前移動火箭,讓我們繼續(xù)設(shè)置我們的項目。
讓我們開始制作游戲吧!我們需要做的第一件事是設(shè)置構(gòu)建環(huán)境。對于這個例子,我選擇使用 Typescript 和 Webpack。這篇文章不是要討論這些技術(shù)的有點,所以除了快速總結(jié)之外,我不會在這里詳細(xì)介紹它們。
使用 Webpack 意味著當(dāng)我們開發(fā)項目并保存文件時,Webpack 將看到我們的文件已更改,并使用保存的更改自動重新加載瀏覽器。
這意味著我們無需在每次進行更改時手動刷新瀏覽器,從而節(jié)省大量時間。這也意味著我們可以使用像three-minifier這樣的插件,它可以在我們部署它時減小我們的包的大小。
在我們的示例中使用 TypeScript 意味著我們的項目將具有類型安全性。我發(fā)現(xiàn)這在使用 Three.js 的一些內(nèi)部類型時特別有用,比如Vector3s 和Quaternions. 知道我將正確類型的值分配給變量是非常有價值的。
我們還將在 UI 中使用Materialize CSS。對于我們將用作 UI 的幾個按鈕和卡片,這個 CSS 框架將有很大幫助。
要開始我們的項目,請創(chuàng)建一個新文件夾。在文件夾中,創(chuàng)建一個package.json并粘貼以下內(nèi)容:
{
"dependencies": {
"materialize-css": "^1.0.0",
"nipplejs": "^0.9.0",
"three": "^0.135.0"
},
"devDependencies": {
"@types/three": "^0.135.0",
"@yushijinhun/three-minifier-webpack": "^0.3.0",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^9.1.0",
"html-webpack-plugin": "^5.5.0",
"raw-loader": "^4.0.2",
"ts-loader": "^9.2.5",
"typescript": "^4.5.4",
"webpack": "^5.51.1",
"webpack-cli": "^4.8.0",
"webpack-dev-server": "^4.0.0",
"webpack-glsl-loader": "git+https://github.com/grieve/webpack-glsl-loader.git",
"webpack-merge": "^5.8.0"
},
"scripts": {
"dev": "webpack serve --config http://www.toutiao.com/a7113453607034520098/webpack.dev.js",
"build": "webpack --config http://www.toutiao.com/a7113453607034520098/webpack.production.js"
}
}
然后,在命令窗口中,鍵入npm i以將包安裝到新項目中。
我們現(xiàn)在需要創(chuàng)建三個文件,一個基本的 Webpack 配置文件,然后是我們項目的開發(fā)和生產(chǎn)配置文件。
在項目文件夾中創(chuàng)建一個webpack.common.js文件并粘貼以下配置:
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyPlugin = require("copy-webpack-plugin");
module.exports = {
plugins: [
// Automatically creat an index.html with the right bundle name and references to our javascript.
new HtmlWebpackPlugin({
template: 'html/index.html'
}),
// Copy game assets from our static directory, to the webpack output
new CopyPlugin({
patterns: [
{from: 'static', to: 'static'}
]
}),
],
// Entrypoint for our game
entry: 'http://www.toutiao.com/a7113453607034520098/game.ts',
module: {
rules: [
{
// Load our GLSL shaders in as text
test: /.(glsl|vs|fs|vert|frag)$/, exclude: /node_modules/, use: ['raw-loader']
},
{
// Process our typescript and use ts-loader to transpile it to Javascript
test: /.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
}
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
}
然后,創(chuàng)建一個webpack.dev.js文件并粘貼這些詳細(xì)信息。這配置了 Webpack 開發(fā)服務(wù)器的熱重載功能:
const { merge } = require('webpack-merge')
const common = require('http://www.toutiao.com/a7113453607034520098/webpack.common.js')
const path = require('path');
module.exports = merge(common, {
mode: 'development', // Don't minify the source
devtool: 'eval-source-map', // Source map for easier development
devServer: {
static: {
directory: path.join(__dirname, 'http://www.toutiao.com/a7113453607034520098/dist'), // Serve static files from here
},
hot: true, // Reload our page when the code changes
},
})
最后,創(chuàng)建一個webpack.production.js文件并粘貼這些詳細(xì)信息:
const { merge } = require('webpack-merge')
const common = require('http://www.toutiao.com/a7113453607034520098/webpack.common.js')
const path = require('path');
const ThreeMinifierPlugin = require("@yushijinhun/three-minifier-webpack");
const {CleanWebpackPlugin} = require("clean-webpack-plugin");
const threeMinifier = new ThreeMinifierPlugin();
module.exports = merge(common, {
plugins: [
threeMinifier, // Minifies our three.js code
new CleanWebpackPlugin() // Cleans our 'dist' folder between builds
],
resolve: {
plugins: [
threeMinifier.resolver,
]
},
mode: 'production', // Minify our output
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[fullhash:8].js', // Our output will have a unique hash, which will force our clients to download updates if they become available later
sourceMapFilename: '[name].[fullhash:8].map',
chunkFilename: '[id].[fullhash:8].js'
},
optimization: {
splitChunks: {
chunks: 'all', // Split our code into smaller chunks to assist caching for our clients
},
},
})
我們需要做的下一件事是配置 TypeScript 環(huán)境以允許我們使用來自 JavaScript 文件的導(dǎo)入。為此,請創(chuàng)建一個tsconfig.json文件并粘貼以下詳細(xì)信息:
{
"compilerOptions": {
"moduleResolution": "node",
"strict": true,
"allowJs": true,
"checkJs": false,
"target": "es2017",
"module": "commonjs"
},
"include": ["**/*.ts"]
}
我們的構(gòu)建環(huán)境現(xiàn)在已經(jīng)配置好了?,F(xiàn)在是時候開始為我們的玩家創(chuàng)造一個美麗而可信的場景了。
我們的場景包含以下元素:
我們將在一個名為 game.ts的文件中完成大部分工作,但我們也會將部分游戲拆分為單獨的文件,這樣我們就不會得到一個非常長的文件。我們現(xiàn)在可以繼續(xù)創(chuàng)建文件game.ts。
因為我們正在處理一個非常復(fù)雜的主題,所以我還將包含指向此代碼在 GitHub 上的項目中的位置的鏈接。這應(yīng)該有望幫助你保持自己的方向,而不是在更大的項目中迷失方向。
我們需要做的第一件事是創(chuàng)建一個Scene,以便 Three.js 有一些東西可以渲染。在我們的game.ts中,我們將添加以下行來構(gòu)建我們的并將 一個ScenePerspectiveCamera放置在場景中,這樣我們就可以看到發(fā)生了什么。
最后,我們將為稍后分配的渲染器創(chuàng)建一個引用:
export const scene = new Scene()
export const camera = new PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
2000
)
// Our three renderer
let renderer: WebGLRenderer;
為了設(shè)置我們的場景,我們需要執(zhí)行一些任務(wù),比如創(chuàng)建一個新的WebGLRenderer和設(shè)置我們想要繪制的畫布的大小。
為此,讓我們創(chuàng)建一個init函數(shù)并將其也放入我們的game.ts中。此init函數(shù)將為我們的場景執(zhí)行初始設(shè)置,并且只運行一次(當(dāng)游戲首次加載時):
/// Can be viewed here
async function init() {
renderer = new WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
}
我們還需要為場景利用渲染和動畫循環(huán)。我們需要動畫循環(huán)來根據(jù)需要在屏幕上移動對象,并且我們需要渲染循環(huán)來將新幀繪制到屏幕上。
讓我們繼續(xù)在game.ts中創(chuàng)建render函數(shù)。 一開始,這個函數(shù)看起來很簡單,因為它只是請求一個動畫幀然后渲染場景。
我們請求動畫幀的原因有很多,但其中一個主要原因是如果用戶更改選項卡,我們的游戲?qū)和#@將提高性能并減少設(shè)備上可能浪費的資源:
// Can be viewed here
const animate = () => {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
好了,現(xiàn)在我們有了一個空的場景,里面有一個相機,但沒有別的了。讓我們在場景中添加一些水。
幸運的是,Three.js 包含一個我們可以在場景中使用的水對象示例。它包括實時反射,看起來相當(dāng)不錯;你可以在這里查看。
對我們來說幸運的是,這種水將完成我們在場景中想要做的大部分事情。我們唯一需要做的就是稍微改變水的著色器,這樣我們就可以在渲染循環(huán)中更新它。
我們這樣做是因為如果隨著時間的推移,我們越來越多地抵消我們的水紋理,那么它會給我們帶來速度的感覺。
作為演示,下面就是我們游戲的開場場景,但我每幀都增加了偏移量。隨著偏移量的增加,感覺就像我們下方海洋的速度正在增加(即使火箭實際上是靜止的)。
水對象可以在 Three.js GitHub 上找到。我們唯一需要做的就是做一個小的改變,使這個偏移量可以從我們的渲染循環(huán)中控制(所以我們可以隨著時間的推移更新它)。
我們要做的第一件事是在 Three.js 存儲庫中獲取 Water.js 示例的副本。我們將把這個文件objects/water.js放在我們的項目中。如果我們打開water.js文件,大約一半,我們將開始看到如下所示的內(nèi)容:
這是海洋材質(zhì)的著色器。著色器本身的介紹超出了本文的范圍,但基本上,它們是我們的游戲?qū)⑾蛴脩舻挠嬎銠C提供的關(guān)于如何繪制此特定對象的說明。
這里還有我們的著色器代碼,它是用 OpenGraph 著色器語言 (GLSL) 編寫的,并合并到一個原本是 JavaScript 的文件中。
這沒有什么問題,但是如果我們將這個著色器代碼單獨移動到一個文件中,那么我們可以將 GLSL 支持安裝到我們選擇的 IDE 中,我們將獲得語法著色和驗證之類的東西,這有助于我們自定義 GLSL .
要將 GLSL 分解為單獨的文件,讓我們在當(dāng)前objects目錄中創(chuàng)建一個shader目錄,選擇 我們的vertexShader和 fragmentShader 的內(nèi)容, 并將它們分別移動到waterFragmentShader.glsl和waterVertexShader.glsl文件中。
在我們waterFragmentShader.glsl文件的頂部,我們有一個getNoise函數(shù)。默認(rèn)情況下,它看起來像這樣:
vec4 getNoise( vec2 uv ) {
vec2 uv0 = ( uv / 103.0 ) + vec2(time / 17.0, time / 29.0);
vec2 uv1 = uv / 107.0-vec2( time / -19.0, time / 31.0 );
vec2 uv2 = uv / vec2( 8907.0, 9803.0 ) + vec2( time / 101.0, time / 97.0 );
vec2 uv3 = uv / vec2( 1091.0, 1027.0 ) - vec2( time / 109.0, time / -113.0 );
vec4 noise = texture2D( normalSampler, uv0 ) +
texture2D( normalSampler, uv1 ) +
texture2D( normalSampler, uv2 ) +
texture2D( normalSampler, uv3 );
return noise * 0.5 - 1.0;
}
為了使這個偏移量可以從我們的游戲代碼中調(diào)整,我們想在我們的 GLSL 文件中添加一個參數(shù),允許我們在執(zhí)行期間對其進行修改。為此,我們必須將此函數(shù)替換為以下函數(shù):
// Can be viewed here
uniform float speed;
vec4 getNoise(vec2 uv) {
float offset;
if (speed == 0.0){
offset = time / 10.0;
}
else {
offset = speed;
}
vec2 uv3 = uv / vec2(50.0, 50.0) - vec2(speed / 1000.0, offset);
vec2 uv0 = vec2(0, 0);
vec2 uv1 = vec2(0, 0);
vec2 uv2 = vec2(0, 0);
vec4 noise = texture2D(normalSampler, uv0) +
texture2D(normalSampler, uv1) +
texture2D(normalSampler, uv2) +
texture2D(normalSampler, uv3);
return noise * 0.5 - 1.0;
}
你會注意到我們在這個 GLSL 文件中包含了一個新變量:speed變量。這是我們將更新以提供速度感的變量。
在我們的game.ts中,現(xiàn)在需要配置水的設(shè)置。在我們文件的頂部,添加以下變量:
// Can be viewed here
const waterGeometry = new PlaneGeometry(10000, 10000);
const water = new Water(
waterGeometry,
{
textureWidth: 512,
textureHeight: 512,
waterNormals: new TextureLoader().load('static/normals/waternormals.jpeg', function (texture) {
texture.wrapS = texture.wrapT = MirroredRepeatWrapping;
}),
sunDirection: new Vector3(),
sunColor: 0xffffff,
waterColor: 0x001e0f,
distortionScale: 3.7,
fog: scene.fog !== undefined
}
);
然后,在我們的init函數(shù)中,必須配置水平面的旋轉(zhuǎn)和位置,如下所示:
// Can be viewed here
// Water
water.rotation.x = -Math.PI / 2;
water.rotation.z = 0;
scene.add(water);
這將為海洋提供正確的旋轉(zhuǎn)。
Three.js 帶有一個相當(dāng)令人信服的天空,我們可以在項目中免費使用它。你可以在此處的 Three.js 示例頁面中查看此示例。
在我們的項目中添加天空非常容易;只需要將天空添加到場景中,設(shè)置天空盒的大小,然后設(shè)置一些參數(shù)來控制天空的外觀。
在我們聲明的init函數(shù)中,我們將天空添加到場景中并配置天空的視覺效果:
// Can be viewed here
const sky = new Sky();
sky.scale.setScalar(10000); // Specify the dimensions of the skybox
scene.add(sky); // Add the sky to our scene
// Set up variables to control the look of the sky
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 10;
skyUniforms['rayleigh'].value = 2;
skyUniforms['mieCoefficient'].value = 0.005;
skyUniforms['mieDirectionalG'].value = 0.8;
const parameters = {
elevation: 3,
azimuth: 115
};
const pmremGenerator = new PMREMGenerator(renderer);
const phi = MathUtils.degToRad(90 - parameters.elevation);
const theta = MathUtils.degToRad(parameters.azimuth);
sun.setFromSphericalCoords(1, phi, theta);
sky.material.uniforms['sunPosition'].value.copy(sun);
(water.material as ShaderMaterial).uniforms['sunDirection'].value.copy(sun).normalize();
scene.environment = pmremGenerator.fromScene(sky as any).texture;
(water.material as ShaderMaterial).uniforms['speed'].value = 0.0;
我們需要對初始場景初始化做的最后一件事是添加一些光照并添加我們的火箭模型和母艦?zāi)P停?/span>
// Can be viewed here
// Set the appropriate scale for our rocket
rocketModel.scale.set(0.3, 0.3, 0.3);
scene.add(rocketModel);
scene.add(mothershipModel);
// Set the scale and location for our mothership (above the player)
mothershipModel.position.y = 200;
mothershipModel.position.z = 100;
mothershipModel.scale.set(15,15,15);
sceneConfiguration.ready = true;
現(xiàn)在我們有了一些漂亮的水和火箭的場景。但是,我們?nèi)狈θ魏慰梢哉嬲顾蔀橛螒虻臇|西。為了解決這個問題,我們需要構(gòu)建一些基本參數(shù)來控制游戲并允許玩家朝著某些目標(biāo)前進。
在我們game.ts文件的頂部,我們將添加以下sceneConfiguration變量,這有助于我們跟蹤場景中的對象:
// Can be viewed here
export const sceneConfiguration = {
/// Whether the scene is ready (i.e.: All models have been loaded and can be used)
ready: false,
/// Whether the camera is moving from the beginning circular pattern to behind the ship
cameraMovingToStartPosition: false,
/// Whether the rocket is moving forward
rocketMoving: false,
// backgroundMoving: false,
/// Collected game data
data: {
/// How many crystals the player has collected on this run
crystalsCollected: 0,
/// How many shields the player has collected on this run (can be as low as -5 if player hits rocks)
shieldsCollected: 0,
},
/// The length of the current level, increases as levels go up
courseLength: 500,
/// How far the player is through the current level, initialises to zero.
courseProgress: 0,
/// Whether the level has finished
levelOver: false,
/// The current level, initialises to one.
level: 1,
/// Gives the completion amount of the course thus far, from 0.0 to 1.0.
coursePercentComplete: () => (sceneConfiguration.courseProgress / sceneConfiguration.courseLength),
/// Whether the start animation is playing (the circular camera movement while looking at the ship)
cameraStartAnimationPlaying: false,
/// How many 'background bits' are in the scene (the cliffs)
backgroundBitCount: 0,
/// How many 'challenge rows' are in the scene (the rows that have rocks, shields, or crystals in them).
challengeRowCount: 0,
/// The current speed of the ship
speed: 0.0
}
現(xiàn)在,我們必須為玩家所在的當(dāng)前關(guān)卡執(zhí)行初始化。這個場景設(shè)置函數(shù)很重要,因為每次用戶開始一個新的關(guān)卡時都會調(diào)用它。
因此,我們需要將火箭的位置設(shè)置回起點并清理所有正在使用的舊資產(chǎn)。我在代碼行內(nèi)添加了一些注釋,以便你可以看到每一行在做什么:
// Can be viewed here
export const sceneSetup = (level: number) => {
// Remove all references to old "challenge rows" and background bits
sceneConfiguration.challengeRowCount = 0;
sceneConfiguration.backgroundBitCount = 0;
// Reset the camera position back to slightly infront of the ship, for the start-up animation
camera.position.z = 50;
camera.position.y = 12;
camera.position.x = 15;
camera.rotation.y = 2.5;
// Add the starter bay to the scene (the sandy shore with the rocks around it)
scene.add(starterBay);
// Set the starter bay position to be close to the ship
starterBay.position.copy(new Vector3(10, 0, 120));
// Rotate the rocket model back to the correct orientation to play the level
rocketModel.rotation.x = Math.PI;
rocketModel.rotation.z = Math.PI;
// Set the location of the rocket model to be within the starter bay
rocketModel.position.z = 70;
rocketModel.position.y = 10;
rocketModel.position.x = 0;
// Remove any existing challenge rows from the scene
challengeRows.forEach(x => {
scene.remove(x.rowParent);
});
// Remove any existing environment bits from the scene
environmentBits.forEach(x => {
scene.remove(x);
})
// Setting the length of these arrays to zero clears the array of any values
environmentBits.length = 0;
challengeRows.length = 0;
// Render some challenge rows and background bits into the distance
for (let i = 0; i < 60; i++) {
// debugger;
addChallengeRow(sceneConfiguration.challengeRowCount++);
addBackgroundBit(sceneConfiguration.backgroundBitCount++);
}
//Set the variables back to their beginning state
// Indicates that the animation where the camera flies from the current position isn't playing
sceneConfiguration.cameraStartAnimationPlaying = false;
// The level isn't over (we just started it)
sceneConfiguration.levelOver = false;
// The rocket isn't flying away back to the mothership
rocketModel.userData.flyingAway = false;
// Resets the current progress of the course to 0, as we haven't yet started the level we're on
sceneConfiguration.courseProgress = 0;
// Sets the length of the course based on our current level
sceneConfiguration.courseLength = 1000 * level;
// Reset how many things we've collected in this level to zero
sceneConfiguration.data.shieldsCollected = 0;
sceneConfiguration.data.crystalsCollected = 0;
// Updates the UI to show how many things we've collected to zero.
crystalUiElement.innerText = String(sceneConfiguration.data.crystalsCollected);
shieldUiElement.innerText = String(sceneConfiguration.data.shieldsCollected);
// Sets the current level ID in the UI
document.getElementById('levelIndicator')!.innerText = `LEVEL ${sceneConfiguration.level}`;
// Indicates that the scene setup has completed, and the scene is now ready
sceneConfiguration.ready = true;
}
我們預(yù)計有兩種類型的設(shè)備可以玩我們的游戲:臺式電腦和手機。為此,我們需要適應(yīng)兩種類型的輸入選項:
現(xiàn)在讓我們配置這些。
在我們game.ts的開始,我們將添加以下變量來跟蹤鍵盤上是否按下了左鍵或右鍵:
let leftPressed = false;
let rightPressed = false;
然后,在我們的init函數(shù)中,我們將注冊keydownandkeyup事件來分別調(diào)用onKeyDownandonKeyUp函數(shù):
document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('keyup', onKeyUp, false);
最后,對于鍵盤輸入,我們將記錄按下這些鍵時要執(zhí)行的操作:
// Can be viewed here
function onKeyDown(event: KeyboardEvent) {
console.log('keypress');
let keyCode = event.which;
if (keyCode == 37) { // Left arrow key
leftPressed = true;
} else if (keyCode == 39) { // Right arrow key
rightPressed = true;
}
}
function onKeyUp(event: KeyboardEvent) {
let keyCode = event.which;
if (keyCode == 37) { // Left arrow key
leftPressed = false;
} else if (keyCode == 39) { // Right arrow key
rightPressed = false;
}
}
我們的移動用戶沒有鍵盤可以輸入,因此,我們將使用nippleJS在屏幕上創(chuàng)建一個操縱桿,并使用操縱桿的輸出來影響火箭在屏幕上的位置。
在我們的init函數(shù)中,我們將通過檢查它在屏幕上是否有非零數(shù)量的觸摸點來檢查設(shè)備是否是觸摸設(shè)備。如果是,我們將創(chuàng)建操縱桿,但一旦玩家釋放操縱桿的控制,我們還將將火箭的運動設(shè)置回零:
// Can be viewed here
if (isTouchDevice()) {
// Get the area within the UI to use as our joystick
let touchZone = document.getElementById('joystick-zone');
if (touchZone != null) {
// Create a Joystick Manager
joystickManager = joystick.create({zone: document.getElementById('joystick-zone')!,})
// Register what to do when the joystick moves
joystickManager.on("move", (event, data) => {
positionOffset = data.vector.x;
})
// When the joystick isn't being interacted with anymore, stop moving the rocket
joystickManager.on('end', (event, data) => {
positionOffset = 0.0;
})
}
}
在我們的animate函數(shù)中,我們會跟蹤此時按下左鍵或右鍵或操縱桿是否正在使用中的操作。我們還將火箭的位置夾在可接受的左右位置,這樣火箭就不能完全移出屏幕:
// Can be viewed here
// If the left arrow is pressed, move the rocket to the left
if (leftPressed) {
rocketModel.position.x -= 0.5;
}
// If the right arrow is pressed, move the rocket to the right
if (rightPressed) {
rocketModel.position.x += 0.5;
}
// If the joystick is in use, update the current location of the rocket accordingly
rocketModel.position.x += positionOffset;
// Clamp the final position of the rocket to an allowable region
rocketModel.position.x = clamp(rocketModel.position.x, -20, 25);
正如我們已經(jīng)討論過的,火箭飛船在我們的場景中保持靜止,并且物體朝它移動。這些物體移動的速度隨著用戶繼續(xù)玩而逐漸增加,隨著時間的推移增加了關(guān)卡的難度。
仍然在我們的動畫循環(huán)中,我們希望逐步將這些對象移向玩家。當(dāng)對象離開玩家的視野時,我們希望將它們從場景中移除,這樣我們就不會占用玩家計算機上不必要的資源。
在我們的渲染循環(huán)中,我們可以像這樣設(shè)置這個功能:
// Can be viewed here
if (sceneConfiguration.rocketMoving) {
// Detect if the rocket ship has collided with any of the objects within the scene
detectCollisions();
// Move the rocks towards the player
for (let i = 0; i < environmentBits.length; i++) {
let mesh = environmentBits[i];
mesh.position.z += sceneConfiguration.speed;
}
// Move the challenge rows towards the player
for (let i = 0; i < challengeRows.length; i++) {
challengeRows[i].rowParent.position.z += sceneConfiguration.speed;
// challengeRows[i].rowObjects.forEach(x => {
// x.position.z += speed;
// })
}
// If the furtherest rock is less than a certain distance, create a new one on the horizon
if ((!environmentBits.length || environmentBits[0].position.z > -1300) && !sceneConfiguration.levelOver) {
addBackgroundBit(sceneConfiguration.backgroundBitCount++, true);
}
// If the furtherest challenge row is less than a certain distance, create a new one on the horizon
if ((!challengeRows.length || challengeRows[0].rowParent.position.z > -1300) && !sceneConfiguration.levelOver) {
addChallengeRow(sceneConfiguration.challengeRowCount++, true);
}
// If the starter bay hasn't already been removed from the scene, move it towards the player
if (starterBay != null) {
starterBay.position.z += sceneConfiguration.speed;
}
// If the starter bay is outside of the players' field of view, remove it from the scene
if (starterBay.position.z > 200) {
scene.remove(starterBay);
}
我們可以看到有幾個函數(shù)是這個調(diào)用的一部分:
讓我們探索一下這些函數(shù)在我們的游戲中完成了什么。
碰撞檢測是我們游戲的重要途徑。沒有它,我們將不知道我們的火箭飛船是否達到了任何目標(biāo),或者它是否撞到了巖石并應(yīng)該減速。這就是我們想要在游戲中使用碰撞檢測的原因。
通常,我們可以使用物理引擎來檢測場景中對象之間的碰撞,但是 Three.js 沒有包含物理引擎。
不過,這并不是說 Three.js 不存在物理引擎。他們當(dāng)然可以,但是為了我們的需要,我們不需要添加物理引擎來檢查我們的火箭是否擊中了另一個物體。
本質(zhì)上,我們想回答這個問題,“我的火箭模型目前是否與屏幕上的任何其他模型相交?” 我們還需要根據(jù)受到的打擊以某些方式做出反應(yīng)。
例如,如果我們的玩家不斷將火箭撞到巖石上,我們需要在受到一定程度的傷害后結(jié)束關(guān)卡。
為了實現(xiàn)這一點,讓我們創(chuàng)建一個函數(shù)來檢查我們的火箭和場景中的對象的交集。根據(jù)玩家擊中的內(nèi)容,我們會做出相應(yīng)的反應(yīng)。
我們將把這段代碼放在我們的game目錄中的一個collisionDetection.ts文件中:
// Can be viewed here
export const detectCollisions = () => {
// If the level is over, don't detect collisions
if (sceneConfiguration.levelOver) return;
// Using the dimensions of our rocket, create a box that is the width and height of our model
// This box doesn't appear in the world, it's merely a set of coordinates that describe the box
// in world space.
const rocketBox = new Box3().setFromObject(rocketModel);
// For every challange row that we have on the screen...
challengeRows.forEach(x => {
// ...update the global position matrix of the row, and its children.
x.rowParent.updateMatrixWorld();
// Next, for each object within each challenge row...
x.rowParent.children.forEach(y => {
y.children.forEach(z => {
// ...create a box that is the width and height of the object
const box = new Box3().setFromObject(z);
// Check if the box with the obstacle overlaps (or intersects with) our rocket
if (box.intersectsBox(rocketBox)) {
// If it does, get the center position of that box
let destructionPosition = box.getCenter(z.position);
// Queue up the destruction animation to play (the boxes flying out from the rocket)
playDestructionAnimation(destructionPosition);
// Remove the object that has been hit from the parent
// This removes the object from the scene
y.remove(z);
// Now, we check what it was that we hit, whether it was a rock, shield, or crystal
if (y.userData.objectType !== undefined) {
let type = y.userData.objectType as ObjectType;
switch (type) {
// If it was a rock...
case ObjectType.ROCK:
// ...remove one shield from the players' score
sceneConfiguration.data.shieldsCollected--;
// Update the UI with the new count of shields
shieldUiElement.innerText = String(sceneConfiguration.data.shieldsCollected);
// If the player has less than 0 shields...
if (sceneConfiguration.data.shieldsCollected <= 0) {
// ...add the 'danger' CSS class to make the text red (if it's not already there)
if (!shieldUiElement.classList.contains('danger')) {
shieldUiElement.classList.add('danger');
}
} else { //Otherwise, if it's more than 0 shields, remove the danger CSS class
// so the text goes back to being white
shieldUiElement.classList.remove('danger');
}
// If the ship has sustained too much damage, and has less than -5 shields...
if (sceneConfiguration.data.shieldsCollected <= -5) {
// ...end the scene
endLevel(true);
}
break;
// If it's a crystal...
case ObjectType.CRYSTAL:
// Update the UI with the new count of crystals, and increment the count of
// currently collected crystals
crystalUiElement.innerText = String(++sceneConfiguration.data.crystalsCollected);
break;
// If it's a shield...
case ObjectType.SHIELD_ITEM:
// Update the UI with the new count of shields, and increment the count of
// currently collected shields
shieldUiElement.innerText = String(++sceneConfiguration.data.shieldsCollected);
break;
}
}
}
});
})
});
}
對于碰撞檢測,我們唯一需要做的另一件事是添加一個短動畫,當(dāng)用戶與對象碰撞時播放該動畫。此函數(shù)將獲取發(fā)生碰撞的位置并從該原點生成一些框。
完成的結(jié)果將如下所示。
為了實現(xiàn)這一點,我們必須在碰撞發(fā)生的地方創(chuàng)建一個圓圈中的盒子,并將它們向外設(shè)置動畫,這樣看起來它們就像從碰撞中爆炸一樣。為此,讓我們在collisionDetection.ts文件中添加此功能:
// Can be viewed here
const playDestructionAnimation = (spawnPosition: Vector3) => {
// Create six boxes
for (let i = 0; i < 6; i++) {
// Our destruction 'bits' will be black, but have some transparency to them
let destructionBit = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial({
color: 'black',
transparent: true,
opacity: 0.4
}));
// Each destruction bit object within the scene will have a 'lifetime' property associated to it
// This property is incremented every time a frame is drawn to the screen
// Within our animate loop, we check if this is more than 500, and if it is, we remove the object
destructionBit.userData.lifetime = 0;
// Set the spawn position of the box
destructionBit.position.set(spawnPosition.x, spawnPosition.y, spawnPosition.z);
// Create an animation mixer for the object
destructionBit.userData.mixer = new AnimationMixer(destructionBit);
// Spawn the objects in a circle around the rocket
let degrees = i / 45;
// Work out where on the circle we should spawn this specific destruction bit
let spawnX = Math.cos(radToDeg(degrees)) * 15;
let spawnY = Math.sin(radToDeg(degrees)) * 15;
// Create a VectorKeyFrameTrack that will animate this box from its starting position to the final
// 'outward' position (so it looks like the boxes are exploding from the ship)
let track = new VectorKeyframeTrack('.position', [0, 0.3], [
rocketModel.position.x, // x 1
rocketModel.position.y, // y 1
rocketModel.position.z, // z 1
rocketModel.position.x + spawnX, // x 2
rocketModel.position.y, // y 2
rocketModel.position.z + spawnY, // z 2
]);
// Create an animation clip with our VectorKeyFrameTrack
const animationClip = new AnimationClip('animateIn', 10, [track]);
const animationAction = destructionBit.userData.mixer.clipAction(animationClip);
// Only play the animation once
animationAction.setLoop(LoopOnce, 1);
// When complete, leave the objects in their final position (don't reset them to the starting position)
animationAction.clampWhenFinished = true;
// Play the animation
animationAction.play();
// Associate a Clock to the destruction bit. We use this within the render loop so ThreeJS knows how far
// to move this object for this frame
destructionBit.userData.clock = new Clock();
// Add the destruction bit to the scene
scene.add(destructionBit);
// Add the destruction bit to an array, to keep track of them
destructionBits.push(destructionBit);
}
這就是我們整理出來的碰撞檢測,當(dāng)物體被破壞時會有一個漂亮的動畫。
隨著場景的進行,我們希望在玩家的兩側(cè)添加一些懸崖,這樣感覺就像他們的運動在某個空間內(nèi)得到了適當(dāng)?shù)南拗?。我們使用模運算符在程序上將巖石添加到用戶的右側(cè)或左側(cè):
// Can be viewed here
export const addBackgroundBit = (count: number, horizonSpawn: boolean = false) => {
// If we're spawning on the horizon, always spawn at a position far away from the player
// Otherwise, place the rocks at certain intervals into the distance-
let zOffset = (horizonSpawn ? -1400 : -(60 * count));
// Create a copy of our original rock model
let thisRock = cliffsModel.clone();
// Set the scale appropriately for the scene
thisRock.scale.set(0.02, 0.02, 0.02);
// If the row that we're adding is divisble by two, place the rock to the left of the user
// otherwise, place it to the right of the user.
thisRock.position.set(count % 2 == 0 ? 60 - Math.random() : -60 - Math.random(), 0, zOffset);
// Rotate the rock to a better angle
thisRock.rotation.set(MathUtils.degToRad(-90), 0, Math.random());
// Finally, add the rock to the scene
scene.add(thisRock);
// Add the rock to the beginning of the environmentBits array to keep track of them (so we can clean up later)
environmentBits.unshift(thisRock);// add to beginning of array
}
隨著場景的進行,我們還希望將“挑戰(zhàn)行”添加到場景中。這些是包含巖石、水晶或盾牌物品的物體。每次創(chuàng)建這些新行中的一個時,我們都會為每一行隨機分配巖石、水晶和盾牌。
因此,在上面的示例中,單元格 1、2 和 4 沒有添加任何內(nèi)容,而單元格 3 和 5 分別添加了水晶和盾牌項目。
為了實現(xiàn)這一點,我們將這些挑戰(zhàn)行分為五個不同的單元格。我們根據(jù)隨機函數(shù)的輸出在每個單元格中生成某個項目,如下所示:
// Can be viewed here
export const addChallengeRow = (count: number, horizonSpawn: boolean = false) => {
// Work out how far away this challenge row should be
let zOffset = (horizonSpawn ? -1400 : -(count * 60));
// Create a Group for the objects. This will be the parent for these objects.
let rowGroup = new Group();
rowGroup.position.z = zOffset;
for (let i = 0; i < 5; i++) {
// Calculate a random number between 1 and 10
const random = Math.random() * 10;
// If it's less than 2, create a crystal
if (random < 2) {
let crystal = addCrystal(i);
rowGroup.add(crystal);
}
// If it's less than 4, spawn a rock
else if (random < 4) {
let rock = addRock(i);
rowGroup.add(rock);
}
// but if it's more than 9, spawn a shield
else if (random > 9) {
let shield = addShield(i);
rowGroup.add(shield);
}
}
// Add the row to the challengeRows array to keep track of it, and so we can clean them up later
challengeRows.unshift({rowParent: rowGroup, index: sceneConfiguration.challengeRowCount++});
// Finally add the row to the scene
scene.add(rowGroup);
}
可以在這些鏈接中的任何一個查看巖石、水晶和盾牌創(chuàng)建功能。
我們需要在渲染循環(huán)中完成的最后一件事是:
在我們的渲染函數(shù)結(jié)束時,我們可以添加以下代碼來適應(yīng)這個功能:
// Can be viewed here
// Call the function to relocate the current bits on the screen and move them towards the rocket
// so it looks like the rocket is collecting them
moveCollectedBits();
// If the rockets progress equals the length of the course...
if (sceneConfiguration.courseProgress >= sceneConfiguration.courseLength) {
// ...check that we haven't already started the level-end process
if (!rocketModel.userData.flyingAway) {
// ...and end the level
endLevel(false);
}
}
// If the level end-scene is playing...
if (rocketModel.userData.flyingAway) {
// Rotate the camera to look at the rocket on it's return journey to the mothership
camera.lookAt(rocketModel.position);
}
這就是我們的渲染循環(huán)完成了。
當(dāng)人們加載我們的游戲時,他們會看到一些讓他們能夠開始玩的按鈕。
這些只是簡單的 HTML 元素,我們根據(jù)游戲中發(fā)生的情況以編程方式顯示或隱藏它們。問題圖標(biāo)讓玩家對游戲的內(nèi)容有所了解,并包含有關(guān)如何玩游戲的說明。它還包括我們模型的(非常重要的?。┰S可證。
并且,按下紅色按鈕開始游戲。請注意,當(dāng)我們點擊紅色的“播放”按鈕時,攝像機會移動并旋轉(zhuǎn)到火箭后面,讓玩家準(zhǔn)備好開始場景。
在我們的場景init函數(shù)中,我們將要執(zhí)行此操作的事件注冊到此按鈕的onClick處理程序。要創(chuàng)建旋轉(zhuǎn)和移動功能,我們需要執(zhí)行以下操作:
為此,我們將在init函數(shù)中添加以下代碼,如下所示:
// Can be viewed here
startGameButton.onclick = (event) => {
// Indicate that the animation from the camera starting position to the rocket location is running
sceneConfiguration.cameraStartAnimationPlaying = true;
// Remove the red text on the shield item, if it existed from the last level
shieldUiElement.classList.remove('danger');
// Show the heads up display (that shows crystals collected, etc)
document.getElementById('headsUpDisplay')!.classList.remove('hidden');
// Create an animation mixer on the rocket model
camera.userData.mixer = new AnimationMixer(camera);
// Create an animation from the cameras' current position to behind the rocket
let track = new VectorKeyframeTrack('.position', [0, 2], [
camera.position.x, // x 1
camera.position.y, // y 1
camera.position.z, // z 1
0, // x 2
30, // y 2
100, // z 2
], InterpolateSmooth);
// Create a Quaternion rotation for the "forwards" position on the camera
let identityRotation = new Quaternion().setFromAxisAngle(new Vector3(-1, 0, 0), .3);
// Create an animation clip that begins with the cameras' current rotation, and ends on the camera being
// rotated towards the game space
let rotationClip = new QuaternionKeyframeTrack('.quaternion', [0, 2], [
camera.quaternion.x, camera.quaternion.y, camera.quaternion.z, camera.quaternion.w,
identityRotation.x, identityRotation.y, identityRotation.z, identityRotation.w
]);
// Associate both KeyFrameTracks to an AnimationClip, so they both play at the same time
const animationClip = new AnimationClip('animateIn', 4, [track, rotationClip]);
const animationAction = camera.userData.mixer.clipAction(animationClip);
animationAction.setLoop(LoopOnce, 1);
animationAction.clampWhenFinished = true;
camera.userData.clock = new Clock();
camera.userData.mixer.addEventListener('finished', function () {
// Make sure the camera is facing in the right direction
camera.lookAt(new Vector3(0, -500, -1400));
// Indicate that the rocket has begun moving
sceneConfiguration.rocketMoving = true;
});
// Play the animation
camera.userData.mixer.clipAction(animationClip).play();
// Remove the "start panel" (containing the play buttons) from view
startPanel.classList.add('hidden');
}
當(dāng)我們的關(guān)卡結(jié)束時,我們還必須連接我們的邏輯,并且可以在此處查看執(zhí)行此操作的代碼。
在 Three.js 中創(chuàng)建游戲可以讓你接觸到數(shù)量驚人的潛在客戶。由于人們可以在瀏覽器中玩游戲而無需下載或安裝到他們的設(shè)備上,因此它成為開發(fā)和分發(fā)游戲的一種非常有吸引力的方式。
正如我們所見,為廣泛的用戶創(chuàng)造一種引人入勝且有趣的體驗是非常有可能的。所以,唯一需要解決的是,你將在 Three.js 中創(chuàng)建什么?
原文鏈接:
http://www.bimant.com/blog/threejs-game-dev-tutorial/
熱門資訊
探討游戲引擎的文章,介紹了10款游戲引擎及其代表作品,涵蓋了RAGE Engine、Naughty Dog Game Engine、The Dead Engine、Cry Engine、Avalanche Engine、Anvil Engine、IW Engine、Frostbite Engine、Creation引擎、Unreal Engine等引擎。借此分析引出了游戲設(shè)計領(lǐng)域和數(shù)字藝術(shù)教育的重要性,歡迎點擊咨詢報名。
2. 手機游戲如何開發(fā)(如何制作傳奇手游,都需要準(zhǔn)備些什么?)
?如何制作傳奇手游,都需要準(zhǔn)備些什么?提到傳奇手游相信大家都不陌生,他是許多80、90后的回憶;從起初的端游到現(xiàn)在的手游,說明時代在進步游戲在更新,更趨于方便化移動化。而如果我們想要制作一款傳奇手游的
3. B站視頻剪輯軟件「必剪」:免費、炫酷特效,小白必備工具
B站視頻剪輯軟件「必剪」,完全免費、一鍵制作炫酷特效,適合新手小白??靵碓囋?!
游戲中玩家將面臨武俠人生的掙扎抉擇,戰(zhàn)或降?殺或放?每個抉定都將觸發(fā)更多愛恨糾葛的精彩奇遇?!短烀嬗肪哂卸嗑€劇情多結(jié)局,不限主線發(fā)展,高自由...
5. Bigtime加密游戲經(jīng)濟體系揭秘,不同玩家角色的經(jīng)濟活動
Bigtime加密游戲經(jīng)濟模型分析,探討游戲經(jīng)濟特點,幫助玩家更全面了解這款GameFi產(chǎn)品。
6. 3D動漫建模全過程,不是一般人能學(xué)的會的,會的多不是人?
步驟01:面部,頸部,身體在一起這次我不準(zhǔn)備設(shè)計圖片,我從雕刻進入。這一次,它將是一種純粹關(guān)注建模而非整體繪畫的形式。像往常一樣,我從Sphere創(chuàng)建它...
7. 3D動畫軟件你知道幾個?3ds Max、Blender、Maya、Houdini大比拼
當(dāng)提到3D動畫軟件或動畫工具時,指的是數(shù)字內(nèi)容創(chuàng)建工具。它是用于造型、建模以及繪制3D美術(shù)動畫的軟件程序。但是,在3D動畫軟件中還包含了其他類型的...
?三昧動漫對于著名ARPG游戲《巫師》系列,最近CD Projekt 的高層回應(yīng)并不會推出《巫師4》。因為《巫師》系列在策劃的時候一直定位在“三部曲”的故事框架,所以在游戲的出品上不可能出現(xiàn)《巫師4》
9. 3D打印技巧揭秘!Cura設(shè)置讓你的模型更堅固
想讓你的3D打印模型更堅固?不妨嘗試一下Cura參數(shù)設(shè)置和設(shè)計技巧,讓你輕松掌握!
10. Unity3D入門:手把手帶你開發(fā)一款坦克大戰(zhàn)的游戲
Unity工程創(chuàng)建完成后如圖所示: 接下來應(yīng)該導(dǎo)入此項目所需的Unity Package文件,要用到的Unity package文件大家可以去Unity3D的官方網(wǎng)站下載(地址:ht...
最新文章
同學(xué)您好!