Talking about Bouncy Light game, 3D, Game development, HTML5, Javascript, Phaser and TypeScript.
This is the result of an experiment I built while I was about to port the first step of Godot “Stairs” prototype in Phaser using Three.js to render 3D objects moving according to Phaser sprites.
I already built something similar when I added 3D to my Bouncy Light prototype, but:
1 – The prototype was more than 2 years old, and meanwhile both Phaser and Three have been updated several times.
2 – It was not written in TypeScript
3 – It used a Phaser3D extension which is for Phaser supporters only (like I am) full of unnecessary code, since my prototype only uses some basic features.
So this is basically a modern rewrite of 3D Bouncy Light prototype.
But why did I build this, while writing the porting of Stairs? Because I found an infamous bug which does not allow to properly render Phaser and Three.js together if you add a visible Phaser Game Object and more than one Three.js geometry.
There isn’t that much to say, just follow my Twitter’s post #1, #2 and #3 to see the problem I was facing and how I tried to solve them. And, why not? Follow me on Twitter if you didn’t already.
I found the latest Three.js version working properly with latest Phaser version is Three.js v 0.109 which is three years old but at least it works without workarounds.
So just to test if everything would be ok I built this minigame:
Just click or tap and hold to make ball move, release to stop it.
The actual game is the one you can see in the top of the canvas, the 3D is just a representation of what’s happening in the 2D environment.
Basically I coded the game logic with Phaser, and rendered it with Three.js
All this stuff in one HTML file, one CSS file and six TypeScript files, let’s have a look at them:
index.html
The web page which hosts the game, to be run inside thegame element.
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="style.css">
<script src="main.js"></script>
</head>
<body>
<div id = "thegame"></div>
</body>
</html>
style.css
The cascading style sheets of the main web page.
* {
padding : 0;
margin : 0;
}
body {
background-color: #011025;
}
canvas {
touch-action : none;
-ms-touch-action : none;
}
gameOptions.ts
Configurable game options. It’s a good practice to place all configurable game options, if possible, in a single and separate file, for a quick tuning of the game.
// CONFIGURABLE GAME OPTIONS
// changing these values will affect gameplay
export const GameOptions = {
// player size
playerSize : 20,
// player gravity
playerGravity : 1800,
// player start x position, 0 = left; 1 = right
playerStartXPosition : 0.2,
// bounce velocity when the player hits a platform
bounceVelocity : 900,
// amount of platforms to be created and recycled
platformAmount : 10,
// platform speed, in pixels per second
platformSpeed : 1000,
// min and max distance range between platforms
platformDistanceRange : [200, 350],
// min and max platform height, , 0 = top of the screen; 1 = bottom of the screen
platformHeightRange : [0.5, 0.8],
// min and max platform length
platformLengthRange : [40, 100],
// platform thickness, in pixels (y size)
platformThickness : 20
}
main.ts
This is where the game is created, with all Phaser related options.
// MAIN GAME FILE
// modules to import
import Phaser from 'phaser';
import { PreloadAssets } from './preloadAssets';
import { PlayGame } from './playGame';
// object to initialize the Scale Manager
const scaleObject : Phaser.Types.Core.ScaleConfig = {
mode : Phaser.Scale.FIT,
autoCenter : Phaser.Scale.CENTER_BOTH,
parent : 'thegame',
width : 800,
height : 600
}
// game configuration object
const configObject : Phaser.Types.Core.GameConfig = {
type : Phaser.WEBGL,
transparent: true,
backgroundColor : 0x011025,
scale : scaleObject,
scene : [PreloadAssets, PlayGame],
physics : {
default : 'arcade',
}
}
// the game itself
new Phaser.Game(configObject);
preloadAssets.ts
Here we preload all assets to be used in the game, such as the sprites used for the player and the wall.
// CLASS TO PRELOAD ASSETS
// this class extends Scene class
export class PreloadAssets extends Phaser.Scene {
// constructor
constructor() {
super({
key : 'PreloadAssets'
});
}
// method to be execute during class preloading
preload() : void {
// this is how we preload an image
this.load.image('player', 'assets/player.png');
this.load.image('platform', 'assets/platform.png');
}
// method to be called once the instance has been created
create() : void {
// call PlayGame class
this.scene.start('PlayGame');
}
}
playGame.ts
Main game file, all game logic and Three integration is stored here.
// THE GAME ITSELF
import * as THREE from 'three';
import { ThreePlayer } from './threePlayer';
import { GameOptions } from './gameOptions';
import { ThreePlatform } from './threePlatform';
// this class extends Scene class
export class PlayGame extends Phaser.Scene {
player : ThreePlayer;
platformGroup : Phaser.Physics.Arcade.Group;
threeScene : THREE.Scene;
// constructor
constructor() {
super({
key: 'PlayGame'
});
}
// method to be executed when the scene has been created
create() : void {
this.create3DWorld();
this.player = new ThreePlayer(this, this.threeScene);
this.addPlatforms();
this.input.on('pointerdown', this.pointerDown, this);
this.input.on('pointerup', this.pointerUp, this);
this.cameras.main.setViewport(0, 0, 0, 0);
this.cameras.main.setZoom(0.25);
}
pointerDown() : void {
this.platformGroup.setVelocityX(-GameOptions.platformSpeed);
}
pointerUp() : void {
this.platformGroup.setVelocityX(0);
}
create3DWorld() : void {
const width : number = this.game.config.width as number;
const height : number = this.game.config.height as number;
this.threeScene = new THREE.Scene();
const renderer : THREE.WebGLRenderer = new THREE.WebGLRenderer({
canvas: this.sys.game.canvas,
context: this.sys.game.context as WebGLRenderingContext,
antialias: true
});
renderer.autoClear = false;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const camera : THREE.PerspectiveCamera = new THREE.PerspectiveCamera(45, width / height, 1, 10000);
camera.position.set(width / 2, height, width);
camera.lookAt(width / 2 , height / 2, 0);
const ambientLight : THREE.AmbientLight = new THREE.AmbientLight(0xffffff, 1);
this.threeScene.add(ambientLight);
const spotLight : THREE.SpotLight = new THREE.SpotLight(0xffffff, 1, 0, 0.4, 0.5, 0.1);
spotLight.position.set(width * GameOptions.playerStartXPosition, height * 3, 0);
spotLight.castShadow = true;
spotLight.shadow.mapSize.width = 512;
spotLight.shadow.mapSize.height = 512;
spotLight.shadow.camera.near = 1;
spotLight.shadow.camera.far = 10000;
this.threeScene.add(spotLight);
const view : Phaser.GameObjects.Extern = this.add.extern();
// @ts-expect-error
view.render = () => {
renderer.state.reset();
renderer.render(this.threeScene, camera);
};
}
addPlatforms() : void {
// creation of a physics group containing all platforms
this.platformGroup = this.physics.add.group();
// let's proceed with the creation
for (let i = 0; i < GameOptions.platformAmount; i ++) {
new ThreePlatform(this, this.platformGroup, this.threeScene);
}
}
// method to be executed at each frame
update() : void {
// collision management ball Vs platforms
this.physics.world.collide(this.platformGroup, this.player, () => {
// bounce back the ball
this.player.body.velocity.y = -GameOptions.bounceVelocity;
}, undefined, this);
// if 2D ball falls down the screen...
if (this.player.y > this.game.config.height) {
// restart the game
this.scene.start("PlayGame");
}
}
}
threePlayer.ts
Custom class extending Phaser.Physics.Arcade.Sprite to define the player, which is both an Arcade Sprite and a Three Mesh.
import { GameOptions } from './gameOptions';
import * as THREE from 'three';
// this class extends Phaser.Physics.Arcade.Sprite
export class ThreePlayer extends Phaser.Physics.Arcade.Sprite {
threeObject : THREE.Mesh;
scene : Phaser.Scene;
constructor(scene : Phaser.Scene, threeScene : THREE.Scene) {
super(scene, 0, 0, 'player');
this.x = scene.game.config.width as number * GameOptions.playerStartXPosition;;
this.displayWidth = GameOptions.playerSize;
this.displayHeight = GameOptions.playerSize;
scene.add.existing(this);
scene.physics.add.existing(this);
this.body.gravity.y = GameOptions.playerGravity;
this.body.checkCollision.down = true;
this.body.checkCollision.up = false;
this.body.checkCollision.left = false;
this.body.checkCollision.right = false;
const geometry : THREE.BoxGeometry = new THREE.BoxGeometry(10, 10, 10);
const material : THREE.Material = new THREE.MeshStandardMaterial({
color : 0xff0000
});
this.threeObject = new THREE.Mesh(geometry, material);
this.threeObject.scale.x = this.displayWidth / 10;
this.threeObject.scale.y = this.displayHeight / 10;
this.threeObject.scale.z = this.displayHeight / 10;
this.threeObject.position.x = this.x;
this.threeObject.castShadow = true;
threeScene.add(this.threeObject);
this.scene = scene;
}
preUpdate () : void {
this.threeObject.position.y = this.scene.game.config.height as number - this.y;
}
}
threePlatform.ts
Custom class extending Phaser.Physics.Arcade.Sprite to define the platform, which is both an Arcade Sprite and a Three Mesh.
import { GameOptions } from './gameOptions';
import * as THREE from 'three';
// this class extends Phaser.Physics.Arcade.Sprite
export class ThreePlatform extends Phaser.Physics.Arcade.Sprite {
threeObject : THREE.Mesh;
group : Phaser.Physics.Arcade.Group;
scene : Phaser.Scene;
constructor(scene : Phaser.Scene, group : Phaser.Physics.Arcade.Group, threeScene : THREE.Scene) {
super(scene, 0, 0, 'platform');
this.group = group;
this.scene = scene;
let posX : number = (this.getRightmostPlatform() == 0) ? scene.game.config.width as number * GameOptions.playerStartXPosition : this.setPlatformX();
this.setPosition(posX, this.setPlatformY());
this.setOrigin(0.5, 1);
this.displayWidth = Phaser.Math.Between(GameOptions.platformLengthRange[0], GameOptions.platformLengthRange[1]);
this.displayHeight = GameOptions.platformThickness;
scene.add.existing(this);
scene.physics.add.existing(this);
group.add(this);
this.setImmovable(true);
const geometry : THREE.BoxGeometry = new THREE.BoxGeometry(10, 10, 80);
const material : THREE.MeshStandardMaterial = new THREE.MeshStandardMaterial({
color : 0x00ff00
});
this.threeObject = new THREE.Mesh(geometry, material);
this.threeObject.receiveShadow = true;
this.threeObject.position.x = posX;
this.threeObject.position.y = this.scene.game.config.height as number - this.y + this.displayHeight / 2;
this.threeObject.scale.x = this.displayWidth / 10;
this.threeObject.scale.y = this.displayHeight / 10;
threeScene.add(this.threeObject);
}
getRightmostPlatform() : number {
let rightmostPlatform : number = 0;
this.group.getChildren().forEach((platform) => {
let platformObject : Phaser.Physics.Arcade.Sprite = platform as Phaser.Physics.Arcade.Sprite;
rightmostPlatform = Math.max(rightmostPlatform, platformObject.x);
});
return rightmostPlatform;
}
// method to set a random platform X position
setPlatformX() : number {
return this.getRightmostPlatform() + Phaser.Math.Between(GameOptions.platformDistanceRange[0], GameOptions.platformDistanceRange[1]);
}
// method to set a random platform Y position
setPlatformY() : number {
return Phaser.Math.Between(this.scene.game.config.height as number * GameOptions.platformHeightRange[0], this.scene.game.config.height as number * GameOptions.platformHeightRange[1]);
}
preUpdate() : void {
if (this.getBounds().right < -100) {
this.x = this.setPlatformX();
this.y = this.setPlatformY();
this.displayWidth = Phaser.Math.Between(GameOptions.platformLengthRange[0], GameOptions.platformLengthRange[1]);
this.threeObject.scale.x = this.displayWidth / 10;
this.threeObject.position.y = this.scene.game.config.height as number - this.y + this.displayHeight / 2;
}
this.threeObject.position.x = this.x;
}
}
Now that I managed to make Phaser and Three work, nothing will stop me from building the infinite staircase. Download the source code of the entire project.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.