Tutorial: Creating a tank battle style game with Cocos Creator (Part 2)

Tutorial: Creating a tank battle style game with Cocos Creator (Part 2)
0

Tutorial: Creating a tank battle style game with Cocos Creator (Part 2)

Note: Part 1 of this tutorial is located here.

Introduction and getting help

You are welcome to post to the Cocos Creator category on the Cocos forums to talk about any issues you encounter during the development process. In this tutorial, you will start to learn to write a relatively complete tank war game. Please prepare your development environment before starting this tutorial. If you are having trouble with our tutorial, please take a look at the following documentation:

Preparing the development environment


Let’s begin

1. Write a script to work with game data

We need to first create a script that will read and write our games data. By reading the game data, we can know the current game progress, character status, etc., and by writing data, we can control the character to perform tasks, save the game progress and so on.

First, create a gameData folder in the ./assets/scripts/ directory, mainly for saving game data scripts. Second, create a GameConfig.js script, under the gameData folder, which will be used to save the games configuration data. Example:

var GameConfig = {
    PlayerNum: 1,  // number of players
};

module.exports = GameConfig;

The module.exports statement is scripting code for the Javascript language that allows other scripts to read and write data from other scripts. You can read more about this in the Modular Script documentation.

Create a GameConst.js script under the gameData folder. This script is mainly used to record various constant data in the game. Example code:

var GameEnum = require("./GameEnum");
var GameConst = {
	GidToTileType:[
		GameEnum.TileType.tileNone,
		
		GameEnum.TileType.tileNone, GameEnum.TileType.tileNone, GameEnum.TileType.tileGrass, GameEnum.TileType.tileGrass, GameEnum.TileType.tileSteel, GameEnum.TileType.tileSteel, 
		GameEnum.TileType.tileNone, GameEnum.TileType.tileNone, GameEnum.TileType.tileGrass, GameEnum.TileType.tileGrass, GameEnum.TileType.tileSteel, GameEnum.TileType.tileSteel,
	
		GameEnum.TileType.tileWall, GameEnum.TileType.tileWall, GameEnum.TileType.tileRiver, GameEnum.TileType.tileRiver, GameEnum.TileType.tileKing, GameEnum.TileType.tileKing,
		GameEnum.TileType.tileWall, GameEnum.TileType.tileWall, GameEnum.TileType.tileRiver, GameEnum.TileType.tileRiver, GameEnum.TileType.tileKing, GameEnum.TileType.tileKing,
	
		GameEnum.TileType.tileKing, GameEnum.TileType.tileKing, GameEnum.TileType.tileNone, GameEnum.TileType.tileNone, GameEnum.TileType.tileNone, GameEnum.TileType.tileNone,
		GameEnum.TileType.tileKing, GameEnum.TileType.tileKing, GameEnum.TileType.tileNone, GameEnum.TileType.tileNone, GameEnum.TileType.tileNone, GameEnum.TileType.tileNone
	],
	Dirction: ["up","left","down","right"],
	DirctionRex: [/up/,/left/,/down/,/right/],
	EnemyTankTypes: [
		{
			name: "armor",
			score: 500,
			speed: 0.4
		},
		{
			name: "fast",
			score: 250,
			speed: 0.2
		},
		{
			name: "normal",
			score: 100,
			speed: 0.4
		}
	],
	armorTankNum: 4,
	fastTankNum: 3,
	normalTankNum: 2,
	PlayerTankReviveTimes: 5,
	EnemyTankAmount: 20
};
module.exports = GameConst;

Next, create a GameEnum.js script under the gameData folder. This script will be mainly used to record various enumeration data in the game. Example code:

var GameEnum = {
    TankFlag: cc.Enum({
        Player: 0,
        Enemy: 1
    }),
    TileType: cc.Enum({
        tileNone: 0, 
        tileGrass: 1, 
        tileSteel: 2, 
        tileWall: 3,
        tileRiver: 4, 
        tileKing: 5
    }),
    TileGroup: cc.Enum({
        default: 0,
        playerBullet: 1,
        playerTank: 2,
        enemyTank: 3,
        enemyBullet: 4
    })
};

module.exports = GameEnum;

In Cocos Creator, open the menu Project -> Project Settings…, select the Group Manager interface, make the following settings changes and click Save when finished:

cc.game.groupList is responsible for recording the group data in this game.


2. Layout of the game start interface

First, preview the game start interface as it is currently:

1571134226766

Use the following image to set the Canvas component of the Canvas node:

1571135093072

Set the game start scene according to the design resolution. Add a node by referring to the node tree in the following figure:

The name of the Atlas resource is required for this interface to start.

Create Start.js in the ./assets/ directory and add it to the Canvas node. This script is primarily responsible for handling user actions on the game start interface. Example code:

var GameConfig = require("./gameData/GameConfig");
cc.Class({
    extends: cc.Component,

    properties: {
        gamePlayerCount: {
            default: null,
            type: cc.ToggleContainer
        },
    },

    start () {
        this.initGameLoginView();
    },
    
	// initialize the game start interface
    initGameLoginView () {
        switch (GameConfig.PlayerNum) {
            case 1:
                this.gamePlayerCount.node.getChildByName("Toggle OnePlayer").getComponent(cc.Toggle).isChecked = true;
                break;
            case 2:
                console.log(this.gamePlayerCount.node);
                this.gamePlayerCount.node.getChildByName("Toggle DoublePlayer").getComponent(cc.Toggle).isChecked = true;
                break;
        }
    },
	
	// Select a callback for the number of players
    onGamePlayerNumToggleChecked (event, CoustomEventData) {
        switch (CoustomEventData) {
            case "onePlayer":
                GameConfig.PlayerNum = 1;
            break;
            case "doublePlayer":
                GameConfig.PlayerNum = 2;
            break;
        }
    },
});

The Property Inspector interface binding for this user component:

1571139651485

