Creating a 3D Browser Game With Physics in a Few Steps...

This is the final part of my “Developing a Street Basketball Game” trilogy. If you haven't checked Part I: Getting workflow ready and Part ll: Throw a ball into a basket—please check them out first.

Today, I will tell you about how to make new levels, create a level selection menu, and show a player's stats.

Filling init()

First, note that we added two new files to app.js: levelData.js and utils/textures.js.

levelData will return an array that contains level objects, these are JavaScript objects filled with config that differs from defaults.

utils/textures returns functions for generating textures from inputted data. It will be used to print stats and level items in our level menu.

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

Another thing we need to do is add a raycaster variable. This thing is used to check if 3D vectors generated from a 2D mouse position intersects with other scene objects.

And, in the last 21 lines of our init() function, we added checking for app loading status. I won’t describe this part because it is not related to our main application, and you will probably skip or rewrite this part in your app. Just keep in mind that we use event listeners here to do various things that will make an impact on the app preloader.

Three Sections

By the way, let’s define the three sections of our game accessed via the main menu:

Main game. Player throws the ball and tries to score a goal.

Player throws the ball and tries to score a goal. Goal details. Player scored a goal and now he/she sees how much time it took, number of attempts, and accuracy.

Player scored a goal and now he/she sees how much time it took, number of attempts, and accuracy. Level selecting menu. Player wants to choose another level or rturn to the current one.

Initmenu();

Ratio

Let’s make a nice transition for camera, goal stats, and game headline.

At first, we need to define a variable (ratio in the code below) that will store the relation between window width and window height. THREE.PerspectiveCamera already has two methods that will give us width and height values ->.getFilmWidth() and .getFilmHeight()

Note that APP.camera is a whitestorm.js wrapper for camera, to get it’s Three.js camera use APP.camera.getNative()

Headline Text

Let’s add a headline for this game. For that we use WHS.Text. We need to generate a .js font file... I used typeface.js generator to do this. I named my file 1.js, you can name it as you see fit. Note that in our font parameter we type font URL, not a font name like in three.js.

For material, I applied a texture using the handy WHS.texture function. “repeat” parameter there automatically applies THREE.RepeatWrapping to wrapS and wrapT of the texture. For more information check out this example in the three.js docs.

For performance reasons, this text will be available only for desktops.

Text Centering

To make this text center-aligned, we need to calculate it’s width, divide it by 2, and subtract this value from the text’s X position (If we want to center it by the X axis). We can find text mesh’s width simply by subtraction of bounding box’s X-max and X-min values.

In the image below, you can see how we do this. Blue line is a center of the screen.

Goal Details

To show goal details, we need to make a 2D text. The easiest way is to create a plane and make a text as it’s texture. To make it, we need to create a 2D canvas 2000 x 1000 (these values will only be used for dimension, the only thing you always need to keep the same is ratio. You can make it 1000 x 500 or etc.)

Then we create an img element and apply base64 exported from canvas element to img’s src.

The last step is to make a THREE.Texture from this image. It can be simply done by passing image as a parameter: new THREE.Texture(image)

This file is 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 };

The plane’s size will depend on the ratio of the device that we use. If the ratio is smaller than 0.7, the plane will be 150 x 75, if greater than 0.7 – 200 x 100.

Let’s sum up the above:

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

Loop_raycaster

This loop is used to make a cursor from a 3D ball. First of all, we need to hide a default cursor, it can easily be implemented using CSS:

body { cursor: none; /* Disable cursor */ }

Then we should make our ball follow the hidden cursor. For that, we need to have two 3D points:

A point where the ball should be.

Current ball position.

The second one we already have, but where should the first one be? To find that, we should use THREE.Raycaster. We use our current cursor’s X and Y to get a point where our projected ray intersects “plane for raycasting”. Let’s modify createScene() a little:

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); } // ... }

APP.planeForRaycasting should be a Math Plane, not a plane geometry. In the image below, you can see that this plane is highlighted blue:

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

