Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

Developing a Street Basketball Game (Part 2)

DZone's Guide to

Developing a Street Basketball Game (Part 2)

Continuing on with our tutorial, this time, we will talk about the creation of the basketball net (with physics) and how we will hold the ball and throw it in different directions depending on mouse position. Read on and I'll show you how it's done.

· Web Dev Zone
Free Resource

Start coding today to experience the powerful engine that drives data application’s development, brought to you in partnership with Qlik.

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

This time, we will talk about the creation of the basketball net (with physics) and how we will hold the ball and throw it in different directions depending on mouse position. If you haven’t read Part 1: Getting workflow ready, read it first, please. I describe there what to start with, how to create basic scene elements (ball, basket, wall, and ground).

In this post, I will try to describe in-game mechanics as much as possible and how all these things interact in Three.js and Ammo.js wrapped in the Whitestorm.js framework. Without this framework, there will be much more unnecessary code used. One more thing is softbody physics… in JavaScript… I have told you that we are going to create a net that will move freely and shake when it collides with the ball, not a scripted animation that happens when we score a goal, but complete physics for net and ball collisions.

Two More Files

We compile this project using rollup.js, it means we can use es6 modules without any restrictions. Let’s add two files that will handle: 1) Events, 2)Loops.

import EVENTS from './events';
import {pick_ball} from './loops';

