Further to my revisit of Phaser Coding Tips 3 (original Phaser 2 tutorial here), where I went beyond the scope of the original tutorial and ended up making Mr Dude ride on a platform that moves in an ellipse, I thought it would be real easy to work through Coding Tips 4 (original Phaser 2 tutorial here), which is about making Mr Dude ride on "clouds" that follow a fixed pre-determined path based on chained tweens.
Well, I was wrong..I ended up spending hours in trying to follow the original Phaser 2 structure as closely as possible...getting into a "tween-motionpaths-nightmare". Whereas in the past the challenge was to get tween based path followers to "collaborate" with arcade physics engine, this time I got stuck with the configuration of tweens themselves - in particular, the use of relative values in the context of looping tweens...
Perhaps some of my learnings will be useful to other people looking at making characters riding platforms that follow paths.
Making the clouds follow a motion path using tweens
I have spent an enormous amount of time exploring the use of tweens to make objects follow a motion path. I have delved deep into Phaser 3's path follower object (which is itself based on tweens). The conclusion was that I should stay away from using tweens to move objects using tweens where I need to rely on arcade physics for collision detection. Nevertheless, I saw that the original code tutorial example uses the Phaser 2 equivalent of chained tweens, which I had not explored so I ended up using tweens anyway....
The conclusion is that I simply cannot seem to get along nicely with tweens...anyway so that I do not end up going down the same path, I have documented the challenges and heartache in this particular exploration into chained tweens.
And yes, I did, in the end manage to get the example code working in Phaser 3 using tweens - the code is available at the end of this post.
Syntax for creating chained tweens in Phaser 2 very different from Phaser 3
One of the key aspects of the original code is to create chained tweens with relative values. Specifically, 2 sets of tweens are created, for the x-direction and y-direction respectively, using a function called addMotionPath.
Original Phaser 2 code
CloudPlatform.prototype.addMotionPath = function (motionPath) {
this.tweenX = this.game.add.tween(this.body);
this.tweenY = this.game.add.tween(this.body);
// motionPath is an array containing objects with this structure
// [
// { x: "+200", xSpeed: 2000, xEase: "Linear", y: "-200", ySpeed: 2000, yEase: "Sine.easeIn" }
// ]
for (var i = 0; i < motionPath.length; i++)
{
this.tweenX.to( { x: motionPath[i].x }, motionPath[i].xSpeed, motionPath[i].xEase);
this.tweenY.to( { y: motionPath[i].y }, motionPath[i].ySpeed, motionPath[i].yEase);
}
this.tweenX.loop();
this.tweenY.loop();
};
Specifically, for the first cloud, which move around a diamond shape in the anticlockwise direction, the following config is passed to the above function.
(Original Phaser 2 code)
var cloud1 = new CloudPlatform(this.game, 300, 450, 'cloud-platform', this.clouds);
cloud1.addMotionPath([
{ x: "+200", xSpeed: 2000, xEase: "Linear", y: "-200", ySpeed: 2000, yEase: "Sine.easeIn" },
{ x: "-200", xSpeed: 2000, xEase: "Linear", y: "-200", ySpeed: 2000, yEase: "Sine.easeOut" },
{ x: "-200", xSpeed: 2000, xEase: "Linear", y: "+200", ySpeed: 2000, yEase: "Sine.easeIn" },
{ x: "+200", xSpeed: 2000, xEase: "Linear", y: "+200", ySpeed: 2000, yEase: "Sine.easeOut" }
]);
I started off thinking this would be very easy in Phaser 3; with something like below to create the tween. (Don't actually try these! They do not work!)
var cloud1 = new CloudPlatform(this, 300, 450, 'cloud-platform', this.clouds);
var tweenX = this.tweens.timeline({
targets: cloud1,
tweens: [
{x: "+=200", ease: "Linear", duration: 2000},
{x: "-=200", ease: "Linear", duration: 2000},
{x: "-=200", ease: "Linear", duration: 2000},
{x: "+=200", ease: "Linear", duration: 2000},
],
loop: -1
});
var tweenY = this.tweens.timeline({
targets: cloud1,
tweens: [
{y: "-=200", ease: "Sine.easeIn", duration: 2000},
{y: "-=200", ease: "Sine.easeOut", duration: 2000},
{y: "+=200", ease: "Sine.easeIn", duration: 2000},
{y: "+=200", ease: "Sine.easeOut", duration: 2000}
],
loop: -1
});
or something like this.
var cloud1 = new CloudPlatform(this, 300, 450, 'cloud-platform', this.clouds);
var timeline = this.tweens.timeline({
targets: cloud1,
tweens: [
{
x: {value: "+=200", ease: "Linear", duration: 2000},
y: {value: "-=200", ease: "Sine.easeIn", duration: 2000}
},
{
x: {value: "-=200", ease: "Linear", duration: 2000},
y: {value: "-=200", ease: "Sine.easeOut", duration: 2000}
},
{
x: {value: "-=200", ease: "Linear", duration: 2000},
y: {value: "+=200", ease: "Sine.easeIn", duration: 2000}
},
{
x: {value: "+=200", ease: "Linear", duration: 2000},
y: {value: "+=200", ease: "Sine.easeOut", duration: 2000}
}
],
loop: -1
});
or something like this.
var cloud1 = new CloudPlatform(this, 300, 450, 'cloud-platform', this.clouds);
var tweenX = this.tweens.createTimeline({loop: -1})
tweenX.add({targets: cloud1, x: "+=200", ease: "Linear", duration: 2000})
tweenX.add({targets: cloud1, x: "-=200", ease: "Linear", duration: 2000})
tweenX.add({targets: cloud1, x: "-=200", ease: "Linear", duration: 2000})
tweenX.add({targets: cloud1, x: "+=200", ease: "Linear", duration: 2000})
var tweenY = this.tweens.createTimeline({loop: -1});
tweenY.add({targets: cloud1, y: "-=200", ease: "Sine.easeIn", duration: 2000})
tweenY.add({targets: cloud1, y: "-=200", ease: "Sine.easeOut", duration: 2000})
tweenY.add({targets: cloud1, y: "+=200", ease: "Sine.easeIn", duration: 2000})
tweenY.add({targets: cloud1, y: "+=200", ease: "Sine.easeOut", duration: 2000})
tweenX.play();
tweenY.play();
For all of the above "implementations", the cloud moves as expected for the first "loop", but then goes haywire - I cannot for the life of me figure out why they behave in that way.
Hard coding the properties end values
In order to overcome the above, the easiest thing is to hard code the x and y destination values. However, it would be nice to keep the ability to specify relative values for the destination values. We can achieve pretty much the same effect as the original code by amending the addMotionPath method slightly, like below. The basic overall logic for this method is the same as the original Phaser 2 example, except:
rather than pass to the tween the x and y destination properties as relative properties, they are converted to absolute numbers by this method, before passing to the tween, as in the blue highlighted code
in order to "manually" calculate the delta-x and delta-y of the cloud (since the deltaX() and deltaY() methods will return zero, given that the physics engine is not being used to move the cloud), the red highlighted code is included
addMotionPath(motionPath) {
this.tweenX = this.scene.tweens.createTimeline({
loop: -1,
onUpdate: (tween, target) => {
this.vx = this.body.position.x - this.previousX;
this.previousX = this.body.position.x;
}
});
this.tweenY = this.scene.tweens.createTimeline({
loop: -1,
onUpdate: (tween, target) => {
this.vy = this.body.position.y - this.previousY;
this.previousY = this.body.position.y;
}
});
var destX = this.x;
var destY = this.y;
for (var i = 0; i<motionPath.length; i++) {
destX += Number(motionPath[i].x);
destY += Number(motionPath[i].y);
this.tweenX.add({targets: this, x: destX, duration: motionPath[i].xSpeed, ease: motionPath[i].xEase})
this.tweenY.add({targets: this, y: destY, duration: motionPath[i].ySpeed, ease: motionPath[i].yEase})
}
return this
}
With the above method, the motion paths can be set from the game scene create method, pretty much in the same as the original Phaser 2 tutorial example;
var cloud1 = new CloudPlatform(this, 300, 450, 'cloud-platform', this.clouds);
cloud1.addMotionPath([
{ x: "+200", xSpeed: 2000, xEase: "Linear", y: "-200", ySpeed: 2000, yEase: "Sine.easeIn" },
{ x: "-200", xSpeed: 2000, xEase: "Linear", y: "-200", ySpeed: 2000, yEase: "Sine.easeOut" },
{ x: "-200", xSpeed: 2000, xEase: "Linear", y: "+200", ySpeed: 2000, yEase: "Sine.easeIn" },
{ x: "+200", xSpeed: 2000, xEase: "Linear", y: "+200", ySpeed: 2000, yEase: "Sine.easeOut" }
])
.start();
Some small but critical points in ensuring Mr Dude stays on moving cloud
In comparison with the examples I posted in my previous post exploring vertically moving platforms using tweens (here), there are a number of small but very important differences in this particular implementation.
customSeparateX and customSeparateY
Just as in the original Phaser 2 tutorial example, I set the properties customSeparateX and customSeparateY of the cloud platforms to true - this is important!
class CloudPlatform extends Phaser.Physics.Arcade.Sprite {
constructor(scene, x, y, texture, group) {
super(scene, x, y, texture);
this.scene = scene;
scene.add.existing(this);
scene.physics.add.existing(this);
this.setOrigin(0);
this.vx;
this.vy;
this.previousX = this.x;
this.previousY = this.y;
// this.playerLocked = false;
group.add(this);
this.body.customSeparateX = true;// these 2 lines of code should come after the group.add
this.body.customSeparateY = true;// since properties will be overwritten by the group defaults
}
According to the documentation, customSeparateX is described as follows.
A flag disabling the default horizontal separation of colliding bodies. Pass your own collideCallback to the collider.
The collider is set as follows:
this.physics.add.collider(this.player, this.clouds, this.customSep, null, this);
and the colliderCallback defined as follows;
customSep(player, platform) {
// if (platform.body.touching.up && player.body.touching.down) {
if (!player.locked && player.body.velocity.y > 0) {
player.locked = true;
player.lockedTo = platform;
// platform.playerLocked = true;
player.body.velocity.y = 0;
}
This means that when Mr Dude collides with a cloud, a check is first carried out to see if (i) Mr Dude is already "locked" to a cloud, and (ii) Mr Dude is actually standing on top of a cloud (or anything - assumes this must be a cloud).
If both conditions are met, the player is locked to the colliding cloud, the relevant cloud is set to the "lockedTo" property of Mr Dude, and the vertical velocity of Mr Dude is killed off for good measure.
However, the Phaser's built-in arcade physics separation routine is not executed, because customSeparateX and customSeparateY are set to true.
default separation can cause problems
If the customSeparateX and customSeparateY properties were not set to true, the arcade physics engine separation routine would kick-in (that is the default behaviour).
However, letting the arcade physics separation routine do its work can lead to unpredictable behaviour when Mr Dude jumps on top of the moving cloud. For example, if Mr Dude jumps on cloud 2 (ie the one in the middle that moves vertically, with Sine.easeIn on the way down, and Sine.easeOut on the way up) when the cloud is moving upwards, you will find that Mr Dude can fall through the cloud, when landing from the jump. This is because (I think) the physics engine separates Mr Dude from the cloud (ie move Mr Dude in the y-direction, by just the amount of penetration at the time of landing on the cloud) but the cloud is moving upwards hence the separation doesn't work work properly since the cloudhas "caught up" with Mr Dude - this kind of situation can occur particularly when the velocity of the cloud is changing (as in this case).
Hence, what the code does it not switch off arcade physics separation routine, and instead upon collision with the cloud, Mr Dude's y-coordinate is forcibly reset to the y-coordinate of the cloud minus Mr Dude's height. This is why sometimes you will see Mr Dude "dig" into the cloud, but quickly get "reset" to ontop of the cloud.
"locking" Mr Dude's y-position
When Mr Dude is locked to the cloud, the tutorial example forces Mr Dude's y-position as follows;
preRender(time) {
if (this.locked || this.wasLocked) {
this.body.position.x += this.lockedTo.vx;
// this.body.position.y += this.lockedTo.vy;
this.body.y = this.lockedTo.body.y - this.body.height;
}
Importantly, it does NOT reset y with reference to the cloud's (manually calculated) velocity - this.lockedTo.vy. This is because the collision seaparaion is no longer taking place so that Y-position is not always flush against the cloud, hence Mr Dude will "drift" away from the cloud.
Allowing Mr Dude to jump from cloud
In the original Phaser 2 code, blue highlighted code below is checked before allowing Mr Dude to jump; ie Mr Dude is only allowed to jump when he is on the ground (or cloud). In my Phaser 3 translation however, this.body.touching.down and body.blocked.down are both set to false, when standing on the platform which is moving downwards.
Hence to make Mr Dude able to jump from a cloud when it is moving downwards, I have added an additional check, as highlighted in red below.
var standing = this.body.blocked.down || this.body.touching.down;
if ((standing && this.cursors.up.isDown && time > this.jumpTimer) ||
(this.cursors.up.isDown && this.locked && time > this.jumpTimer)) {
if (this.locked) {
this.cancelLock();
}
this.willJump = true;
}
Height of jumps on vertically moving cloud
I don't know about other people, but when I made Mr Dude jump on top of the moving cloud, he seems to be jumping higher if the jump was made from a downward moving cloud.. same in the original Phaser 2 example. I spent some time figuring out if there was a bug. But I came to the conclusion that this is just a trick of the eye. Mr Dude is simply falling further, hence looks like he is moving up higher.
Finally, don't forget to set arcade physics fps
And finally, as explored in my previous post on vertically moving platforms, the fps property of arcade physics needs to be set to a higher value (120 is arbitrary) within the game config.
const config = {
width: 640,
height: 480,
backgroundColor: 0x2f9acc,
pixelArt: true,
physics: {
default: "arcade",
arcade: {
fps: 120,
gravity: {y: 600},
debug: true
}
},
scene: [Game]
};
const game = new Phaser.Game(config);
Otherwise Mr Dude will "slip & slide" on the moving cloud, as the tween update and the game scene update are not in synch.
My Phaser 3 conversion is available below for your perusal and comment.
Key references
Comments