I am participating in the nuggets Community game creative submission Contest. For details, please see: Game Creative Submission Contest

preface

Hi, here is CSS and WebGL magic – Alphardex. In this article, we will implement a very classic physics game called Smash Hit using Kokomi.js.

In the game you will become a marble, through the catapult to smash all obstacles in front of you, especially suitable for decompression.

The game is the address

r76xf5.csb.app

kokomi.js

If you don’t know her, don’t worry, the following article will give you an introduction to Kokomi.js

Juejin. Cn/post / 707857…

Scenario building

First, we fork the template to create the simplest scenario possible

Create four scene elements: environment, ground, cube, and pinball

Each element is a separate class, maintaining its own state and function without interfering with each other

The environment

The lighting is set inside: an ambient light and a daylight light

components/environment.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";

class Environment extends kokomi.Component {
  ambiLight: THREE.AmbientLight;
  directionalLight: THREE.DirectionalLight;
  constructor(base: kokomi.Base) {
    super(base);

    const ambiLight = new THREE.AmbientLight(0xffffff.0.7);
    this.ambiLight = ambiLight;

    const directionalLight = new THREE.DirectionalLight(0xffffff.0.2);
    this.directionalLight = directionalLight;
  }
  addExisting(): void {
    const scene = this.base.scene;
    scene.add(this.ambiLight);
    scene.add(this.directionalLight); }}export default Environment;
Copy the code

The ground

The earth used to hold all physical objects

There are 3 steps to creating a 3D object with physical properties:

  1. Create a mesh in the render worldmesh
  2. Create rigid bodies in the physical worldbody
  3. Add the created mesh and rigidbody tokokomithephysicsObject, which automatically synchronizes the operational state of the physical world to the render world

Notice that the plane here is vertical by default, and we want to rotate it 90 degrees beforehand

Kokomi convertGeometryToShape is a shortcut functions, can automatically. Three js geometry into cannon. Js required shape, don’t need to manually to define the shape

components/plane.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";
import * as CANNON from "cannon-es";

class Floor extends kokomi.Component {
  mesh: THREE.Mesh;
  body: CANNON.Body;
  constructor(base: kokomi.Base) {
    super(base);

    const geometry = new THREE.PlaneGeometry(10.10);
    const material = new THREE.MeshStandardMaterial({
      color: new THREE.Color("# 777777")});const mesh = new THREE.Mesh(geometry, material);
    mesh.rotation.x = THREE.MathUtils.degToRad(-90);
    this.mesh = mesh;

    const shape = kokomi.convertGeometryToShape(geometry);
    const body = new CANNON.Body({
      mass: 0,
      shape,
    });
    body.quaternion.setFromAxisAngle(
      new CANNON.Vec3(1.0.0),
      THREE.MathUtils.degToRad(-90));this.body = body;
  }
  addExisting(): void {
    const { base, mesh, body } = this;
    const{ scene, physics } = base; scene.add(mesh); physics.add({ mesh, body }); }}export default Floor;
Copy the code

square

The small squares used to withstand the impact of marbles can also be replaced with other shapes

The material here uses the GlassMaterial of kokomi. Js

components/box.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";
import * as CANNON from "cannon-es";

class Box extends kokomi.Component {
  mesh: THREE.Mesh;
  body: CANNON.Body;
  constructor(base: kokomi.Base) {
    super(base);

    const geometry = new THREE.BoxGeometry(2.2.0.5);
    const material = new kokomi.GlassMaterial({});
    const mesh = new THREE.Mesh(geometry, material);
    this.mesh = mesh;

    const shape = kokomi.convertGeometryToShape(geometry);
    const body = new CANNON.Body({
      mass: 1,
      shape,
      position: new CANNON.Vec3(0.1.0)});this.body = body;
  }
  addExisting(): void {
    const { base, mesh, body } = this;
    const{ scene, physics } = base; scene.add(mesh); physics.add({ mesh, body }); }}export default Box;
Copy the code

marbles

Our hero, it will be used to break down all obstacles!

