Pull to refresh

Creating a mini-game with drip effect and moving circles. Part 2. Final

Level of difficultyEasy
Reading time8 min
Views778
Original author: Ivan Zhukov

Hello, dear Habr contributors!

Today let's continue creating a mini-game with moving circles and an interesting drip effect.

In the first part we made a basic movement of circles on the page. And in today's lesson we will make an animation of "explosion" and absorption of circles.

Final demo of the second part of the lesson:

Let's get started!

Generating circles

In order to "split" one circle into several, we will add a new splitting function to the Substance class, which will create a given number of new circles in a loop. We will also define two new fields as data:

  • minSize - the minimum size of the circle;

  • countPieces - the number of generated circles.

And at the end of the constructor we will call the created function.

class Substance extends Base {
    constructor(...) {
        this.data = {
            minSize: 60,
            countPieces: 5,
            ...
        }
        ...

        this.splitting();
    }

    splitting() {
        for (let i = 0; i < this.data.countPieces; i++) {
            this.data.pieces.push(new Piece(this));
        }
    }
}

To keep the circles from flying too fast across the screen, you can adjust the MIN_SPEED and MAX_SPEED values of the Piece class to your liking. In the example, I'll change the movement speed to make the "merging" effect of the circles more visible as they fly past each other.

class Piece extends Base {
    MIN_SPEED = 1;
    MAX_SPEED = 3;
    
    ...
}

As a result, when you click on the page, the number of circles specified in the countPieces field will appear, which will move chaotically around the page.

The result is the appearance of many circles
The result is the appearance of many circles

Animation of a circle "exploding"

Since we will be changing the width and height values of each element while creating the animation, we will use JavaScript's built-in animate function to create a smooth animation.

The animate function allows you to describe the animation order for any HTML element, as well as to set other animation characteristics (duration, start delay, etc.). CSS animations using @keyframes work in a similar way.

Let's create animateCollapse function in the Piece class.

As the first parameter to the animate function we pass an object that contains the name of each CSS property that we are going to animate, and as an array - the sequence of animation frames.

As the second parameter we will set the duration of the animation, as well as the animation start delay.

class Piece extends Base {
    ...

    animateCollapse() {
        const maxSize = this.parent.data.maxSize;
        const minSize = this.parent.data.minSize;

        this.animate = this.el.animate({
            width: [`${maxSize}px`, `${minSize}px`],
            height: [`${maxSize}px`, `${minSize}px`]
        }, {
            duration: 300,
            delay: 300
        });

        this.animate.onfinish = () => {}
    }
}

In this case, we will use JavaScript implementation. We need to define the moment when the animation ends, and we can do it through the onfinish method. We'll leave it empty for now, but we'll come back to it soon.

The animateCollapse function needs to be called to start the animation chain. Let's remove the call to the splitting function, as we'll move it to the onfinish method a little later. And when we create the first Piece element, we will call the "explosion" animation for it.

class Substance extends Base {
    constructor(parent, params) {
        this.data = {...}

        this.data.pieces.push(new Piece(this));
        this.data.pieces[0].animateCollapse();
    }
    
    ...
}

We now have an animation of an element "exploding". Now we can add the generation of circles.

Appearance of circles after the end of animation

In order to generate circles immediately after the end of the animation, let's call the splitting function in onfinish.

class Piece extends Base {
    ...
    
    animateCollapse() {
        ...

        this.animate.onfinish = () => {
            this.parent.splitting();
        }
    }
}

Checking the result in the browser, we can see that a large number of circles appear after the animation is over.

But there are a few flaws:

  • during the animation the element moves;

  • due to the fact that the element is moving, the generation of circles happens exactly where we clicked earlier.

We will fix it!

Stopping the movement of the circle during animation

For the Substance class, let's add a new field canMove, which will store the movement state "allowed/forbidden". It is this field that we will change to "movement allowed" when the animation ends, and vice versa, when the animation starts. And the default value is false, as our circle will immediately start its animation when it appears on the page.

class Substance extends Base {
    constructor(parent, params) {
        ...
        this.data = {...}
        
        this.canMove = false;

        ...
    }
}

