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:
- Create a mesh in the render world
mesh
- Create rigid bodies in the physical world
body
- Add the created mesh and rigidbody to
kokomi
thephysics
Object, 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:
- Get three pieces of data needed for segmentation: the grid
mesh
(already), collision pointpoi
(need to be calculated), normalsnor
(Also need to be calculated) - Pass the partition data to the function
subdivideByImpact
To retrieve all fragmentsmesh
- Remove broken object objects (both render world and physical world removed)
- Add all fragments to the current world (
mesh
Already,body
theshape
You can calculate it using a shortcut function,mass
Directly takeuserData
In. Syncposition
andquaternion
Data)
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:
- Bi: First collider (body I)
- Bj: The second collider (body J)
- Ri: Vector of the world from the first collider to the contact point
- Rj: World vector from the second collider to the point of contact
- 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:
- Add more destructible shapes such as cones
- Change various colors (background color, material color, illumination color) to optimize rendering
- Added a fogging effect to the vista
- 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.