components/ball.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";
import * as CANNON from "cannon-es";

class Ball extends kokomi.Component {
  mesh: THREE.Mesh;
  body: CANNON.Body;
  constructor(base: kokomi.Base) {
    super(base);

    const geometry = new THREE.SphereGeometry(0.25.64.64);
    const material = new kokomi.GlassMaterial({});
    const mesh = new THREE.Mesh(geometry, material);
    this.mesh = mesh;

    const shape = kokomi.convertGeometryToShape(geometry);
    const body = new CANNON.Body({
      mass: 1,
      shape,
      position: new CANNON.Vec3(0.1.2)});this.body = body;
  }
  addExisting(): void {
    const { base, mesh, body } = this;
    const{ scene, physics } = base; scene.add(mesh); physics.add({ mesh, body }); }}export default Ball;
Copy the code

The actor is in place

Instantiate all four of the element classes we created into Sketch and we’ll see them

app.ts

import * as kokomi from "kokomi.js";
import Floor from "./components/floor";
import Environment from "./components/environment";
import Box from "./components/box";
import Ball from "./components/ball";

class Sketch extends kokomi.Base {
  create() {
    new kokomi.OrbitControls(this);

    this.camera.position.set(-3.3.4);

    const environment = new Environment(this);
    environment.addExisting();

    const floor = new Floor(this);
    floor.addExisting();

    const box = new Box(this);
    box.addExisting();

    const ball = new Ball(this); ball.addExisting(); }}const createSketch = () = > {
  const sketch = new Sketch();
  sketch.create();
  return sketch;
};

export default createSketch;
Copy the code

Current address

codesandbox.io/s/1-le69n3? …

Launch the marble

When the user clicks the mouse, a marble is automatically launched from the clicking position

Create a Shooter class

Shooter is mainly responsible for launching marbles by clicking the mouse

components/shooter.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";
import Ball from "./ball";

class Shooter extends kokomi.Component {
  constructor(base: kokomi.Base) {
    super(base);
  }
  addExisting(): void {
    window.addEventListener("click".() = > {
      this.shootBall();
    });
  }
  // Launch marbles
  shootBall(){}}export default Shooter;
Copy the code

Instantiate the Shooter class in Sketch and bind the click event

app.ts

.import Shooter from "./components/shooter";

class Sketch extends kokomi.Base {
  create(){...const shooter = new Shooter(this); shooter.addExisting(); }}...Copy the code

To track the mouse

Creating marbles is as simple as instantiating the Ball class

So how do you make it generate with the position of the mouse, using kokomi’s built-in interactionManager to get the position of the mouse

components/shooter.ts

class Shooter extends kokomi.Component {...shootBall() {
    const ball = new Ball(this.base);
    ball.addExisting();

    // Track mouse position
    const p = new THREE.Vector3(0.0.0);
    p.copy(this.base.interactionManager.raycaster.ray.direction);
    p.add(this.base.interactionManager.raycaster.ray.origin); ball.body.position.set(p.x, p.y, p.z); }}Copy the code

Given speed

At this point, the marbles will actually be generated from where we clicked the mouse, but they will fall from the ground, and we will also give them a speed along the mouse direction

components/shooter.ts

class Shooter extends kokomi.Component {...shootBall(){...// Give mouse direction speed
    const v = new THREE.Vector3(0.0.0);
    v.copy(this.base.interactionManager.raycaster.ray.direction);
    v.multiplyScalar(24); ball.body.velocity.set(v.x, v.y, v.z); }}Copy the code

Shoot event

In order for other classes to get information about the marbles that have been fired, we need to trigger the Shoot event with the data of the marbles that have been fired

components/shooter.ts

import mitt, { type Emitter } from "mitt";

class Shooter extends kokomi.Component {
  emitter: Emitter<any>;
  constructor(base: kokomi.Base){...this.emitter = mitt();
  }
  shootBall(){...this.emitter.emit("shoot", ball); }}Copy the code

Current address

codesandbox.io/s/2-ftycgk? …

Break up the object