const APP = {
// ...


App “js” folder structure


We’ll back to them soon, but let’s create a backboard and a basketball net first.

Backboard & Basketball Net

The backboard is just a WHS.Box with applied map, normalMap(defines how light will be cast on texture) and displacementMap. The backboard will be 41 x 28 and with the depth of 1. We don’t want the backboard to fall down, so its mass will be as with the wall and ground.

The net has a cylinder geometry with radiusTop bigger than radiusBottom. It is created from softTrimesh object, unlike softClothMesh it also has pressureproperty which defines how fast this object will return to its default state. This cylinder will be openEnded and have no bottom and top (Just like we need).

The number of segments will be different for desktop and mobile or tablet devices. This is used to make optimizations on mobile devices as fast as they should be (like we have on desktops).

const APP = {
  // APP: config. <-
  // APP: variables. <-
  // APP: init. <-
  // APP: createScene. <-
  // APP: addLights. <-

  addBasket() {
    /* BACKBOARD OBJECT */
    APP.backboard = new WHS.Box({
      geometry: {
        buffer: true,
        width: 41,
        depth: 1,
        height: 28
      },

      mass: 0,

      material: {
        kind: 'standard',
        map: WHS.texture('textures/backboard/1/backboard.jpg'),
        normalMap: WHS.texture('textures/backboard/1/backboard_normal.jpg'),
        displacementMap: WHS.texture('textures/backboard/1/backboard_displacement.jpg'),
        normalScale: new THREE.Vector2(0.3, 0.3),
        metalness: 0,
        roughness: 0.3
      },

      pos: {
        y: APP.basketY + 10,
        z: APP.getBasketZ() - APP.getBasketRadius()
      }
    });

    APP.backboard.addTo(APP.world).then(() => APP.ProgressLoader.step());

    // BASKET OBJECT. <-

    /* NET OBJECT */
    APP.net = new WHS.Cylinder({
      geometry: {
        radiusTop: APP.getBasketRadius(),
        radiusBottom: APP.getBasketRadius() - 3,
        height: 15,
        openEnded: true,
        heightSegments: APP.isMobile ? 2 : 3,
        radiusSegments: APP.isMobile ? 8 : 16
      },

      shadow: {
        cast: false
      },

      physics: {
        pressure: 2000,
        friction: 0.02,
        margin: 0.5,
        anchorHardness: 0.5,
        viterations: 2,
        piterations: 2,
        diterations: 4,
        citerations: 0
      },

      mass: 30,
      softbody: true,

      material: {
        map: WHS.texture('textures/net4.png', {repeat: {y: 0.7, x: 2}, offset: {y: 0.3}}), // 0.85, 19
        transparent: true,
        opacity: 0.7,
        kind: 'basic',
        side: THREE.DoubleSide,
        depthWrite: false
      },

      pos: {
        y: APP.basketY - 8,
        z: APP.getBasketZ()
      }
    });

    APP.net.addTo(APP.world).then(() => {
      APP.net.getNative().frustumCulled = false;
      const netRadSegments = APP.isMobile ? 8 : 16;

      for (let i = 0; i < netRadSegments; i++) {
        APP.net.appendAnchor(APP.world, APP.basket, i, 0.8, true);
      }

      APP.ProgressLoader.step();
    });
  }

  // APP: addBall. <-
  // ...TODO
};


Setting Config and Variables…

We have already added some configuration details and the isMobile variable. Now it’s time to fill all other properties and explain what we need for each one.

I made this gist to describe each configuration or variable used in app:

import EVENTS from './events';
import {pick_ball} from './loops';

const APP = {
  /* === APP: config === */
  /* GLOABAL */
  helpersActive: true, // This one is used once for helper gif.
  /* APP */
  bgColor: 0xcccccc, // Walls and ground color.
  /* BALL */
  ballRadius: 6, // Radius of basketball ball.
  /* BASKET */
  basketColor: 0xff0000, // Color of basket.
  getBasketRadius: () => APP.ballRadius + 2, // Basket radius.
  basketTubeRadius: 0.5, // Basket tube radius.
  basketY: 20, // Basket Y position.
  basketDistance: 80, // Distance from player to basket.
  getBasketZ: () => APP.getBasketRadius() + APP.basketTubeRadius * 2 - APP.basketDistance, // Basket Z position.
  /* GOAL */
  basketGoalDiff: 2.5, // Used to detect a goal.
  basketYGoalDiff: () => APP.isMobile ? 2 : 1, // Used to detect a goal by Y.
  basketYDeep: () => APP.isMobile ? 2 : 1, // Will be used to make basket lower for detection.
  goalDuration: 1800, // ms. Time, after which you can make another goal.
  /* EVENTS | MOBILE */
  doubleTapTime: 300, // For doubletap detection.

  /* === APP: variables === */
  thrown: false,  // Is basketball ball thrown.
  doubletap: false,  // Is doubletap active.
  goal: false, // Is goal active.
  controlsEnabled: true, // Are controls enabled.
  isMobile: navigator.userAgent.match(/(iPhone|iPod|iPad|Android|BlackBerry)/), // Is device - mobile.

  cursor: {
    x: 0, // Mouse X.
    y: 0, // Mouse Y.
    xCenter: window.innerWidth / 2, // Window center X.
    yCenter: window.innerHeight / 2 // Window center Y.
  },

  force: {
    y: 6, // Kick ball Y force.
    z: -2, // Kick ball Z force.
    m: 2400, // Multiplier for kick force.
    xk: 8 // Kick ball X force multiplier.
  },

  // ...


We use arrow functions if we want to use other configuration settings to form a new one because if not they will return undefined because APP hasn’t been defined before they called.

New File: Events.js

I split app.js into several files to make it smaller and show how to use ES6 module syntax in such apps. The first file I created will contain functions that register events:

export default {
  _click(APP) {
    window.addEventListener('click', APP.throwBall);
    window.addEventListener('click', () => {
      const el = APP.world.getRenderer().domElement;

      if (!el.fullscreenElement && APP.isMobile) {
        if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
        if (el.mozRequestFullscreen) el.mozRequestFullscreen();
        if (el.msRequestFullscreen) el.msRequestFullscreen();
        if (el.requestFullscreen) el.requestFullscreen();
      }
    });
  },

  _move(APP) {
    ['mousemove', 'touchmove'].forEach((e) => {
      window.addEventListener(e, APP.updateCoords);
    });
  },

  _keypress(APP) {
    window.addEventListener('keypress', APP.checkKeys);
  },

  _resize(APP) {
    APP.cursor.xCenter = window.innerWidth / 2;
    APP.cursor.yCenter = window.innerHeight / 2;

    window.addEventListener('resize', () => {
      const style = document.querySelector('.whs canvas').style;

      style.width = '100%';
      style.height = '100%';
    });
  }
};


Also, note that we should pass the APP variable in each function that should interact with the main app and it’s variables.

There, we see that some event handlers need to be added to the app.js file, we also need to call all the functions to make them work:

const APP = {
  // APP: config. <-
  // APP: variables. <-
  // APP: init. <-
  // APP: createScene. <-
  // APP: addLights. <-
  // APP: addBasket. <-
  // APP: addBall. <-

  /* === APP: Events === */

  initEvents() {
    EVENTS._move(APP);
    EVENTS._click(APP);
    EVENTS._keypress(APP);
    EVENTS._resize(APP);

    APP.ProgressLoader.step();
  },

  updateCoords(e) {
    e.preventDefault();

    APP.cursor.x = e.touches && e.touches[0] ? e.touches[0].clientX : e.clientX;
    APP.cursor.y = e.touches && e.touches[0] ? e.touches[0].clientY : e.clientY;
  },

  checkKeys(e) {
    e.preventDefault();
    if (e.code === "Space") APP.thrown = false;
  },

  detectDoubleTap() {
    if (!APP.doubletap) { // Wait for second click.
      APP.doubletap = true;

      setTimeout(() => {
        APP.doubletap = false;
      }, APP.doubleTapTime);

      return false;
    } else { // Double tap triggered.
      APP.thrown = false;
      APP.doubletap = true;

      return true;
    }
  },

  // ...TODO
};


We just added 4 new functions, let’s explain them:

  • initEvents(), starts all event handlers.
  • updateCoords(), updates local cursor position variables.
  • checkKeys(), checks for “Spacebar” key pressed. If yes — resets ball position.
  • detectDoubleTap(), does the same as checkKeys(), but upon a double tap event and is more useful for mobile devices.

New File: Loops.js

To keep a ball we need to reset its position on each frame. We’ll call keepBall()function for that. But it should do it only if the ball isn’t previously thrown by the player and hasn’t been returned to its default position with a pressed spacebar or double tap.

loops.js

// Keep a ball and goal detection.
export const keep_ball = (APP) => {
  return new WHS.Loop(() => {
    if (!APP.thrown) APP.keepBall();

    const BLpos = APP.ball.position;
    const BSpos = APP.basket.position

    if (BLpos.distanceTo(BSpos) < APP.basketGoalDiff
      && Math.abs(BLpos.y - BSpos.y + APP.basketYDeep()) < APP.basketYGoalDiff() 
      && !APP.goal) APP.onGoal(BLpos, BSpos);
  });
}


apps.js

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: Functions === */
  /* Func: 1 Section. GAME */

  throwBall(e) {
    e.preventDefault();

    if (!APP.detectDoubleTap() && APP.controlsEnabled && !APP.thrown) {
      const vector = new THREE.Vector3(
        APP.force.xk * (APP.cursor.x - APP.cursor.xCenter), 
        APP.force.y * APP.force.m,
        APP.force.z * APP.force.m
      );

      APP.ball.setLinearVelocity(new THREE.Vector3(0, 0, 0)); // Reset gravity affect.

      APP.ball.applyCentralImpulse(vector);

      vector.multiplyScalar(10 / APP.force.m)
      vector.y = vector.x;
      vector.x = APP.force.y;
      vector.z = 0;

      APP.ball.setAngularVelocity(vector); // Reset gravity affect.
      APP.thrown = true;
      APP.menu.attempts++;
    }
  },

  keepBall() {
    const cursor = APP.cursor;

    const x = (cursor.x - cursor.xCenter) / window.innerWidth * 32;
    const y = - (cursor.y - cursor.yCenter) / window.innerHeight * 32;

    APP.ball.position.set(x, y, -36);
  }

  // ...TODO
};


There's one more function called throwBall(), we use it simply to apply a certain impulse to a ball. Note that impulse is applied only once, so we need to pass large numbers there. For that, we’ll use APP.force.m, a number that multiplies Y and Z values of impulse (usually a large value).

One Thing Left

There is only one thing that we forgot to do. We forgot to start our keep_ball loop, it should be in the init() function, which is why I left it for last.


  /* === APP: init === */

  init() {
    // ...

    APP.createScene(); // 1
    APP.addLights(); // 2
    APP.addBasket(); // 3
    APP.addBall(); // 4
    APP.initEvents(); // 5

    // Start the loop.
    APP.keep_ball = keep_ball(APP);
    APP.world.addLoop(APP.keep_ball);
    APP.keep_ball.start();

    APP.world.start(); // Ready.
  },


To start the loop, we need to add it to the world first. Adding it to the world means that the loop will be executed in the same function (at the same time) that renders our world. Only after that, should we start executing it.

Result

Result of part 2.


This time, you will have the ability to move the ball, throw it into a basket, but not just one time (simply press spacebar or do a double tap and throw a ball again).

Links

Previous part (part 1)

A result of this part is also available on github.

Full game: Github | Demo

Create data driven applications in Qlik’s free and easy to use coding environment, brought to you in partnership with Qlik.

Topics:
javascript ,web developement ,webgl ,3d ,web design ,programming ,html5 ,tutorial ,whitestorm.js

Published at DZone with permission of Alexander Buzin. See the original article here.

Opinions expressed by DZone contributors are their own.

THE DZONE NEWSLETTER

Dev Resources & Solutions Straight to Your Inbox

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.

X

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}