In the Piece class, let's make a few additions:

  • at the start of the animateCollapse function, change the value of canMove to "no movement allowed", because in the future the cycle of "explosion" and "merging" of elements will be infinite;

  • at the end of the animation in the onfinish method, change the value of canMove to "movement allowed";

  • at the start of the update function we check that "movement is allowed", otherwise we terminate the function and do not update the position of the circle.

class Piece extends Base {
    ...

    update() {
        if (!this.parent.canMove) return;
        ...
    }
    
    animateCollapse() {
        this.parent.canMove = false;
        ...
        
        this.animate.onfinish = () => {
            this.parent.canMove = true;
            ...
        }
    }
}

Now we have achieved the desired effect, and new circles appear exactly where the main one "explodes".

Reducing the size of the circles

In the Base class, let's add an updateSize function to update the sizes of HTML elements.

class Base {
    ...

    updateSize() {
        this.el.style.width = `${this.data.size}px`;
        this.el.style.height = `${this.data.size}px`;
    }
}

As the default size value when creating an instance of the Piece class, we replace the maximum size with the minimum size, since only the first item will have the maximum size and all others should be generated at the minimum size.

class Piece extends Base {
    constructor(parent) {
        ...
        
        this.data = {
            size: this.parent.data.minSize,
            ...
        }
        ...
    }
}

And in order to apply a dynamic value as the size of the circle, in the createElement function we replace the manually set value with the field from data.size.

class Piece extends Base {
    ...

    createElement() {
        ...
        this.el.style.width = `${this.data.size}px`;
        this.el.style.height = `${this.data.size}px`;
        ...
    }

    ...
}

As mentioned earlier, the first circle should have the maximum size to play the animation correctly. Therefore, we will add size updates to the animateCollapse function.

class Piece extends Base {
    ...

    animateCollapse() {
        ...

        this.data.size = this.parent.data.maxSize;
        this.updateSize();
        
        ...
        
        this.animate.onfinish = () => {
            this.data.size = this.parent.data.minSize;
            this.updateSize();
            ...
        }
    }
}

The "explosion" and resizing animations now look correct.

Defining the intersection of circles

Let's make it so that when two circles intersect, only one circle remains. In the future it will look like simulation of "eating" of a small circle by a larger one.

Let's add a static function checkCollision to the Base class, in which we will use the mathematical formula for the contact of circles and return the result true/false.

class Base {
    ...

    static checkCollision(piece1, piece2) {
        const a = piece1.r + piece2.r;
        const x = piece1.x - piece2.x;
        const y = piece1.y - piece2.y;
        
        return a > Math.sqrt((x * x) + (y * y));
    }
}

In the Piece class we will create two new functions:

  • checkCollision - in it we will call the static method Base.checkCollision and pass the arguments of two circles. If the method returns true, it means that the circles are in contact, and we remove one of the circles;

  • removeElement - will remove from the DOM the element of the circle, in the instance of the class of which the function was called, as well as remove the same circle from the pieces array.

class Piece extends Base {
    ...

    checkCollision() {
        this.parent.data.pieces.forEach((piece) => {
            // Exit the loop if we are comparing the same circle
            if (this === piece) return;

            if (Base.checkCollision({
                r: this.data.size / 2,
                x: this.data.position.x,
                y: this.data.position.y
            }, {
                r: piece.data.size / 2,
                x: piece.data.position.x,
                y: piece.data.position.y
            })) {
                this.removeElement();
            }
        });
    }

    removeElement() {
        this.parent.removePieceFromArray(this);
        this.el.remove();
    }
}

In the Substance class, create a function removePieceFromArray. This function is necessary even though we have removed the object from the DOM, but it remains in JavaScript memory and is still being moved.

class Substance extends Base {
    ...

    removePieceFromArray(piece) {
        this.data.pieces.splice(this.data.pieces.indexOf(piece), 1);
    }
}

At the end of the update function, we will add a call to the checkCollision function.

class Piece extends Base {
    ...
    
    update() {
        ...

        this.checkCollision();
    }
}

Checking the result, we see a strange behaviour. This happens because the elements do not have time to fly away from each other and instantly "eat" all of themselves.

In order to "switch off" the "eating" functionality for some time after the "explosion" occurs, let's add a new field canCheckCollision in the Substance class.