We’ll talk about triggerLevelMenu() and goBackToLevel() later, when we’ll make a Level Menu section.

We Can Load App Faster

Some things can be done after the app is loaded. I created one more init… : initLevelMenu()

…but the difference between initMenu() and initLevelMenu() is that the first one is called before the app is started and the second one only after we score a goal. Of course, it’s not necessary to do such things, but it’s up to you.

So, what do we prepare in this part?

Level grid — some planes with generated texture (by number of level)

— some planes with generated texture (by number of level) Level indicator - used in checkForLevel loop. Will be shown when player’s cursor is over the level plane.

- used in loop. Will be shown when player’s cursor is over the level plane. LI progress — this is a progress bar for Level Indicator.

In this image, you can see that the ball (cursor) is over a Level plane (level 3).

Level indicator is a little white sphere in ball center.

LI progress is a torus around Level Indicator.

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

And, we need to make 2D textures depending on level data again:

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

This file will store an array of level objects that will store miscellaneous parameters such as distance to basket, basket color, backboard texture. You may add your own ones.

You can find an example of my levelData.js on GitHub.

The Most Interesting Part: Transitions

Before We Start

We need to add some more things: onGoal and onLevelStart. We had a line in keep_ball loop from Part II. So, what does this function do?

Records goal data. Time, accuracy, attempts. Modifies APP.goal variable. The most important: calls goToMenu();

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(); }, // ... }

Moving From Main Game to Second Section

Before the switching animation starts, we need to stop the keep_ball loop and disable controls used in throwBall().

Then we can easily understand what mark the player achieves. In this example, if accuracy is more than 60, time is less than 2 seconds, and 1 attempt — “Excellent”, accuracy is more than 40, time is less than 5 seconds and 1 attempt — “Good”, and else — “OK”

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(); } }); }, // ... }

Transition

To make a nice animation while going from the Main Game to the Goal Details section, I used GSAP’s TweenLite. I don’t know what rotation I should use for the camera’s destination so I will use .lookAt() method for a cloned camera that is already on the destination position. Then, I simply get the data from the camera as cameraDest.rotation and start loop_raycaster when the animation is complete.

3rd Section: Level Select Menu

This part is similar to the previous one, because here we actually do the same: we make transitions, but this time from Goal Details to the Level Select Menu:

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; }}); } } // ... }

I removed the fade-In effect for mobile devices for performance reasons (Yep, one more time…), but for desktops, I made a beautiful fade-in effect where lights switch on in-line.

shifting from 2nd part to 3rd





Back to Level & Switching Levels

goBackToLevel() resets all goal’s data including time, attempts, and accuracy. We stop checkForLevel loop because we don’t need it anymore. (I mean until we return to the Level Select menu again).

Also, we hide all objects from the second section (Goal data) to make the transition prettier and smoother (less objects on scene = more frames per second).

The same trick we did with camera destination, we do here. Use lookAt() and then get rotation from the camera’s object.

How to Make Fadein / Fadeout in Three.js Without Touching HTML? Easy. Use Fog.

Yep, fog is nice when you create first-person games or simply want to make color overlay for objects depending on the distance to the camera. But, it’s also useful when you want to create the fadeIn effect easily, simply tween it’s far value.

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; }, // ... };

Changelevels()

We don’t need to make a special function for each level. The best way is to overwrite variables and use them again for existing objects in the scene.

Note that net’s position should be always vec3(0, 0, 0) to work properly. This is because the net is a soft body; and it’s position and rotation never change, but geometry’s vertices do. And that’s why we us the .translate() method.

Summary

This is the last and largest part of “Developing a Street Basketball Game.” I tried to explain each confusing thing in the development process... if I missed something or was unclear, please leave a comment, and I will do my best to answer you quickly.

Previous Parts:

This article was hard work, I tried to providea tutorial for making a complete game (not just a part of it). You can support it simply by recommending this tutorial to others or sharing your own results here! I hope you found the information above useful. Thanks!

Full game: GitHub | Demo