Create a Breaker class

The Breaker class is responsible for smashing rigid bodies in the physical world

components/breaker.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";
import * as CANNON from "cannon-es";

class Breaker extends kokomi.Component {
  constructor(base: kokomi.Base) {
    super(base);
  }
  // Add a separable object
  add(obj: any, splitCount = 0) {
    console.log(obj);
  }
  / / collision
  onCollide(e: any) {
    console.log(e); }}export default Breaker;
Copy the code

Instantiate the Breaker class in Sketch, define all objects that can be smashed, listen to the shoot event of the Shooter class, and when there is a ball fired, send it to Collide, that is, listen to the Collide event and send it to the Breaker class to smash

app.ts

.import Breaker from "./components/breaker";

class Sketch extends kokomi.Base {
  create(){...const breaker = new Breaker(this);

    // Define all shatterable objects
    const breakables = [box];
    breakables.forEach((item) = > {
      breaker.add(item);
    });

    // When a marble is fired, it listens for collision, and if collision is triggered, it crushes the object it hits
    shooter.emitter.on("shoot".(ball: Ball) = > {
      ball.body.addEventListener("collide".(e: any) = >{ breaker.onCollide(e); }); }); }}Copy the code

Crush objects

The most interesting part of this game is coming, how to shatter objects into small pieces?

Here we can use a ConvexObjectBreaker class from the example of three.js.

components/breaker.ts

class Breaker extends kokomi.Component {
  cob: STDLIB.ConvexObjectBreaker;
  constructor(base: kokomi.Base){...const cob = new STDLIB.ConvexObjectBreaker();
    this.cob = cob; }}Copy the code

Add shatterable objects

Call the prepareBreakableObject of the ConvexObjectBreaker to create the partition data for the mesh and bind the mesh ID to the body to make them correspond to each other

components/breaker.ts

class Breaker extends kokomi.Component {...objs: any[];
  // Add shattering objects
  add(obj: any, splitCount = 0) {
    this.cob.prepareBreakableObject(
      obj.mesh,
      obj.body.mass,
      obj.body.velocity,
      obj.body.angularVelocity,
      true
    );
    obj.body.userData = {
      splitCount, // The number of times it has been split
      meshId: obj.mesh.id, // Make the body correspond to its corresponding mesh
    };
    this.objs.push(obj); }}Copy the code

Collision detection

Get the object obj from the collider’s meshId and split it if it hasn’t already been split

It is judged that she can only be partitioned once at most. If she is more than that, she will not be partitioned again. (If she is confident about her CPU, she can be raised to d.)

components/breaker.ts

class Breaker extends kokomi.Component {...// Get obj by id
  getObjById(id: any) {
    const obj = this.objs.find((item: any) = > item.mesh.id === id);
    return obj;
  }
  / / collision
  onCollide(e: any) {
    const obj = this.getObjById(e.body.userData? .meshId);if (obj && obj.body.userData.splitCount < 2) {
      this.splitObj(e); }}// Split objects
  splitObj(e: any) {
    console.log(obj); }}Copy the code

Object segmentation

The game’s most complex function, the idea is like this:

  1. Get three pieces of data needed for segmentation: the gridmesh(already), collision pointpoi(need to be calculated), normalsnor(Also need to be calculated)
  2. Pass the partition data to the functionsubdivideByImpactTo retrieve all fragmentsmesh
  3. Remove broken object objects (both render world and physical world removed)
  4. Add all fragments to the current world (meshAlready,bodytheshapeYou can calculate it using a shortcut function,massDirectly takeuserDataIn. SyncpositionandquaternionData)

The most critical point is the calculation of the collision point. E. contact is the contact equation, and several parameters have no meanings in the official document, but the author’s understanding is as follows:

  1. Bi: First collider (body I)
  2. Bj: The second collider (body J)
  3. Ri: Vector of the world from the first collider to the contact point
  4. Rj: World vector from the second collider to the point of contact
  5. Ni: normal vector

Once we know what they mean, we can calculate the collision points POI and normals NOR