class Substance extends Base {
    constructor(parent, params) {
        this.data = {...}
        ...

        this.canCheckCollision = false;

        ... 
    }
    
    ...
}

At the start of animateCollapse we will assign the value false for the canCheckCollision field, and at the end of the onfinish method we will add a timer. Since some time passes after the end of the "explosion" animation and before the circles fly around the page, the timer will allow us to change the value after the time has passed.

class Piece extends Base {
    ...

    animateCollapse() {
        this.parent.canCheckCollision = false;
        ...

        this.animate.onfinish = () => {
            ...
            setTimeout(() => {
                this.parent.canCheckCollision = true;
            }, 1000);
        }
    }
    
    ...
}

"Absorption" of small circles

Now we have just deleting one of the circles at intersection. Let's make the effect of "eating" and define the largest circle by increasing its size, and delete the smaller one.

In the Substance class create a new field difSize, which will be the difference between the values of the maximum and minimum sizes of the circles. We will use it in the formula for calculating the increase of the absorber circle.

class Substance extends Base {
    constructor(parent, params) {
        ...

        this.canCheckCollision = false;
        this.difSize = this.data.maxSize - this.data.minSize;

        ...
    }
    
    ...
}

And in the Piece class we will add the consume function, describing the formula that will be used to calculate the number added to the size when a smaller element is "consumed".

class Piece extends Base {
    ...

    consume(piece) {
        this.data.size += piece.data.size - this.parent.data.minSize + this.parent.difSize / this.parent.data.countPieces;
        this.updateSize();
        piece.removeElement();
    }
}

Call the consume function when the condition of intersection of circles inside checkCollision is fulfilled.

class Piece extends Base {
    ...

    checkCollision() {
        if (!this.parent.canCheckCollision) return;

        this.parent.data.pieces.forEach((piece) => {
            ...

            if (Base.checkCollision(...)) {
                if (this.data.size > piece.data.size) this.consume(piece);
                else piece.consume(this);
            }
        });
    }
}

And to make the animation of the increasing circle look smoother, we still need to add one more additional property to our style.css style file.

.spore {
    transition: width .2s, height .2s;
    ...
}

The repeated "explosion" of the mug

The condition under which we will re-run the animation for a circle is that all circles from the array are completely consumed. Thus, each time a circle is removed from the array, we will call a function that checks if the animation can be re-run.

In the checkSplitting function, we will check that the length of the array is exactly one element and overwrite the "generation" position of the circles with the current position of the circle.

class Substance extends Base {
    ...
    
    removePieceFromArray(piece) {
        this.data.pieces.splice(this.data.pieces.indexOf(piece), 1);
        this.checkSplitting();
    }
    
    checkSplitting() {
        if (this.data.pieces.length === 1) {
            const piece = this.data.pieces[0];
            
            this.data.position.x = piece.data.position.x;
            this.data.position.y = piece.data.position.y;

            piece.animateCollapse();
        }
    }
}

Adding different colours

We will add an array of different colours to the COLORS variable as a constant at the beginning of the file.

const SCREEN_WIDTH = ...
const SCREEN_HEIGHT = ...

const COLORS = ['red', 'cyan', 'yellow', 'green', 'blue', 'black'];

To make the circles appear in different colours when clicking on the page, we will use an already implemented function that allows you to get a random number in a given range. Let's add one more string as a data field for the Substance class.

class Substance extends Base {
    constructor(parent, params) {
        ...

        this.data = {
            ...
            countPieces: 5,
            color: COLORS[Game.random(0, COLORS.length - 1)]
        }
        ...
    }
    
    ...
}

And to apply the colour of all circles - let's add a background to the element in the createElement function.

class Piece extends Base {
    createElement() {
        ...
        this.el.style.backgroundColor = this.parent.data.color;

        ZONE.appendChild(this.el);
    }
    
    ...
}

Conclusion

As a result of the work we have a small interactive page with an interesting effect, which you can continue to refine by introducing your ideas!

The final result of the lesson
The final result of the lesson

Repository on GitHub

Thanks for listening, everyone, and see you all back on Habr!

Tags:
Hubs:
Rating0
Comments0

Articles