这是我的“开发街头篮球游戏”三部曲的最后一部分。如果你还没有检查Part I: Getting workflow ready 和Part ll: Throw a ball into a basket—请首先查看它们。
今天,我将告诉你如何制作新关卡,创建一个关卡选择菜单,并显示玩家的统计数据。
首先,请注意我们在app.js中添加了两个新文件:levelData.js和utils/textures.js。
级别数据将返回一个包含级别对象的数组,这些对象是用不同于默认值的配置填充的JavaScript对象。
utils/textures返回从输入数据生成纹理的函数。它将被用来在我们的关卡菜单中打印统计数据和关卡项目。
import levelData from './levelData';
import TexUtils from './utils/textures';
import EVENTS from './events';
import {checkForLevel, loop_raycaster, keep_ball} from './loops';
const APP = {
// APP: config. <-
// APP: variables. <-
/* === APP: init === */
init() {
// APP.world, ...
// Add raycaster variable
APP.raycaster = new THREE.Raycaster();
// camera, ProgressLoader, constructing scene (createScene(), ...), keep_ball, world.start(), ...
APP.initMenu(); // 6
// When app is loading...
APP.ProgressLoader.on('step', () => {
const hh = APP.ProgressLoader.getPercent();
TweenLite.to(document.querySelector('#loader0'), 2, {
css: {
height: (100 - hh) + '%'
},
ease: Power2.easeInOut
});
});
// ... app loaded.
APP.ProgressLoader.on('complete', () => {
setTimeout(() => {
document.querySelector('.loader').className += ' loaded';
setTimeout(() => {
document.querySelector('.loader').style.display = 'none';
APP.onLevelStart();
}, 2000);
}, 2000);
});
},
我们需要做的另一件事是添加一个raycaster变量。这个东西用于检查从2D鼠标位置生成的3D矢量是否与其他场景对象相交。
最后我们的init()的21行功能,我们增加了检查应用程序加载状态。我不会描述这一部分,因为它与我们的主应用程序无关,您可能会跳过或重写您的应用程序中的这一部分。请记住,我们在这里使用事件侦听器来做各种事情,这些事情将对应用程序预加载器产生影响。
顺便说一下,让我们定义通过主菜单访问的游戏的三个部分:
让我们为镜头、进球统计和游戏标题做一个很好的过渡。
首先,我们需要定义一个变量(下面代码中的比率),它将存储窗口宽度和窗口高度之间的关系。三。透视相机已经有两种方法可以给我们宽度和高度值-->。getFilmWidth()和。getFilmHeight()
请注意APP.camera是一个白色风暴。js相机包装,让它的三个。js相机使用APP.camera.getNative()
让我们为这个游戏添加一个标题。为此,我们需要生成一个字体文件...我用过typeface.js generator这样做。我把我的文件命名为1.js,你可以随意命名。请注意,在我们的字体参数中,我们键入的是字体网址,而不是像三个。
对于材质,我使用了便利的纹理WHS.texture功能。“重复”参数自动应用三。重复播放至包装和包装质地。有关更多信息,请查看此example in the three.js docs。
出于性能原因,此文本仅适用于台式机。
为了使文本居中对齐,我们需要计算它的宽度,除以2,然后从文本的X位置减去这个值(如果我们想让它以X轴为中心)。我们可以通过减去边界框的最大值和最小值来找到文本网格的宽度。
在下图中,您可以看到我们是如何做到的。蓝线是屏幕的中心。
为了显示目标细节,我们需要制作一个2D文本。最简单的方法是创建一个平面,并使其纹理文本。为了做到这一点,我们需要创建一个2000 x 1000的2D画布(这些值将只用于尺寸,唯一需要保持不变的是比例。你可以把它做成1000 x 500等。(
然后我们创建一个img元素并将从画布元素导出的base64应用到img科学研究委员会。
最后一步是做一个三。此图像的纹理。这可以简单地通过将图像作为参数传递来实现:新三。纹理(图像)
这个文件是utils/textures.js:
export default {
generateMenuTexture(menu) {
/* CONFIG */
const leftPadding = 1700;
/* CANVAS */
const canvas = document.createElement('canvas');
canvas.width = 2000;
canvas.height = 1000;
const context = canvas.getContext('2d');
context.font = "Bold 100px Richardson";
context.fillStyle = "#2D3134";
context.fillText("Time", 0, 150);
context.fillText(menu.time.toFixed() + 's.', leftPadding, 150);
context.fillText("Attempts", 0, 300);
context.fillText(menu.attempts.toFixed(), leftPadding, 300);
context.fillText("Accuracy", 0, 450);
context.fillText(menu.accuracy.toFixed(), leftPadding, 450);
context.font = "Normal 200px FNL";
context.textAlign = "center";
context.fillText(menu.markText, 1000, 800);
const image = document.createElement('img');
image.src = canvas.toDataURL();
const texture = new THREE.Texture(image);
texture.needsUpdate = true;
return texture;
},
// ...TODO
};
飞机的大小将取决于我们使用的设备的比例。如果比值小于0.7,平面将为150×75,如果大于0.7–200×100。
让我们总结一下以上几点:
const APP = {
// APP: config. <-
// APP: variables. <-
// APP: init. <-
// APP: createScene. <-
// APP: addLights. <-
// APP: addBasket. <-
// APP: addBall. <-
// APP: initEvents. <-
// APP: updateCoords. <-
// APP: checkKeys. <-
// APP: detectDoubleTap. <-
// APP: throwBall. <-
// APP: keepBall. <-
initMenu() {
const ratio = APP.camera.getNative().getFilmWidth() / APP.camera.getNative().getFilmHeight();
if (!APP.isMobile) {
APP.text = new WHS.Text({
geometry: {
text: "Street Basketball",
parameters: {
size: 10,
font: "fonts/1.js",
height: 4
}
},
shadow: {
cast: false,
receive: false
},
physics: false,
mass: 0,
material: {
kind: "phong",
color: 0xffffff,
map: WHS.texture('textures/text.jpg', {repeat: {x: 0.005, y: 0.005}})
},
pos: {
y: 120,
z: -40
},
rot: {
x: -Math.PI / 3
}
});
APP.text.addTo(APP.world).then(() => {
APP.text.getNative().geometry.computeBoundingBox();
APP.text.position.x = -0.5 * (APP.text.getNative().geometry.boundingBox.max.x - APP.text.getNative().geometry.boundingBox.min.x);
APP.ProgressLoader.step();
});
}
APP.menuDataPlane = new WHS.Plane({ // There we show stats.
geometry: {
width: ratio < 0.7 ? 150 : 200,
height: ratio < 0.7 ? 75 : 100
},
material: {
kind: 'phong',
transparent: true,
opacity: 0,
fog: false,
shininess: 900,
reflectivity: 0.5,
map: TexUtils.generateMenuTexture(APP.menu)
},
physics: false,
rot: {
x: -Math.PI / 2
},
pos: {
y: -19.5,
z: -20
}
});
APP.menuDataPlane.addTo(APP.world).then(() => {APP.ProgressLoader.step()});
APP.selectLevelHelper = new WHS.Plane({
geometry: {
width: 50,
height: 50
},
material: {
kind: 'basic',
transparent: true,
fog: false,
map: WHS.texture('textures/select-level.png')
},
physics: false,
rot: {
x: -Math.PI / 2
},
pos: {
y: -19.5,
z: 90
}
});
APP.selectLevelHelper.addTo(APP.world);
if (!APP.isMobile) {
APP.MenuLight = new WHS.SpotLight({
light: {
distance: 100,
intensity: 3
},
shadowmap: {
cast: false
},
pos: {
y: 200,
z: -30
},
target: {
y: 120,
z: -40
}
});
}
APP.LevelLight1 = new WHS.SpotLight({
light: {
distance: 800,
intensity: 0,
angle: Math.PI / 7
},
shadowmap: {
cast: false
},
pos: {
y: 10,
x: 500,
z: 100
},
target: {
z: 500,
x: -200
}
});
APP.LevelLight2 = APP.LevelLight1.clone();
APP.LevelLight2.position.x = -500;
APP.LevelLight2.target.x = 200;
if (!APP.isMobile) APP.MenuLight.addTo(APP.world).then(() => {APP.ProgressLoader.step()});
APP.LevelLight1.addTo(APP.world).then(() => {APP.ProgressLoader.step()});
APP.LevelLight2.addTo(APP.world).then(() => {APP.ProgressLoader.step()});
APP.loop_raycaster = loop_raycaster(APP);
APP.world.addLoop(APP.loop_raycaster);
},
// ...TODO
};
这个循环用于从一个3D球制作一个光标。首先,我们需要隐藏一个默认的光标,它可以很容易地用CSS实现:
body {
cursor: none; /* Disable cursor */
}
然后我们应该让我们的球跟随隐藏的光标。为此,我们需要两个3D点:
第二个我们已经有了,但是第一个应该在哪里?要找到它,我们应该使用THREE.Raycaster。我们使用当前光标的X和Y来获得投影光线与“光线投射平面”相交的点。让我们稍微修改一下createScene():
const APP = {
// APP: config. <-
// APP: init. <-
// APP: variables. <-
createScene() {
// APP.ground ...
// APP.wall ...
APP.planeForRaycasting = new THREE.Plane(new THREE.Vector3(0, 1, 0), -APP.ground.position.y - APP.ballRadius);
}
// ...
}
广播应该是一个Math Plane,而不是平面几何。在下图中,您可以看到该平面突出显示为蓝色:
export const loop_raycaster = (APP) => {
const cameraNative = APP.camera.getNative();
const raycaster = APP.raycaster;
const ray = APP.raycaster.ray;
const plane = APP.planeForRaycasting;
return new WHS.Loop(() => {
raycaster.setFromCamera(
new THREE.Vector2(
(APP.cursor.x / window.innerWidth) * 2 - 1,
-(APP.cursor.y / window.innerHeight) * 2 + 1
),
cameraNative
);
const bPos = APP.ball.position;
const raycastPoint = ray.at(ray.distanceToPlane(plane));
if (!APP.levelMenuTriggered && APP.animComplete && bPos.z > 60) APP.triggerLevelMenu();
if (APP.levelMenuTriggered && APP.animComplete && bPos.z < 170) APP.goBackToLevel();
APP.ball.setLinearVelocity(raycastPoint.sub(bPos).multiplyScalar(2));
});
}
// LOOP: keep_ball
稍后,我们将创建一个级别菜单部分,我们将讨论触发级别菜单()和返回级别()。
有些事情可以在应用程序加载后完成。我又创建了一个初始化…:初始化菜单()
…但是initMenu()和initlevelmonline()的区别在于,第一个是在应用程序启动前调用的,第二个是在我们进球后调用的。当然,没有必要做这样的事情,但这取决于你。
那么,我们在这部分准备什么?
在此图像中,您可以看到球(光标)位于标高平面(标高3)上。
水平指示器是球中心的一个白色小球。
LI进度是围绕水平指示器的圆环。
const APP = {
// APP: config. <-
// APP: variables. <-
// APP: init. <-
// APP: createScene. <-
// APP: addLights. <-
// APP: addBasket. <-
// APP: addBall. <-
// APP: initMenu. <-
initLevelMenu() {
APP.menu.enabled = true;
const ratio = APP.camera.getNative().getFilmWidth() / APP.camera.getNative().getFilmHeight();
let levelXstartOffset = -225;
let levelZstartOffset = 200;
let cols = 4;
if (ratio < 0.7) {
cols = 1;
levelXstartOffset = -90;
} else if (ratio < 1) {
cols = 2;
levelXstartOffset = -135
} else if (ratio < 1.3) {
cols = 3;
levelXstartOffset = -180;
} else {
cols = 4;
levelXstartOffset = -225;
}
let rows = Math.ceil(levelData.length / cols);
let levelXoffset = levelXstartOffset;
let levelZoffset = levelZstartOffset;
const levelPlane = new WHS.Plane({
geometry: {
height: 40,
width: 80
},
physics: false,
material: {
kind: 'phong'
},
pos: {
y: -19,
x: levelXoffset
},
rot: {
x: -Math.PI / 2
}
});
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const i = r * cols + c;
console.log(i);
if (levelData[i]) {
const newLevelPlane = levelPlane.clone();
levelXoffset += 90;
newLevelPlane.position.z = levelZoffset;
newLevelPlane.position.x = levelXoffset;
newLevelPlane.M_({
map: TexUtils.generateLevelTexture(levelData[i])
});
newLevelPlane.getNative().data = levelData[i];
newLevelPlane.addTo(APP.world);
APP.levelPlanes.push(newLevelPlane.getNative());
}
}
levelZoffset += 60;
levelXoffset = levelXstartOffset;
}
APP.levelIndicator = new WHS.Sphere({
geometry: {
radius: 1,
widthSegments: 16,
heightSegments: 16
},
physics: false,
material: {
kind: 'basic',
color: 0xffffff
}
});
APP.levelIndicator.hide();
APP.levelIndicator.addTo(APP.world);
APP.liProgress = new WHS.Torus({
geometry: {
radius: 3,
tube: 0.5,
radialSegments: 16,
tubularSegments: 16,
arc: 0
},
physics: false,
material: {
kind: 'basic',
color: 0xffffff
},
rot: {
x: Math.PI / 2,
z: Math.PI / 2
}
});
APP.liProgress.addTo(APP.levelIndicator);
APP.liProgress.data_arc = 0;
APP.checkForLevel = checkForLevel(APP);
APP.world.addLoop(APP.checkForLevel);
APP.checkForLevel.start();
}
};
此外,我们还需要根据高程数据制作2D纹理:
export default {
// generateMenuTexture. <-
generateLevelTexture(levelData) {
/* CANVAS */
const canvas = document.createElement('canvas');
canvas.width = 160;
canvas.height = 80;
const context = canvas.getContext('2d');
context.fillStyle = "#000";
context.beginPath();
context.rect(0, 0, 160, 80);
context.fill();
context.fillStyle = "#2D3134";
context.beginPath();
context.rect(5, 5, 150, 70);
context.fill();
context.fillStyle = "#000";
context.beginPath();
context.arc(80, 40, 40, 0, Math.PI * 2, false);
context.fill();
context.font = "Bold 60px Richardson";
context.fillStyle = levelData.basketColor ? IntToHex(levelData.basketColor, 6) : "#2D3134";
context.textAlign = "center";
context.fillText("" + levelData.level, 80, 60);
const image = document.createElement('img');
image.src = canvas.toDataURL();
const texture = new THREE.Texture(image);
texture.needsUpdate = true;
return texture;
}
};
该文件将存储一个级别对象数组,该数组将存储各种参数,例如到篮筐的距离、篮筐颜色、篮板纹理。你可以添加你自己的。
你可以找到我的一个例子levelData.js on GitHub。
我们需要添加更多的东西:onGoal和onLevelStart。我们有一条保持球循环的线Part II。那么,这个函数是做什么的?
const APP = {
// ...
// ....
// .....
// Events
onLevelStart() {
APP.menu.timeClock = new THREE.Clock();
APP.menu.timeClock.getElapsedTime();
},
onGoal(ballp, basketp) {
const distance = new THREE.Vector2(ballp.x, ballp.z)
.distanceTo(new THREE.Vector2(basketp.x, basketp.z));
APP.menu.time = APP.menu.timeClock.getElapsedTime();
APP.menu.accuracy = (1 - distance / 2) * 100;
if (APP.helpersActive) {
document.querySelector('.helpers').className += ' deactivated';
APP.helpersActive = false;
}
APP.goal = true;
setTimeout(() => APP.goal = false, APP.goalDuration);
APP.goToMenu();
},
// ...
}
在切换动画开始之前,我们需要停止keep_ball循环并禁用throwBall()中使用的控件。
然后我们可以很容易地理解玩家取得了什么成绩。在本例中,如果准确度大于60,时间小于2秒,并且一次尝试—“非常好”,准确度大于40,时间小于5秒,并且一次尝试—“好”,否则—“好”
const APP = {
// ...
goToMenu() {
// Stop picking ball.
APP.keep_ball.stop();
APP.controlsEnabled = false; // Disable moving.
let mark = 0, markText = "";
// Detect mark depending on existing stats.
if (APP.menu.time.toFixed() < 2
&& APP.menu.attempts.toFixed() == 1
&& APP.menu.accuracy.toFixed() > 60) {
mark = 3;
APP.menu.markText = "Excellent";
} else if (APP.menu.time.toFixed() < 5
&& APP.menu.attempts.toFixed() == 1
&& APP.menu.accuracy.toFixed() > 40) {
mark = 2;
APP.menu.markText = "Good";
} else {
mark = 1;
APP.menu.markText = "OK";
}
// FadeIn effect for
APP.menuDataPlane.getNative().material.map = TexUtils.generateMenuTexture(APP.menu);
APP.menuDataPlane.show();
APP.selectLevelHelper.show();
if (APP.isMobile) {
APP.menuDataPlane.getNative().material.opacity = 0.7;
} else {
APP.menuDataPlane.getNative().material.opacity = 0;
TweenLite.to(APP.menuDataPlane.getNative().material, 3, {opacity: 0.7, ease: Power2.easeInOut});
}
// Tween camera position and rotation to go upper and look at basket position.
const cameraDest = APP.camera.clone();
cameraDest.position.y = 300;
cameraDest.lookAt(new THREE.Vector3(0, APP.basketY, 0));
TweenLite.to(APP.camera.position, 3, {y: 300, ease: Power2.easeInOut});
TweenLite.to(APP.camera.rotation, 3, {
x: cameraDest.rotation.x,
y: cameraDest.rotation.y,
z: cameraDest.rotation.z,
ease: Power2.easeInOut,
onComplete: () => {
APP.loop_raycaster.start();
}
});
},
// ...
}
为了在从主游戏进入目标细节部分时制作一个好的动画,我使用了GSAP’s TweenLite。我不知道相机的目的地应该使用什么旋转,所以我会使用。观看()已在目标位置的克隆摄像机的方法。然后,我简单地从相机中获取数据摄像机旋转动画完成后启动loop_raycaster。
这一部分与前一部分类似,因为在这里我们实际上也是这样做的:我们进行过渡,但这一次是从目标详细信息到级别选择菜单:
const APP = {
// ...
/* Func: 3 Section. LEVELMENU */
triggerLevelMenu() {
// Enable for checking in loop.
APP.levelMenuTriggered = true;
// Prevent checking in loop before animation complete.
APP.animComplete = false;
// Draw level grid. Start checking for selecting level.
if (!APP.menu.enabled) APP.initLevelMenu();
if (APP.checkForLevel) APP.checkForLevel.start();
// Go to LevelMenu.
TweenLite.to(APP.camera.position, 1, {z: 350, ease: Power2.easeIn});
if (APP.isMobile) {
APP.LevelLight1.getNative().intensity = 10;
APP.LevelLight2.getNative().intensity = 10;
} else {
// Reset lights.
APP.LevelLight1.getNative().intensity = 0;
APP.LevelLight2.getNative().intensity = 0;
// Tween turning on lights.
TweenLite.to(APP.LevelLight1.getNative(), 0.5, {intensity: 10, ease: Power2.easeIn, delay: 1});
TweenLite.to(APP.LevelLight2.getNative(), 0.5, {intensity: 10, ease: Power2.easeIn, delay: 1.5, onComplete: () => {
APP.animComplete = true;
}});
}
}
// ...
}
出于性能原因,我取消了移动设备的淡入效果(是的,再一次…),但对于台式机,我做了一个漂亮的淡入效果,灯在一条线上打开。
从第二部分切换到第三部分
重置所有目标数据,包括时间、尝试次数和准确度。我们停止检查级别循环,因为我们不再需要它了。(我的意思是直到我们再次回到水平选择菜单)。
此外,我们隐藏第二部分(目标数据)中的所有对象,以使过渡更漂亮、更平滑(场景中物体越少=每秒帧数越多)。
我们在相机目的地做的同样的把戏,我们在这里做。使用观看()然后从相机的物体上获得旋转。
是的,当你创建第一人称游戏或者只是想根据到相机的距离为物体制作颜色叠加时,雾是很好的。但是,当您想要创建渐显效果很容易,只需在远的价值。
const APP = {
// ...
goBackToLevel() {
APP.levelMenuTriggered = false;
APP.animComplete = false;
APP.menu.timeClock = new THREE.Clock();
APP.menu.time = 0;
APP.menu.attempts = 0;
APP.menu.accuracy = 0;
APP.menu.timeClock.getElapsedTime();
if (APP.menuDataPlane) APP.menuDataPlane.hide();
if (APP.selectLevelHelper) APP.selectLevelHelper.hide();
if (APP.checkForLevel) APP.checkForLevel.stop();
const cameraDest = APP.camera.clone();
cameraDest.position.set(0, APP.basketY, 50);
cameraDest.lookAt(new THREE.Vector3(0, APP.basketY, 0));
const rotationDest = cameraDest.rotation;
TweenLite.to(APP.world.getScene().fog, 0.5, {far: 400, onComplete: () => {
APP.loop_raycaster.stop();
APP.controlsEnabled = true;
APP.keep_ball.start();
APP.thrown = false;
APP.ball.setAngularVelocity(new THREE.Vector3(0, 0, 0));
}});
TweenLite.to(APP.world.getScene().fog, 1.5, {delay: 1.5, far: 1000, ease: Power3.easeOut});
TweenLite.to(APP.camera.rotation, 2, {delay: 0.5, x: rotationDest.x, y: rotationDest.y, z: rotationDest.z, ease: Power3.easeOut});
TweenLite.to(APP.camera.position, 2, {delay: 0.5, z: 50, y: APP.basketY, ease: Power3.easeOut, onComplete: () => {
APP.animComplete = true;
}});
},
changeLevel(levelData) {
const tempBY = APP.basketY;
const tempBZ = APP.getBasketZ();
if (levelData.force.y) APP.force.y = levelData.force.y;
if (levelData.force.z) APP.force.z = levelData.force.z;
if (levelData.force.m) APP.force.m = levelData.force.m;
if (levelData.force.xk) APP.force.xk = levelData.force.xk;
APP.backboard.getNative().material.map = WHS.texture('textures/backboard/' + levelData.level + '/backboard.jpg'),
APP.backboard.getNative().material.normalMap = WHS.texture('textures/backboard/' + levelData.level + '/backboard_normal.jpg'),
APP.backboard.getNative().material.displacementMap = WHS.texture('textures/backboard/' + levelData.level + '/backboard_displacement.jpg')
APP.basketY = levelData.basketY;
APP.basketDistance = levelData.basketDistance;
APP.basketColor = levelData.basketColor;
APP.basket.position.y = APP.basketY;
APP.basket.position.z = APP.getBasketZ();
APP.net.getNative().geometry.translate(0, APP.basketY - tempBY, APP.getBasketZ() - tempBZ);
APP.backboard.position.y = APP.basketY + 10;
APP.backboard.position.z = APP.getBasketZ() - APP.getBasketRadius();
APP.wall.position.z = -APP.basketDistance;
APP.basket.M_color = APP.basketColor;
},
// ...
};
我们不需要为每一级做特殊的功能。最好的方法是覆盖变量,并将它们再次用于场景中的现有对象。
请注意,网络的位置应该始终为vec3(0,0,0)才能正常工作。这是因为网是一个柔软的身体;它的位置和旋转永远不会改变,但是几何图形的顶点会改变。这就是为什么我们。翻译()方法。
这是“开发街头篮球游戏”的最后也是最大的一部分我试图解释开发过程中每个令人困惑的事情...如果我遗漏了什么或不清楚,请留言,我会尽力尽快回复你。
前几部分:
这篇文章很辛苦,我试图提供一个制作完整游戏的教程(不仅仅是其中的一部分)。你可以简单地通过向他人推荐本教程或在这里分享你自己的结果来支持它!我希望你发现上面的信息有用。谢谢!