The Property Inspector interface for binding the Button component to the Button PlayGame node will look like this:

1571135988842

The script events shown will be explained in Section 4.

The Property Inspector interface for binding the Toggle component to the Toggle OnePlayer node will look like this:

1571136089188

The Property Inspector interface for the Toggle DoublePlayer node will look like this:

1571136183310

Adding the CCBlockInputEvents component to the Nodes Login Game node prevents accidental access to the game’s battle interface. The Property Inspector panel will look like this:

After the above steps are complete, the starting interface for this game is complete. We can move on to the next steps.


3. Write an object pool management script

During this game, there will be constant demand for node destruction and creation. This includes the creation and destruction of bullets. Node creation and destruction each have a series of functions that need to be executed in-order to completely erase the data of the node object. Executing this code affects game performance. When a large number of nodes are repeatedly created and destroyed, game performance is degraded. In Cocos Creator, the CCNodePool object pool module is designed to provide a solution to this problem. When it is used, node creation and destruction will not be completely executed. When the node is removed from the scene graph, the node is also cached. In the node pool, the next time you use it, the performance is much faster than before because of the caching that happens. Create NodePoolManager.js in the ./assets/scripts/components directory. Use the CCNodePool in the same manner as demonstrated here:

let NodePoolManager = {
    _nodePools:[],
    _nodePoolNames:["player_bullet","player_tank","enemy_tank","enemy_bullet"],
    
    initNoedPools: function () {
        for (let i = 0; i < this._nodePoolNames.length; i++) {
            this.createNodePool(this._nodePoolNames[i]);
        }
    },

    createNodePool: function (name){
        if (!this.getNodePool(name) && !this.getNodeElement(name)) {
            let nodePool = new cc.NodePool(name);
            this._nodePools.push(nodePool);
            return nodePool;
        }
        else {
            return null;
        }
    },

    getNodePool: function (name) {
        if (this._nodePools.length > 0) {
            for (let i = 0; i < this._nodePools.length; i++) {
                if (this._nodePools[i].poolHandlerComp === name) {
                    return this._nodePools[i];
                }
            }
            return null;
        }
    },

    getNodeElement (name) {
        let nodePool = this.getNodePool(name);
        if (nodePool) {
            let nodeElement = nodePool.get();
            return nodeElement;
        }
        else {
            return null;
        }
    },

    putNodeElemenet (name, element) {
        let nodePool = this.getNodePool(name);
        if (nodePool) {
            nodePool.put(element);
        }
    },
};

module.exports = NodePoolManager;

4. Initializing the battle scene

Create a Nodes Play GameP node under the Canvas node, which will mainly be responsible for rendering the core battle interface. The page layout and the node tree for this node is as follows:

After creating the nodes and adjusting the layout of the interface, create Game.js in the ./assets/ directory and add it to the Canvas node. This script is mainly used to perform operations, such as, game initialization, processing game flow, and processing player input and output. Example code, we also write the property parameters that the project will use in the script:

var GameEnum = require("./gameData/GameEnum");
var GameConst = require("./gameData/GameConst");
var GameConfig = require("./gameData/GameConfig");
var NodePoolManager = require("./components/NodePoolManager");
cc.Class({
    extends: cc.Component,

    properties: {
        loginGame: {
            default: null,
            type: cc.Node
        },
        playGame: {
            default: null,
            type: cc.Node
        },
        tankWarMap:{
            default: null,
            type: cc.TiledMap
        },
        tankSpriteAtlas: {
            default: null,
            type:  cc.SpriteAtlas
        },
        anyTank: {
            default: null,
            type: cc.Prefab
        },
        bullet: {
            default: null,
            type: cc.Prefab
        },
        gameMenu: {
            default: null,
            type: cc.Node
        },
        enemyTankBornPosition: {
            default: [],
            type: cc.Vec2
        },
        playerTankBornPosition: {
            default: [],
            type: cc.Vec2
        },
        _initialRound: 1,
        _enemyTankAmount: 0,
        _playerTankReviveTimes: 0,
        _playing: false,
        _playerTank: [],
        _gameScore: 0,
    },
});

The Property Inspector shows how to bind this component:

The resource path of Node AnyTank is ./assets/res/prefab/Node AnyTank.prefab, and the Property Inspector panel of the layout and the CCSprite components is as follows:

The resource path for Node Battle is ./assets/res/prefab/Node Buttle.prefab, and the Property Inspector panel for layout and the CCSprite components is as follows:

Add the onPlayGameButtonClicked function to Game.js to start the click event for the Button PlayGame button. Example:

    onPlayGameButtonClicked () {
        // Hide the game start interface
        this.loginGame.opacity = false;
        this.loginGame.y += this.node.height;
        // Display the game battle interface
        this.playGame.opacity = 255;
        // Play the game start sound, SoundManager.js will be written in Part 5
        this.node.getComponent("SoundManager").playStartGameEffectSound();
        // Perform game initialization
        this.initGame();
        // Execute the game to start
        this.startGame();
    },

Next, the initGame function can be created as:

    initGame() {
        // object pool initialization
        NodePoolManager.initNoedPools();
    },

Next, the startGame function can be created as:

startGame () {
        // Mark the game to start fighting
        this._playing = true;
        // Initialize the game menu information
        this.initGameMenuInfo();
        // Empty the tank
        this.clearTanks();
        // According to the size of EnemyTankAmount to generate the corresponding number of enemy player tanks
        for (let i = 0; i < GameConst.EnemyTankAmount; i++) {
            // Only generate two enemy tanks at a time
            if (i < 2) {
                // Generate enemy tanks at the specified location
                this.createEnemyTank(this.enemyTankBornPosition[i]);
            }
        }
        // According to the size of PlayerNum to generate the corresponding 
        // number of player tanks
        for (let i = 0; i < GameConfig.PlayerNum; i++) {
            // Generate player tank in the specified location
            let playerTank = this.createPlayerTank(this.playerTankBornPosition[i]);
            // Save the object data of the player tank to _playerTank
            this._playerTank.push(playerTank);
        }
        // Initialize the game listener
        this.initListenHandlers();
    },