components/breaker.ts

class Breaker extends kokomi.Component {...// Split objects
  splitObj(e: any) {
    const obj = this.getObjById(e.body.userData? .meshId);/ / collision
    const mesh = obj.mesh; / / grid
    const body = e.body as CANNON.Body;
    const contact = e.contact as CANNON.ContactEquation; / / contact
    const poi = body.pointToLocalFrame(contact.bj.position).vadd(contact.rj); / / collision point
    const nor = new THREE.Vector3(
      contact.ni.x,
      contact.ni.y,
      contact.ni.z
    ).negate(); / / normal
    const fragments = this.cob.subdivideByImpact(
      mesh,
      new THREE.Vector3(poi.x, poi.y, poi.z),
      nor,
      1.1.5
    ); // Split the grid into fragments

    // Remove broken objects
    this.base.scene.remove(mesh);
    setTimeout(() = > {
      this.base.physics.world.removeBody(body);
    });

    // Add shards to the current world
    fragments.forEach((mesh: THREE.Object3D) = > {
      // Convert mesh to physical shape
      const geometry = (mesh as THREE.Mesh).geometry;
      const shape = kokomi.convertGeometryToShape(geometry);
      const body = new CANNON.Body({
        mass: mesh.userData.mass,
        shape,
        position: new CANNON.Vec3(
          mesh.position.x,
          mesh.position.y,
          mesh.position.z
        ), // Don't forget to synchronize the position of the fragments here, otherwise the fragments will fly out
        quaternion: new CANNON.Quaternion(
          mesh.quaternion.x,
          mesh.quaternion.y,
          mesh.quaternion.z,
          mesh.quaternion.w
        ), // Rotate in the same direction
      });
      this.base.scene.add(mesh);
      this.base.physics.add({ mesh, body });

      // Add shards to destructible
      const obj = {
        mesh,
        body,
      };
      this.add(obj, e.body.userData.splitCount + 1); }); }}Copy the code

You can see that the cube is instantly smashed into a million little pieces. It’s perfect!

Smash event

Breaking also triggers an event called hit

components/breaker.ts

import mitt, { type Emitter } from "mitt";

class Breaker extends kokomi.Component {...emitter: Emitter<any>;
  constructor(base: kokomi.Base){...this.emitter = mitt();
  }
  / / collision
  onCollide(e: any){...if (obj && obj.body.userData.splitCount < 2) {...this.emitter.emit("hit"); }}}Copy the code

Add crushing sound

No broken sound will lose the soul, use Kokomi’s own AssetManager to load good sound materials (source: aige.com), hit the play can be

resources.ts

import type * as kokomi from "kokomi.js";

import glassBreakAudio from "./assets/audios/glass-break.mp3";

const resourceList: kokomi.ResourceItem[] = [
  {
    name: "glassBreakAudio".type: "audio".path: glassBreakAudio,
  },
];

export default resourceList;
Copy the code

app.ts

import resourceList from "./resources";

class Sketch extends kokomi.Base {
  create() {
    const assetManager = new kokomi.AssetManager(this, resourceList);
    assetManager.emitter.on("ready".() = > {
      const listener = new THREE.AudioListener();
      this.camera.add(listener);

      const glassBreakAudio = newTHREE.Audio(listener); glassBreakAudio.setBuffer(assetManager.items.glassBreakAudio); .// When the marble hits an object
      breaker.emitter.on("hit".() = > {
        if(! glassBreakAudio.isPlaying) { glassBreakAudio.play(); }else{ glassBreakAudio.stop(); glassBreakAudio.play(); }}); }); }}Copy the code

Current address

codesandbox.io/s/3-qf11ul? …

Game mode

Now that the physics simulation is over, let’s start the gameplay.

There are different ways to play the game: quest mode, endless mode, limited time mode, Zen mode, etc. This article uses the simplest zen mode.

Zen mode

In fact, Zen mode also belongs to endless mode, but she does not consider any gain and loss, there is no concept of success and failure, if it is purely for relaxation, it is highly recommended