Next, the initGameMenuInfo function can be created as:

initGameMenuInfo() {
        this.gameMenu.getChildByName("Label Tank Remaining Number").getComponent(cc.Label).string = this._enemyTankAmount = GameConst.EnemyTankAmount;
        this.gameMenu.getChildByName("Label Revive Times Number").getComponent(cc.Label).string = this._playerTankReviveTimes = GameConst.PlayerTankReviveTimes;
    },

Next, the clearTanks function can be created as:

    clearTanks () {
        this._playerTank = [];
        for (let i = 0; i < this.tankWarMap.node.getChildByName("tank").childrenCount; i++) {
            NodePoolManager.putNodeElemenet(this.tankWarMap.node.getChildByName("tank").children[i].group, this.tankWarMap.node.getChildByName("tank").children[i]);
        }
    }

Next, the createEnemyTank function can be created as:

    createEnemyTank (position) {
        // Determine whether the game is in the battle process
        if (this.node.getComponent("Game")._playing) {
            // Get the tank node object in the cache from the object pool
            this.enemyTank = NodePoolManager.getNodeElement(cc.game.groupList[3]);
            // Create a new tank node object when there are no available tank node 
            // objects in the cache
            if (!this.enemyTank) {
                this.enemyTank = cc.instantiate(this.anyTank);
            }
            // Place the node at the specified coordinates
            this.enemyTank.setPosition(position);
            // Add the tank node to the map
            this.tankWarMap.node.getChildByName("tank").addChild(this.enemyTank);
        }
    },

Next, the createPlayerTank function can be created as:

    createPlayerTank (position) {
        // Determine whether the game is in the battle process
        if (this.node.getComponent("Game")._playing) {
            // Get the tank node object in the cache from the object pool
            this.playerTank = NodePoolManager.getNodeElement(cc.game.groupList[2]);
            // Create a new tank node object when there are no available tank node
            // objects in the cache
            if (!this.playerTank) {
                this.playerTank = cc.instantiate(this.anyTank);
            }
            // Place the node at the specified coordinates
            this.playerTank.setPosition(position);
            // Add the tank node to the map
            this.tankWarMap.node.getChildByName("tank").addChild(this.playerTank);
            // will create a successful tank node object is thrown
            return this.playerTank;
        }
    },

Next, the initListenHandlers function can be created as:

    initListenHandlers () {
        cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN, this.onPlayerKeyDownCallback, this);
    },

The code for onPlayerKeyDownCallback is not being fully implemented now. Section 5 will continue to build out this code. For now, use:

onPlayerKeyDownCallback () {},

Before the player’s control logic for the tank is perfected, after executing the above code, the game runs as follows:

20191015

While the game battle interface was rendered correctly, the tanks also appeared in the designated positions.


5. Tank initialization and management

In this game, different types of tanks need to be generated at the same time. We will make a base type of tank node into a Prefab resource, and then we add this Prefab the ability to transform types and styles at any time.

First, create TankManager.js script in the ./assets/scripts/components/tank directory. Example:

var GameEnum = require("../../gameData/GameEnum");
var GameConst = require("../../gameData/GameConst");
var NodePoolManager = require("../NodePoolManager");
cc.Class({
    extends: cc.Component,

    editor:{
        // executeInEditMode: true
    },

    properties: {
        isAuto: false,
        isCanMove: true,
        tankSpriteAtlas: {
            default: null,
            type: cc.SpriteAtlas
        },
        tankFlag: {
            default: GameEnum.TankFlag.Player,
            type: GameEnum.TankFlag,
            notify: function () {
                this.updateTank(this.tankFlag);
            }
        },
        enemyTankSpriteFrames: {
            default: [],
            type: cc.SpriteFrame
        },
        playerTankSpriteFrames: {
            default: [],
            type: cc.SpriteFrame
        },
        tankDirection: {
            default: GameConst.Dirction[0],
            notify: function () {
                this.updateTankSpriteFrame(this.tankDirection);
            }
        },
        actionSpeed: {
            default: 0.05,
            type: cc.Float,
            range: [0,3,0.01]
        },
        changeDirectionStep: {
            default: 10,
            type: cc.Integer,
            range: [0,100,1]
        },
        bullet: {
            default: null,
            type: cc.Prefab
        },
        _bornPosition: null,
    },

    // LIFE-CYCLE CALLBACKS:

    // onLoad () {},

    start () {
        if (!CC_EDITOR) {
            // Initialize the automatic walking of the tank
            this.initTankAutoActionManager();
        }
    },

    onEnable () {
        if (this.node.group === cc.game.groupList[2]) {
            // Play the opening animation of the tank
            this.node.getComponent(cc.Animation).play();
        }
    },

    // Initialize the various parameters of the tank
    initTank(tankFlag, direction, group, auto, position) {
        // Set the tank ID (according to GameEnum.TankFlag)
        this.tankFlag = tankFlag;
        // Set the tank wind direction
        this.tankDirection = direction;
        // Set the tank node grouping
        this.node.group = group;
        // Set whether to allow automatic behavior
        this.isAuto = auto;
        // Record the initial coordinates of the birth of the node
        this._bornPosition = position;
        // Set the node coordinates
        this.node.setPosition(position);
        // Change the node sprite map according to the direction of the node
        this.updateTankSpriteFrame(direction);
    },

    // Update the data bound to the tank
    updateTank (type) {
        // Create a new variable to receive a new sprite map object
        var newSpriteFrame = null;
        // Only tanks of type Enemy need to be replaced with different textures
        if (type === GameEnum.TankFlag.Enemy) {
            // Randomly get the serial number of the new map
            var spriteFrameIndex = Math.floor(Math.random() * this.enemyTankSpriteFrames.length);
            // Get a random map
            newSpriteFrame = this.enemyTankSpriteFrames[spriteFrameIndex];
            // Get the tank configuration matching the map name from the current
            // tank type
            for (let i = 0; i < GameConst.EnemyTankTypes.length; i++) {
                // Matching process
                if (newSpriteFrame.name.indexOf(GameConst.EnemyTankTypes[i].name) !== -1) {
                    // Get score data
                    this._score = GameConst.EnemyTankTypes[i].score;
                    // Get speed data
                    this.actionSpeed = GameConst.EnemyTankTypes[i].speed;
                }
            }
        }
        else {
            // Set as the default tank wizard map
            newSpriteFrame = this.playerTankSpriteFrames[0];
        }
        // Let the CCSprite component render the new texture
        this.node.getComponent(cc.Sprite).spriteFrame = newSpriteFrame;
    },

    // Update the map of the tank wizard
    updateTankSpriteFrame (newDirection) {
        // Get the rendering component
        var tankSprite = this.node.getComponent(cc.Sprite);
        // Record the name of the old map
        var oldSpriteFrameName = tankSprite.spriteFrame.name;
        // Create a new variable to record the name of the new map
        var newSpriteFrameName = null;
        // Traverse the map name array
        for (let i = 0; i < GameConst.DirctionRex.length; i++) {
            // Find an item from the map name array that matches the old map and
            // return a new map name
            newSpriteFrameName = oldSpriteFrameName.replace(GameConst.DirctionRex[i], newDirection);
            // If the name of the new and old maps are not the same, then jump out
            // of this loop
            if (newSpriteFrameName !== oldSpriteFrameName) {
                break;
            }
        }
        // According to the name of the new map to get the corresponding map 
        // resource in the map
        if (this.tankSpriteAtlas.getSpriteFrame(newSpriteFrameName)) {
            tankSprite.spriteFrame = this.tankSpriteAtlas.getSpriteFrame(newSpriteFrameName);
        }
    },

    // Initialize the tank automatic behavior
    initTankAutoActionManager() {
        // Turn on the timer
        this.schedule(this.timerCallBack, this.actionSpeed);
    },

    // Timer callback
    timerCallBack () {
        // Determine whether automatic behavior is allowed
        if (this.isAuto) {
            // Change direction after every n steps
            if (!this._changeDirectionStep || this._changeDirectionStep < 0) {
                // change direction
                this.changeTankDirection();
                // Update the current number of steps
                this._changeDirectionStep = this.changeDirectionStep;
            }
            else {
                // Automatically fire bullets when there are only half of the steps
                if (this._changeDirectionStep === Math.floor(this.changeDirectionStep / 2)) {
                    // fire bullets
                    this.lauchBullet(cc.game.groupList[4]);
                }
                // If the tank encounters an obstacle in the map
                if (!cc.find("Canvas").getComponent("TiledMapManager").onTileMovedEvent(this.node)) {
                    // Change the direction of the tank
                    this.changeTankDirection();
                }
                // The number of steps is automatically reduced by one
                this._changeDirectionStep--;
            }
            // Let the tank move through the map
            cc.find("Canvas").getComponent("TiledMapManager").onTileMovedEvent(this.node);
        }
    },

    // Change the direction of the tank itself
    changeTankDirection () {
        // Get a random serial number
        var newDircetionIndex = Math.floor(Math.random() * GameConst.Dirction.length);
        // Set the direction of the tank
        this.node.getComponent("TankManager").tankDirection = GameConst.Dirction[newDircetionIndex];
    },

    // 发射子弹
    lauchBullet (group) {
        // Get the bullet object cache from the object pool
        var bullet = NodePoolManager.getNodeElement(group);
        // if there is no object in cache
        if (!bullet) {
            // Instantiate a bullet node
            bullet = cc.instantiate(this.bullet);
        }
        // Set the direction of the bullet
        bullet._direction = this.node.getComponent("TankManager").tankDirection;
        // Add bullets to the scene
        cc.find("Canvas").getComponent("Game").tankWarMap.node.getChildByName("bullet").addChild(bullet);
        // Perform the initialization of the bullet node
        bullet.getComponent("BulletManager").initBullet(this.node, group);
        // Play sound effects, this section will be explained in Section 9.
        cc.find("Canvas").getComponent("SoundManager").playEffectSound("shoot", false);
    },

    // Stop the timer of the current component
    onUnschedule () {
        this.unschedule(this.timerCallBack);
    },

    onDisable () {
        // Close the current node's listen event in the ColliderManager component
        this.node.targetOff(this.node.getComponent("ColliderManager"));
    }
});

Next, add a script component to the Node AnyTank node of the Node AnyTank prefab. Its Property inspector should be as follows:

1571209420292

Also, we can add an animation to the birth of the player tank:

Follow these steps:

  • create a child node, Sp Shield under the Node AnyTank node
  • add a CCSprite component
  • find the shield_0 texture in the ./assets/res/texture/ directory
  • add it to the SpriteFrame property panel of the CCSprite component above

Render the node as follows:

After that:

  • remove the active check of the Sprite Shield node
  • return to the Node AnyTank node
  • add the CCAnimation component to it
  • create an animationClip resource in the ./assets/res/anima/ directory, named start, which is mainly used to play the tank Admission animation.

The Property inspector panel for the CCAnimation component is as follows:

1571212575077

The start sequence animation is set in the Animation editor. If you don’t know how to use the Animation editor, you can refer to this document: Animation system:

After the above steps are complete, the game behaves as follows:

20191016


6. Control the tank moving in the map

We need to use the global system events in Cocos Creator to control the player’s input and output operations. Global system events refer to various global events that are not related to the node tree. They are uniformly distributed by cc.systemEvent. Currently, the following events are supported:

  • keyboard events
  • device gravity sensing events.

In this project, there are a few rules:

Player 1:

  • controls the tank moving up, left, down, and right by pressing w, a, s, and d on the keyboard.
  • launches bullets by pressing the Space key.

Player 2:

  • controls the tank moving up, left, down, and right by pressing the arrow keys on the keyboard.
  • launches bullets by pressing the PgUp key.

We need to assign the keys to the proper event to be fired. Example:

onPlayerKeyDownCallback (event) {
        switch (event.keyCode) {
            case cc.macro.KEY.w:
                // This is the listener event belonging to player 1
                if (!this._playerTank[0]) return;
                // Set the steering of the player 1 tank
                this._playerTank[0].getComponent("TankManager").tankDirection = GameConst.Dirction[0];
                // Transfer the tank object after the state change to the tank map
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[0]);
            break;
            case cc.macro.KEY.a: 
                if (!this._playerTank[0]) return;
                this._playerTank[0].getComponent("TankManager").tankDirection = GameConst.Dirction[1];
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[0]);
            break;
            case cc.macro.KEY.s:
                if (!this._playerTank[0]) return;
                this._playerTank[0].getComponent("TankManager").tankDirection = GameConst.Dirction[2];
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[0]);
            break;
            case cc.macro.KEY.d:
                if (!this._playerTank[0]) return;
                this._playerTank[0].getComponent("TankManager").tankDirection = GameConst.Dirction[3];
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[0]);
            break;
            case cc.macro.KEY.up :
                // This is the listener event belonging to player 1
                if (!this._playerTank[1]) return;
                // set the sterring for player 1
                this._playerTank[1].getComponent("TankManager").tankDirection = GameConst.Dirction[0];
                // Transfer the tank object after the state change to the tank map
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[1]);
            break;
            case cc.macro.KEY.left: 
                if (!this._playerTank[1]) return;
                this._playerTank[1].getComponent("TankManager").tankDirection = GameConst.Dirction[1];
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[1]);
            break;
            case cc.macro.KEY.down:
                if (!this._playerTank[1]) return;
                this._playerTank[1].getComponent("TankManager").tankDirection = GameConst.Dirction[2];
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[1]);
            break;
            case cc.macro.KEY.right:
                if (!this._playerTank[1]) return;
                this._playerTank[1].getComponent("TankManager").tankDirection = GameConst.Dirction[3];
                this.node.getComponent("TiledMapManager").onTileMovedEvent(this._playerTank[1]);
            break;
            case cc.macro.KEY.space:
                if (!this._playerTank[0]) return;
                // Player 1 fires a bullet
                this._playerTank[0].getComponent("TankManager").lauchBullet(cc.game.groupList[1]);
                break;
            case cc.macro.KEY.pageup:
                if (!this._playerTank[1]) return;
                // Player 2 fires a bullet
                this._playerTank[1].getComponent("TankManager").lauchBullet(cc.game.groupList[1]);
                break;
        }
    },

Next, the BulletManager.js function can be created as:

var NodePoolManager = require("../NodePoolManager");
cc.Class({
    extends: cc.Component,

    properties: {
        lauchStep: {
            default: 1,
            type: cc.Float,
            range: [0,1,0.01]
        }
    },

    // LIFE-CYCLE CALLBACKS:

    // onLoad () {},

    start () {
        
    },

    // Initialize bullets
    initBullet (tank, group) {
        // Set the node grouping
        this.node.group = group;
        // Set the node coordinates
        this.node.position = tank.position;
        // Start the timer
        this.schedule(this.timerCallBack, this.lauchStep);

    },

    timerCallBack (direction) {
        // Set the node activity boundary
        if (this.node.position.x === -208 || this.node.position.x === 204 || this.node.position.y === -204 || this.node.position.y === 208) {
            // Remove the node from the node tree and cache the node object into 
            // the object pool
            NodePoolManager.putNodeElemenet(this.node.group, this.node);
            return;
        }
        else {
            // Let the bullet move in the map
            cc.find("Canvas").getComponent("TiledMapManager").onTileMovedEvent(this.node);
        }
    },

    onDisable () {
        // stop the timer
        this.unschedule(this.timerCallBack);
    }
    // update (dt) {},
});

Next, let the tank move on the map. We need to create a map management script, TiledMapManager.js in the ./assets/scripts/components/ directory. This script is mainly responsible for the behavior of the tank on the map. Example:

var NodePoolManager = require("./NodePoolManager");
var GameEnum = require("./gameData/GameEnum");
var GameConst = require("../gameData/GameConst");
cc.Class({
    extends: cc.Component,

    properties: {
        mainTiledMap: {
            default: null,
            type: cc.TiledMap
        },

        tiledMapAssetSet: {
            default: [],
            type: cc.TiledMapAsset
        },

        tiledMapAssets: {
            default: [],
            type: cc.TiledMapAsset
        },
        _interimPos:[],

    },

    start () {
        // Initialize the map data
        this.initTiledMapData();
    },

    initTiledMapData () {
        // initialize layer_0、layer_1
        this.mainLayer = this.mainTiledMap.getLayer("layer_0");
        this.secondaryLayer = this.mainTiledMap.getLayer("layer_1");
    },

    // used to transform the movement of the node in the coordinates of the 
    // node into a map coordinate movement
    onTileMovedEvent (tileNode, callback) {
        // If the tank is not allowed to move, do not execute internal logic
        if (tileNode.getComponent("TankManager") && !tileNode.getComponent("TankManager").isCanMove) {
            return;
        }
        // Create a variable for the initial coordinates of the cache node 
        // under the node axis
        var startPos = cc.v2(tileNode.position.x, tileNode.position.y);
        // Create a variable to cache the direction of the tank
        var tankDirection = null;
        // If the node is not a tank
        if (!tileNode.getComponent("TankManager")) {
            // Set the direction
            tankDirection = tileNode._direction;
        }
        // If the node is a tank
        else {
            // Set the direction
            tankDirection = tileNode.getComponent("TankManager").tankDirection;
        }
        // Reset the coordinates of the node according to the direction obtained
        if (tankDirection === GameConst.Dirction[0]) {
            startPos.y += tileNode.height;
        }
        else if(tankDirection === GameConst.Dirction[2]){
            startPos.y -= tileNode.height;
        }
        else if (tankDirection === GameConst.Dirction[1]) {
            startPos.x -= tileNode.width;
        }
        else if (tankDirection === GameConst.Dirction[3]) {
            startPos.x += tileNode.width;
        }
        // Convert node coordinates to map coordinates
        var tilePos = this.getTilePositionAt(tileNode, startPos);
        // Set the map boundary
        if (tilePos.y <= this.mainLayer.getLayerSize().height - 1 && tilePos.x <= this.mainLayer.getLayerSize().width - 1
         && tilePos.y >= 0 && tilePos.x >= 0) {
            // Get the tile ID corresponding to the current map coordinates
            var tileGID = this.mainLayer.getTileGIDAt(tilePos);
            // Get the type corresponding to the current tile according to
            // GameConst.GidToTileType, and then compare with GameEnum.TileType, 
            // the corresponding changes of different types of tiles are different
            // Here deal with tileWall type tiles
            if (GameConst.GidToTileType[tileGID] === GameEnum.TileType.tileWall) {
                // According to the grouping of nodes to perform different logic, 
                // here the node grouping is tankBullet
                if (tileNode.group === cc.game.groupList[1] || tileNode.group === cc.game.groupList[4]) {
                    // Reset the tile
                    this.mainLayer.setTileGIDAt(0, tilePos.x, tilePos.y);
                    // Remove the tank from the node tree and put the node object 
                    // into the object pool
                    NodePoolManager.putNodeElemenet(tileNode.group, tileNode);
                }
                // tank
                else if (tileNode.group === cc.game.groupList[2] || tileNode.group === cc.game.groupList[3]) {
                    return false;
                }
            }
            // Here deal with tileWall type tiles
            else if (GameConst.GidToTileType[tileGID] === GameEnum.TileType.tileSteel) {
                if (tileNode.group === cc.game.groupList[1] || tileNode.group === cc.game.groupList[4]) {
                    // Play the steel sound, this part is explained in Section 9.
                    cc.find("Canvas").getComponent("SoundManager").playEffectSound("steel", false);
                    NodePoolManager.putNodeElemenet(tileNode.group, tileNode);
                }
                // tank
                else if (tileNode.group === cc.game.groupList[2] || tileNode.group === cc.game.groupList[3]) {
                    return false;
                }
            }
            else if (GameConst.GidToTileType[tileGID] === GameEnum.TileType.tileRiver) {
                if (tileNode.group === cc.game.groupList[2] || tileNode.group === cc.game.groupList[3]) {
                    return false;
                }
            }
            else if (GameConst.GidToTileType[tileGID] === GameEnum.TileType.tileKing) {
                if (tileNode.group === cc.game.groupList[1] || tileNode.group === cc.game.groupList[4]) {
                    // Execute the game end logic
                    cc.find("Canvas").getComponent("Game").onGameOverEvent();
                    NodePoolManager.putNodeElemenet(tileNode.group, tileNode);
                }
                // tank
                else if (tileNode.group === cc.game.groupList[2] || tileNode.group === cc.game.groupList[3]) {
                    return false;
                }
            }
            // Set the converted node coordinates to the node
            tileNode.position = startPos;
            if (typeof callback === "function") {
                // Execute callback function
                callback();
            }
            //return node object
            return tileNode;
        }
        else {
            return false;
        }
    },

    getTilePositionAt (tileNode, position) {
        // Convert the node coordinates of the node into world coordinates
        var worldPosition = tileNode.parent.convertToWorldSpaceAR(position);
        // Get the height and height data of the map node
        var mapSize = this.node.getContentSize();
        // or the width and height of the map
        var tileSize = this.mainTiledMap.getTileSize();
        // Calculate the coordinates of the node in the map, and round down
        var x = Math.floor(worldPosition.x / tileSize.width);
        var y = Math.floor((mapSize.height - worldPosition.y) / tileSize.height);
        return cc.v2(x, y);
    },

    // update (dt) {},
});

Add the component to the Canvas node, below is the Property inspector panel for the component, and the TiledMapAsset resource is in the ./assets/res/map directory:

1571217426613

After completing this part of the game, the gameplay is something similar to this:

20191017


7. Add collision logic for bullets and tanks

Cocos Creator provides a Collision System to handle the collisions between the bullet and the tank. We set the groups grouping in the first section of this tutorial and now we can edit ColliderManager.js. Example:

var GameEnum = require("../gameData/GameEnum");
var NodePoolManager = require("../components/NodePoolManager");
cc.Class({
    extends: cc.Component,

    properties: {
    },

    onLoad () {
        // Get the global collision system management object of the game
        var colliderManager = cc.director.getCollisionManager();
        // Open collision management
        colliderManager.enabled = true;
        // draw the border of the collision box
        // colliderManager.enabledDebugDraw = true;
    },

    // Callback when triggering a collision
    onCollisionEnter (other, self) {
        // Remove the node from the node tree and cache the node object into 
        // the object pool
        NodePoolManager.putNodeElemenet(this.node.group, this.node);
        // Grouping different processing different logic
        if (this.node.group === cc.game.groupList[2]) {
            // The number of rebirths of the player tank is reduced by one.
            --cc.find("Canvas").getComponent("Game")._playerTankReviveTimes;
            // Update the game information on the right side of the battle interface
            cc.find("Canvas").getComponent("Game").updateGameMenuInfo(this.node, GameEnum.TankFlag.Player);
            // Play playerTankBoom sound, the sound system is explained in Section 9.
            cc.find("Canvas").getComponent("SoundManager").playEffectSound("playerTankBoom", false);
        }
        else if (this.node.group === cc.game.groupList[3]) {
            // The number of enemy tanks is reduced by one.
            --cc.find("Canvas").getComponent("Game")._enemyTankAmount;
            // Update the game information on the right side of the battle interface
            cc.find("Canvas").getComponent("Game").updateGameMenuInfo(this.node, GameEnum.TankFlag.Enemy);
            // Calculate the score the player gets in the game
            cc.find("Canvas").getComponent("Game").updateScore(this.node.getComponent("TankManager")._score);
            // Play enemyTankBoom sound effect
            cc.find("Canvas").getComponent("SoundManager").playEffectSound("enemyTankBoom", false);
        }
    },

    // The callback executed at the end of the collision
    onCollisionExit (other, self) {
        if (this.node.group === cc.game.groupList[0]) {
            
        }
        else if (this.node.group === cc.game.groupList[0]) {

        }
    },

    initGroup () {}
    // update (dt) {},
});