The idea is simple: if you don’t move, she moves

components/game.ts

import * as THREE from "three";
import * as kokomi from "kokomi.js";
import * as CANNON from "cannon-es";
import Box from "./box";
import mitt, { type Emitter } from "mitt";

class Game extends kokomi.Component {
  breakables: any[];
  emitter: Emitter<any>;
  score: number;
  constructor(base: kokomi.Base) {
    super(base);

    this.breakables = [];

    this.emitter = mitt();

    this.score = 0;
  }
  // Create a breakable
  createBreakables(x = 0) {
    const box = new Box(this.base);
    box.addExisting();
    const position = new CANNON.Vec3(x, 1, -10);
    box.body.position.copy(position);
    this.breakables.push(box);
    this.emitter.emit("create", box);
  }
  // Move breakables
  moveBreakables(objs: any) {
    objs.forEach((item: any) = > {
      item.body.position.z += 0.1;

      // Remove if the boundary is exceeded
      if (item.body.position.z > 10) {
        this.base.scene.remove(item.mesh);
        this.base.physics.world.removeBody(item.body); }}); }// Periodically create breakables
  createBreakablesByInterval() {
    this.createBreakables();
    setInterval(() = > {
      const x = THREE.MathUtils.randFloat(-3.3);
      this.createBreakables(x);
    }, 3000);
  }
  / / points
  incScore() {
    this.score += 1; }}export default Game;
Copy the code

The ground is longer

components/floor.ts

class Floor extends kokomi.Component {...constructor(base: kokomi.Base){...const geometry = new THREE.PlaneGeometry(10.50); . }... }Copy the code

Applying games to Sketch

app.ts

import * as kokomi from "kokomi.js";
import * as THREE from "three";
import Floor from "./components/floor";
import Environment from "./components/environment";
import Shooter from "./components/shooter";
import Breaker from "./components/breaker";
import resourceList from "./resources";
import type Ball from "./components/ball";
import Game from "./components/game";

class Sketch extends kokomi.Base {
  create() {
    const assetManager = new kokomi.AssetManager(this, resourceList);
    assetManager.emitter.on("ready".() = > {
      const listener = new THREE.AudioListener();
      this.camera.add(listener);

      const glassBreakAudio = new THREE.Audio(listener);
      glassBreakAudio.setBuffer(assetManager.items.glassBreakAudio);

      this.camera.position.set(0.1.6);

      const environment = new Environment(this);
      environment.addExisting();

      const floor = new Floor(this);
      floor.addExisting();

      const shooter = new Shooter(this);
      shooter.addExisting();

      const breaker = new Breaker(this);

      const game = new Game(this);

      game.emitter.on("create".(obj: any) = > {
        breaker.add(obj);
      });

      game.createBreakablesByInterval();

      // When a marble is fired, it listens for collision, and if collision is triggered, it crushes the object it hits
      shooter.emitter.on("shoot".(ball: Ball) = > {
        ball.body.addEventListener("collide".(e: any) = > {
          breaker.onCollide(e);
        });
      });

      // When the marble hits an object
      breaker.emitter.on("hit".() = > {
        game.incScore();
        document.querySelector(".score")! .textContent =`${game.score}`;
        glassBreakAudio.play();
      });

      this.update(() = >{ game.moveBreakables(breaker.objs); }); }); }}const createSketch = () = > {
  const sketch = new Sketch();
  sketch.create();
  return sketch;
};

export default createSketch;
Copy the code

Current address

codesandbox.io/s/4-xz30nb? …

To optimize the

This can be used freely, such as the following:

  1. Add more destructible shapes such as cones
  2. Change various colors (background color, material color, illumination color) to optimize rendering
  3. Added a fogging effect to the vista
  4. Create your own map, turn the game into a pass-through type

The personal embellishment results are as follows

Current address

codesandbox.io/s/5-r76xf5? …

The last

The finished product of this game is very rough and has a lot of room for improvement.

However, it is the process of creation itself that is most meaningful, and only by experiencing it yourself can one appreciate the beauty in it.