We only need to add this component to the Node AnyTank node of the Node AnyTank prefab, and the Node Buttle node of the Node Buttle prefab. Then add the CCBoxCollider component to the node with the ColliderManager.js script component. The Property inspector interface is as follows:

1571220278417

1571220427137

After completing this part of the game, the collision between the bullet and the tank can occur. Shall we see the effect:

20191018


8. Game decision making

After the tank collides, decisions will be made to decide what shape the player is in. We need to add the following function to Game.js:

updateGameMenuInfo (targetNode, tankTag) {
        // Update the game information on the right side of the battle interface
        if (cc.find("Canvas").getComponent("Game")._enemyTankAmount > 0 && cc.find("Canvas").getComponent("Game")._playerTankReviveTimes > 0) {
            //Create a new tank
            this.createNewTank(targetNode, tankTag);
        }
        else if (cc.find("Canvas").getComponent("Game")._enemyTankAmount > 0 && cc.find("Canvas").getComponent("Game")._playerTankReviveTimes === 0){
            // Perform the game failure logic
            this.onGameOverEvent("lose");
        }
        else if (cc.find("Canvas").getComponent("Game")._enemyTankAmount === 0 && cc.find("Canvas").getComponent("Game")._playerTankReviveTimes > 0) {
            // Go to the next level
            this.onNextRoundEvent();
        }
        else if (cc.find("Canvas").getComponent("Game")._enemyTankAmount === 0 && cc.find("Canvas").getComponent("Game")._playerTankReviveTimes === 0){
            // Go to the next level
            this.onNextRoundEvent();
        }
    },

Next, the createNewTank function can be created as:

    createNewTank (targetNode, tankTag) {
        // Create different tanks by type
        if (tankTag === GameEnum.TankFlag.Enemy) {
            cc.find("Canvas").getComponent("Game").gameMenu.getChildByName("Label Tank Remaining Number").getComponent(cc.Label).string = cc.find("Canvas").getComponent("Game")._enemyTankAmount;
            cc.find("Canvas").getComponent("Game").createEnemyTank(targetNode.getComponent("TankManager")._bornPosition);
        }
        else if (tankTag === GameEnum.TankFlag.Player) {
            cc.find("Canvas").getComponent("Game").gameMenu.getChildByName("Label Revive Times Number").getComponent(cc.Label).string = cc.find("Canvas").getComponent("Game")._playerTankReviveTimes;
            cc.find("Canvas").getComponent("Game").createPlayerTank(targetNode.getComponent("TankManager")._bornPosition);
        }
    },

Next, the onGameOverEvent function can be created as:

    // Game end processing
    onGameOverEvent (command) {
        // Update the game state
        this._playing = false;
        // Perform different logic according to the specified
        if (command === "win") {
            // Play begin sound
            this.node.getComponent("SoundManager").playEffectSound("begin", false);
            console.log("[Game Win]");
        }
        else if (command === "lose") {
            // Play gameOver sound effect
            cc.find("Canvas").getComponent("SoundManager").playEffectSound("gameOver", false);
            console.log("[Game Lose]");
        }
        // Restart the game
        this.restartGame();
    },

Next, the onNextRoundEvent function can be created as:

    // Go to the next level
    onNextRoundEvent () {
        // Update the level value
        ++this._initialRound;
        // The level value can not be greater than the number of maps 
        // built into the game
        if (this._initialRound <= this.node.getComponent("TiledMapManager").tiledMapAssets.length) {
            // Replace map resources
            this.tankWarMap.tmxAsset = this.node.getComponent("TiledMapManager").tiledMapAssets[this._initialRound - 1];
            // Update the level value text information
            this.gameMenu.getChildByName("Label Round Number").getComponent(cc.Label).string = this._initialRound;
            // Initialize the map data
            this.node.getComponent("TiledMapManager").initTiledMapData();
            // Play the game to start sound
            this.node.getComponent("SoundManager").playStartGameEffectSound();
            // Execute the game start logic
            this.startGame();
        }
        else {
            this.onGameOverEvent("win");
        }
    },

Next, the restartGame function can be created as:

    // restarting the game
    restartGame () {
        // Update the game state
        this._playing = false;
        // Logout listener event
        this.unListenHandlers();
        // Clear the player tank array cache
        this._playerTank = [];
        // Turn off all sound effects of the game
        this.node.getComponent("SoundManager").stopAll();
        // Load game scene
        cc.director.loadScene("game");
    },

Now, the game can return to the game start interface when the game fails, and enter the next level when the game is leveled up.


9. Adding background music and sound management

We control the various audio playback operations of the game through the SoundManager.js script. Example:

cc.Class({
    extends: cc.Component,
    editor: {
        menu:"CustomComponent/AudioControl",
    },
    properties: {
        backGroupSound: {
            default: null,
            type: cc.AudioClip
        },

        loop: true,
        
        soundsVolume: {
            default: 1,
            range: [0,1,0.01],
            notify: function() {
                this.setSoundsVolume();
            }
        },

        effectsVolume: {
            default: 1,
            range: [0,1,0.01],
            notify: function () {
                this.setEffectsVolume();
            }
        },

        audioClipPool: {
            default: [],
            type: cc.AudioClip
        },
        
        _isPlaying: false,
        _audioId: null,
        _effectId: null,
    },

    // Play background music
    playBackGroupSound (callback) {
        if (this.backGroupSound) {
            // Pause the music being played
            cc.audioEngine.stopAll();
            // Play music
            this._audioId = cc.audioEngine.play(this.backGroupSound, this.loop, this.soundsVolume);
            // Execute callback when playback is complete
            if (callback && typeof callback === "function") {
                cc.audioEngine.setFinishCallback(this._audioId, callback);
            }
        }
    },

    // According to different instructions to play different sound effects
    playEffectSound (command, loop = this.loop, callback) {
        if (command !== null && command !== undefined || this.audioClipPool.length > 0) {
            switch (command) {
                case "begin":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[0], loop);
                    break;
                case "nmoving":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[1], loop);
                    break;
                case "moving":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[2], loop);
                    break;
                case "shoot":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[3], loop);
                    break;
                case "steel":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[4], loop);
                    break;
                case "enemyTankBoom":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[5], loop);
                    break;
                case "playerTankBoom":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[6], loop);
                    break;
                case "gameOver":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[7], loop);
                    break;
                case "pause":
                case "resume":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[8], loop);
                    break;
                case "bouns":
                    this._effectId = cc.audioEngine.playEffect(this.audioClipPool[9], loop);
                    break;
                default:
                    console.error("Command is invalid");
            }
        }
        if (typeof callback === "function") {
            // Execute callback when playback is complete
            cc.audioEngine.setFinishCallback(this._effectId, callback);
        }
    },

    // pause music
    pauseMusic () {
        cc.audioEngine.pauseAll();
    },

    // resue music
    resumeMusic () {
        cc.audioEngine.resumeAll();
    },

    // set sound volume
    setSoundsVolume() {
        if (this._audioId) {
            cc.audioEngine.setVolume(this.soundsVolume);
        }
    },

    // set effects volume
    setEffectsVolume () {
        if (this._effectId) {
            cc.audioEngine.setEffectsVolume(this.effectsVolume);
        }
    },

    // stop all
    stopAll () {
        cc.audioEngine.stopAll();
        this._audioId = null;
        this._effectId = null;
    },

    // play game effects sounds
    playStartGameEffectSound () {
        this.playEffectSound("begin", false, ()=>{
            this.playEffectSound("nmoving", true);
        });
    },
});

Add it to the Canvas node. The Property inspector panel looks like this:

1571227326639

Add a sliding callback to the two CCSlider components, Slider BGM Volume and Slider Effect Volume, so that they can adjust the volume each time they slide. Add the onSliderSwitchEvent function to the Game.js script with the following code:

// Volume control slider
onSliderSwitchEvent (slider, CustomEventData) {
    // Create a new variable cache slider corresponding value
    let volume = slider.progress;
    // Perform different logic according to the slider's custom parameters
    if (CustomEventData === "bgm") {
        // Set the background music volume
        this.node.getComponent("SoundManager").soundsVolume = volume;
    }
    else if (CustomEventData === "effect") {
        // Set the effect volume
        this.node.getComponent("SoundManager").effectsVolume = volume;
    }
},

Bind the script to the sliding callbacks of the Slider BGM Volume and Slider Effect Volume components. The Property inspector panel looks like this:

IMG_0496

1571229170529

This completes the audio control operation of the game.


10. Add game pause, resume, and replay features

Add the onPauseOrResumeGameEvent function to the Game.js script to control the progress of the game. The code is as follows:

// Pause or resume the game
onPauseOrResumeGameEvent () {
    // Change the label of the game battle in progress
    this._playing = !this._playing;
    // Non-combat time
    if (!this._playing) {
        // Play pause sound
        cc.find("Canvas").getComponent("SoundManager").playEffectSound("pause", false);
        // Suspend all tanks
        for (let i = 0; i< this.tankWarMap.node.getChildByName("tank").childrenCount; i++) {
            // Forbidden tank movement
            this.tankWarMap.node.getChildByName("tank").children[i].getComponent("TankManager").isCanMove = false;
            //pause timer
            cc.director.getScheduler().pauseTarget(this.tankWarMap.node.getChildByName("tank").children[i].getComponent("TankManager"));
        }
        //Change the button text
        this.gameMenu.getChildByName("Button Game Pause").getChildByName("Label Button").getComponent(cc.Label).string = "resume";
        console.log("[Game Pause]");
    }
    else {
        // Play recovery sound
        cc.find("Canvas").getComponent("SoundManager").playEffectSound("resume", false);
        // Restore all tanks
        for (let i = 0; i< this.tankWarMap.node.getChildByName("tank").childrenCount; i++) {
            // Allow tanks to move
            this.tankWarMap.node.getChildByName("tank").children[i].getComponent("TankManager").isCanMove = true;
            // Recovery timer
            cc.director.getScheduler().resumeTarget(this.tankWarMap.node.getChildByName("tank").children[i].getComponent("TankManager"));
        }
        // change the button text
        this.gameMenu.getChildByName("Button Game Pause").getChildByName("Label Button").getComponent(cc.Label).string = "pause";
        console.log("[Game Resume]");
    }
},

Set onPauseOrResumeGameEvent to the click callback of the Button Game Pause button. The Property inspector interface is as follows:

1571227962953

Create a new CCButton component node named Button Game Restart. Bind it to the restartGame function in Game.js as the clickback end of the callback. The Property inspector interface is as follows:

1571228602680

This completes the process control for this game.


11. Conclusion

We have now created TankWar. Take a look at the game performance (here the number of enemy tanks is adjusted to 5 for the convenience of testing):

GIF

There are still a lot of optimizations that can take place in this game. You can download the complete project