[3D game development practice] Interpretation of Cocos Cyberpunk source code - full solution of game logic framework

Cocos Cyberpunk is a complete open source TPS 3D game launched by the official team of the Cocos engine to demonstrate the engine's heavy-duty 3D game production capabilities and enhance the community's learning motivation. It supports Web, IOS, and Android multi-terminal releases.

This series of articles will interpret the source code from various aspects to improve everyone's learning efficiency. I hope it can help you to go further on the road of 3D game development.

Project source code free download page:
https://store.cocos.com/app/detail/4543

Qilinzi thinks that this article can at least save you several days of research time.

Do not believe? look down!

Table of contents

In fact, this article was not long at the beginning. When it was almost finished, the boss in charge of the Cocos Cyberpunk Gameplay part told me that this part will continue to be adjusted.

It was only then that Qilinzi realized that too many logical details would lose their reference value as the version was updated.

After learning about some follow-up plans from the boss, Qilinzi decided to overthrow and start over, and make a guide from the perspective of code writing methods and logical operation mechanism:

  • 1. Preloading process
  • 2. 【Key points】Data and Action
  • 3. Game module guide
  • 4. Introduction to the Level module
  • 5. Actor module guide
  • 6. Protagonist control
  • 7. Character animation mask and IK
  • 8. Monster generation mechanism
  • 9. Item drop mechanism
  • 10、…

preload process

Entry script init.ts

The entry script init.ts was briefly mentioned in the previous article . It will detect whether the device supports WEBGL , and give a prompt if it does not support it. It will also set the init node as a resident to ensure the normal execution of the game logic.

Next, we mainly focus on the following initialization function:

...
// Load the resource cache data and execute the initialize game function.
ResCache.Instance.load(async () => {
    
    
    console.time('loadTextures')
    await loadTextures();
    console.timeEnd('loadTextures')
    Game.Instance.init();
});
...

It can be seen that init.ts will first call ResCache.load to initialize, and execute the callback after the initialization is completed.

In the callback function, call the loadTextures function to preload all textures in the resources/textures directory.

Finally, call Game.Instance.init to start the game logic.

ResCache

ResCache is a singleton class that is responsible for loading and caching required resources.

In addition to providing the loadXXX series of methods, it also provides the getXXX series of methods, and all preloaded resources can be used directly.

Enter the ResCache.load function and you can see that it first loads the data/data-res-cache.json file, and then preloads the corresponding json , sprite and sound according to the configuration in the file .

At the same time, ResCache.load will release the msg_loading event to the outside, and after UILoading listens to this event, it will be displayed.

In the loading phase, ResCache also assumes the responsibility of loading progress statistics, which can be seen at a glance by checking where the addLoad and removeLoad methods are called.

When resource preloading ends, the scene will switch from scene-game-start to scene scene, and resources/ui/ui-logo.prefab will be displayed .

【Key points】Data and Action

There should be many friends, like Qilinzi, who tried to find the codes related to scene switching and UI display, but they couldn't find them.

After consulting the big guy in charge of Gameplay, Qilinzi suddenly realized: the whole set of game logic code refers to the node design mechanism of the behavior tree, and is driven based on data (data) and behavior (action) .

The player in the game is an object, the enemy is an object, and the global manager game and level are also objects.

Data

Each object has a data-xxx.json configuration file, you can find them under resources/data , for example:

  • game: data-game.json
  • level: data-level.json
  • player: data-player.json
  • enemy: data-enemy_1.json
  • boss: data-boss_0.json

The data fields in these files have a one-to-one correspondence with the classes used to parse it. for example:

  • game -> game.ts
  • level -> level.ts
  • player -> actor.ts
  • enemy -> actor.ts
  • boss -> actor.ts

Therefore, you will find that the content format of data-player.json , data-enemy_1.json and data-boss_0.json is similar. But with data-game.json , data-level.json is very different.

Action

Each object has an action-xxx.json configuration file, you can find them under resources/action , for example:

  • game: action-game.json
  • level: action-level.json
  • player: action-player.json
  • enemy: action-enemy_1.json
  • boss: action-boss_0.json

Similar to Data , the content of the Action configuration file is also related to the class that executes it, and similar configuration formats will be used for the same type.

In each Action file, a series of Action Nodes are defined .

Objects can trigger different Action Nodes according to conditions .

Each Action Node has two commands: start and end .

start will be executed when the Action Node enters, and end will be executed when the Action Node leaves.

When each start and end command is executed, multiple operations can be performed in sequence.

The class used to parse the action-xxx.json configuration file is in the action.ts script.

Entering action.ts , we can see that the Action class has two main functions:

  • on : Execute the start command
  • off : Execute the end command

In UtilAction , all operations that start and end can perform are defined . for example:

  • on_ui/off_ui : open/close UI
  • on_inst_pool : Initialize the object pool
  • on_bgm : play background music
  • on_scene : switch scene

Next, we use the game object to describe the operation mechanism of Data & Action.

Game module

The three main files involved in the Game module:

  • data-game.json
  • action-game.json
  • game.ts

Let's take a look at the key content of the two json configuration files:

data-game.json

{
    
    
    ...
    "fps":60,
    "start_node":"logo",
    "action_data":"action-game",
    "version":"version:202302271037",
    "show_version":"V1.2",
    ...
    "res_ui_root":"ui/",
    ...
}

In data-game.json , we can see that it defines many things, such as limited frame rate, version number and so on.

Here we should also pay attention to the two values ​​of start_node and action_data , which are the key data of the game startup process.

  • action_data : used to specify the corresponding Action configuration file
  • start_node : Used to specify the default Action Node.

action-game.json

{
    
    
    "logo": {
    
    
        "start": [ ... ],
        "end": [ ... ]
    },
    "level": {
    
    
        "start": [ ... ],
        "end": [ ... ]
    },
    ...

You can see that many actions are defined in action-game.json , the most critical two are:

  • logo : The state when the logo and the start button are displayed
  • level : level status, at this time you can operate the protagonist to fight

game.ts

In the init method of game.ts , we can see the following two codes

// Initialize the game data.
this._data = dataCore.DataGameInst._data;
// Initialize game action data.
this._action = new Action(this._data.action_data);
...
// Push the game initial node into the stack data.
this.push(this._data['start_node']);

The first line of code is to get the configuration data loaded from data-game.json .

The second line of code is to use action_data (the value is ' action-game.json ') to create an Action.

The third line of code is to use start_node (the value is logo ) as the initial action of the game .

Let's take a look at the specific content of the logo node in action-game.json :

{
    
     "time": 0, "name": "on_ui", "data": "ui_logo" },
{
    
     "time": 0.1, "name": "on_inst_pool", "data": "gun_tracer_pool"},
{
    
     "time": 0.15, "name": "on_inst_pool", "data": "sfx_heart"},
{
    
     "time": 0.2, "name": "on_inst_pool", "data": "random-fly-car"},
{
    
     "time": 0.3, "name": "on_inst_pool", "data": "level_events"},
{
    
     "time": 0.4, "name": "on_bgm", "data": "bgm_logo"},
{
    
     "time": 0.5, "name": "on_scene", "data": "scene"}

Translated into the execution process is:

  • show ui_logo
  • Initialize the object and put it into the object pool
  • Play background music bgm_logo
  • Switch to the scene scene

Tips : The unit of the previous time is seconds, which is used for frame processing. If you want to finish processing quickly, you can fill in all 0.

At this point, the introduction of the Game module is over. I believe that with the above explanation, the code reading of the Game module will be much easier.

Level module

The Level module is responsible for state control related to the level, and it has no specific behavior.

It is just a container that connects functions such as the protagonist, monsters, item drops, pathfinding, and archives.

When the user clicks the START button, the level Action in action-game.json will be executed .

"level": {
    
    
    "start": [
        {
    
     "time": 0, "name": "on_bgm", "data": "bgm_level"},
        {
    
     "time": 0, "name": "on_ui", "data": "ui_fx" },
        {
    
     "time": 0, "name": "on_msg", "data": "msg_level_start" },
        {
    
     "time": 0.2, "name": "on_msg_str", "data": {
    
     "key":"msg_set_camera", "value": false } },
        {
    
     "time": 2, "name": "on_ui", "data": "ui_level" }
    ],
    ...
}

As you can see, the following operations are done here:

  • Play background music bgm_level
  • Display UI ui_fx
  • send event msg_level_start
  • Send event msg_set_camera, false
  • Show UI ui_level

The Level module also involves three main files:

  • data-level.json
  • action-level.json
  • level.ts

data-level.json

{
    
    
    "name":"Cyberpunk Scene",
    "total_time":300,
    "close_blood_fx": true,
    "each_rate_value":10000,
    "killed_to_score":100,
    "survival_time_to_score":10,
    "level_events":"level_events",
    "prefab_player":"player-tps",
    "prefab_enemy":"enemy-tps",
    "prefab_drop_item":"drop_item",
    "enemies":["enemy_0", "enemy_1", "enemy_2", "boss_0"],
    "fx_dead":"fx_dead_white",
    "score_level":[...],
    "items":[...],
    "probability_drop_enemy":{
    
    ...},
    "probability_drop_items":{
    
    ...},
    "cards":["life", "attack", "defense", "special"],
    "probability_drop_card":{
    
    ...}
}

We can see that in the data configuration file of the Level module, it contains:

  • kill score
  • respawn time
  • prefab information
  • monster information
  • Drop Item Information
  • score rating

By modifying these configurations, you can change the level data and gameplay.

action-level.json

{
    
    
    "start":{
    
    ...},
    "warning":{
    
     ... },
    "end":{
    
    ...}
}

The Action configuration file of the level is relatively simple, and there are only three situations: start , warning and end .

"start":{
    
    
    "start":[
        {
    
     "time": 0.2, "name": "on_msg_str", "data": {
    
     "key":"level_do", "value":"addPlayer" } },
        {
    
     "time": 0.5, "name": "on_inst", "data": "level_events_enemy" },
        {
    
     "time": 0.6, "name": "on_inst", "data": "level_events_items" }, 
        {
    
     "time": 0.7, "name": "on_inst", "data": "level_events_card" }
    ]
},

In the start naming, we can see that when the level is loaded, the following operations will be performed:

  • on_msg_str:level_do, addPlayer
  • on_inst:level_events_enemy
  • on_inst:level_events_items
  • on_inst:level_events_card

on_msg_str

From the UtilAction class of action.ts , we can see that the method prototype of on_msg_str is:

  public static on_msg_str (data: key_type_string) {
    
    
      Msg.emit(data.key, data.value);
  }

It is not difficult to see that its function is to send out an event with a parameter.

Next, let's take a look at how to perform these operations in the script code.

{
    
     "time": 0.2, "name": "on_msg_str", "data": {
    
     "key":"level_do", "value":"addPlayer" }

Here it is: issue a level_do message with the parameter addPlayer .

Open level.ts and we can see that in its init function, this message is monitored:

Msg.on('level_do', this.do.bind(this));

The prototype of the do function in level.ts is as follows:

public do (fun: string) {
    
    
    this[fun]();
}

It can be seen from this that the first operation in action-level.json is actually to call the addPlayer function of Level .

on_inst

From the UtilAction class of action.ts , we can see that the method prototype of on_msg_str is:

    public static on_inst (key: string, actor: Actor) {
    
    
        var asset = ResCache.Instance.getPrefab(key);
        var obj = Res.inst(asset, Level.Instance._objectNode);
        if (actor && actor._viewRoot) {
    
    
            obj.parent = actor._viewRoot!;
            obj.setPosition(0, 0, 0);
        }
    }

As can be seen from the above prototype, the on_inst method is to instantiate a specified object.

The specified object parameter is the name of the prefab .

  • level_events_enemy
  • level_events_items
  • level_events_cards

They are all prefabs , and the corresponding resources are in the assets/resources/obj directory.

During the preloading phase, ResCache will load all the prefabs in the directory and store them under the prefab name. When using it, you can directly use the name query.

For specific logic, please refer to loadPrefab , setPrefab and getPrefab in the ResCache class .

Actor module

Next, let's talk about the protagonist control , monster generation , and pick-up item generation that everyone is most concerned about .

But before talking about these things, let's take a look at the Actor module.

In the previous content, we mentioned that when you open action-player.json , action-enemy_1.json , and action-boss_0.json
files, you will find that their content formats are similar.
This is because the classes corresponding to the protagonist , monster , and BOSS are all Actors .

Open the data-player.json or data-enemy_1.json file, you can see the action configuration file name , sound effect , maximum HP and other information.

{
    
    
    "name":"actor-enemy_0",
    "action":"action-enemy_0",
    "sfx_walk_ground":"sfx_walk_ground",
    "sfx_walk_grass":"sfx_walk_grass",
    ...
    "strength": 100,
    "max_strength": 100,
    "max_hp":60,
    ...

Open the corresponding action-xxx.json , and you can see commands such as play , jump , dead , and pickup .

Whereas each command executes the following:

  • on_call : Call the specified function name on the current actor
  • on_set : Sets the specified variable value on the current actor
  • on_anig : Sets variables for the animation graph on the current actor
  • on_sfx : play sound effects

For the specific function prototype, you can check it by yourself in action.ts .

The carrier of the above actions is Actor .

protagonist control

initialization

The prefab path used by the protagonist is resources/obj/player-tps.prefab , which is configured in data-player.json .

We continue from the addPlayer function in level.ts .

public addPlayer () {
    
    
    //获得 player 的 prefab
    const prefab = ResCache.Instance.getPrefab(this._data.prefab_player);
    //通过 prefab 生成 player 实例
    const resPlayer = Res.inst(prefab, this._objectNode!, this._data.spawn_pos);
    // 获取 Actor 组件
    this._player = resPlayer.getComponent(Actor)!;
    // 标记这个 actor 为主角
    this._player.isPlayer = true;
    // 使用 data-player.json 初始化这个 actor
    this._player.init('data-player');
}

The above are the key steps in addPlayer . Next, let's continue to look at what this._player.init code does.

This code will first call ActorBase.init to complete some basic initialization work, and then call Actor.initView .

In the last step in Actor.initView , the play Action will be called to start the object.

In action-player.josn , we can see the content of play as follows:

"play":{
    
    
    "start":[
        {
    
     "time": 1, "name": "on_call", "data": "onUpdate"},
        {
    
     "time": 1.5, "name": "on_inst_scene", "data": "actor_input"},
        {
    
     "time": 1.5, "name": "on_com", "data":"ActorStatistics"},
        {
    
     "time": 1.5, "name": "on_com", "data":"ActorSound"},
        {
    
     "time": 2, "name": "on_msg_str", "data": {
    
     "key":"msg_set_input_active", "value": true} }
    ]
},

It does the following things:

  • Call the onUpdate function
  • Instantiate actor_input object
  • Add ActorStatistics and ActorSound components for player
  • Send msg_set_input_active event to activate input

user input

{
    
     "time": 1.5, "name": "on_inst_scene", "data": "actor_input"},

The above statement executes the instantiation of resources/obj/actor_input.prefab and adds it to the scene node.

There is an ActorInput component on actor_input.prefab , and its function is to process user input.

Enter the actor-input.ts file, in the initInput method of the ActorInput class , we can see that it will make platform judgments. When on the mobile side, the joysitck mode will be activated. Otherwise, use the mouse and keyboard to operate.

input-joystick.ts and input-keyboard.ts will not directly control the character, but will hand over all operation instructions to the ActorInput component for processing.

Implement all user operations in ActorInput , such as onMove , onJump , onRotation , onRun, etc.

protagonist camera

In player-tps.prefab , we can find the camera-root node, and three components are hung on this node:

CameraTps : Used to control the up and down rotation of the camera. Since the positive direction of the camera is always aligned with the protagonist, there is no need to control left and right.

CameraMoveTarget : used to control the easing effect of the camera

SensorRayNodeToNode : used for collision detection between cameras and scenes

Character animation masks and IK

In shooting games, there are two main functions:

  • move while shooting
  • Change the posture of the end gun according to the direction of the camera

It is also implemented in Cocos Cyberpunk .

character animation mask

Cocos Creator provides animation graph function, and provides layered animation and mask function.

Layered animation : Simply put, users can play multiple animations at the same time and mix them through weights.

Animation mask : Marks which bones need to be updated when the animation is playing.

In Cocos Cyberpunk , there are two layers of base and fire for the animation graph of the characters (the protagonist and the monster) .

It can be seen that the weight of fire is 1.0. Since the index of fire is lower in the hierarchical diagram, the priority is higher, so when base and fire are played at the same time, fire will replace the animation.

If we want to achieve shooting while moving, what we want is that the base layer plays movement , the fire layer plays shooting , and fire only affects the upper body.

At this time, you need to use the animation mask.

Double-click to open anig_player_mask and you can see that all upper body bones are selected. That is to say, when the fire action is playing, it will only update the upper half shot.

Role IK

As shown in the picture above, we need to change the posture of the end gun according to the direction of the camera to ensure the shooting experience of the game.

This requires the use of our IK .

Find the actor-player-tps node in player-tps.prefab , and you can see that two components are hung on it:

AimIK : Used to bind IK-related data. The aiming posture of the gun is mainly affected by the three joints spine01 , spine02 and spine03 on the spine , which are referenced in the component.

AimControl : used to achieve aiming control

IK- related codes are in the scripts/core/ik directory, interested friends can study in depth.

Monster Generation Mechanism

configuration

Let's start with the start Action in action-level.json :

{
    
     "time": 0.5, "name": "on_inst", "data": "level_events_enemy" },

When the Level starts, an instance of level_events_enemy will be created . There is only one instance in the level, and it is the manager responsible for monster generation and destruction.

Resources related to it:

  • resources/obj/level_events_enemy.prefab
  • resources/obj/boss_0.prefab
  • resources/obj/enemy_0.prefab
  • resources/obj/enemy_1.prefab
  • resources/obj/enemy_2.prefab
  • level_events_enemy.ts

Open level_events_enemy.ts and you can see that the probability_drop_enemy in data-level.json is used as the data source in LevelEventsEnemy , the content is as follows:

"probability_drop_enemy":{
    
    
    "interval":[2, 4],
    "interval_weight_max":1,
    "life_time":[30, 40],
    "max": 4,
    "init_count":[7, 10],
    "weights":[0.4, 0.7, 0.9, 1],
    "weights_max":[4, 4, 4, 1],
    "weights_group":[ 0, 1, 2, 3]
},

As you can see, it defines the refresh interval, maximum number of monsters, and more.

generate

The main function in LevelEventsEnemy is generateEvent , which will determine whether the number of monsters needs to be generated.

If eligible for spawning, it randomizes a monster's appearance from the enemies in data-level.json .

"enemies":["enemy_0", "enemy_1", "enemy_2", "boss_0"],

After getting the appearance, a random behavior group is combined into data, and the msg_add_enemy event is sent.

const currentIndex = this.probability.weights_group[occurGroupIndex];
const res = DataLevelInst._data.enemies[currentIndex];
// Send add add enemy.
Msg.emit('msg_add_enemy', {
    
     res: res, groupID: occurGroupIndex })

Also note that it will also issue a warning when the boss appears. (Qilinzi thinks it might be better to match this in action-boss_0.json )

  if (res == 'boss_0'){
    
    
      Msg.emit('level_action', 'warning');
  } 

At the same time, it will also respond to the msg_remove_enemy event to handle the monster being deleted.

Then, in addEnemy , randomly select an available location point from the pathfinding system for monster creation:

  //从寻路系统中获取一个可用点
  const point = NavSystem.randomPoint();
  //创建 enemy
  var enemy = ResPool.Instance.pop(data.res, point.position); 
  const actor = enemy.getComponent(Actor);
  //初始化怪物数据      
  actor.init(`data-${
      
      data.res}`);

monster AI

Open boss_0.prefab or enemy_0.prefab , you can see two components related to monster AI :

ActorBrain

The real monster AI driver will perform corresponding actions according to the surrounding environment.

ActorInputBrain
adapter, conveniently transfer monster AI behavior to ActorInput .

Pickup item drop mechanism

configuration

Similar to level_events_enemy , after the Level is loaded successfully, a manager for pickup items will be generated:

{
    
     "time": 0.6, "name": "on_inst", "data": "level_events_items" }, 

Resources related to it:

  • resources/obj/level_events_items.prefab
  • resources/obj/drop_item.prefab
  • level_events_items.ts
  • drop-item.ts

In level_events_enemy.ts , the probability_drop_items field in data-level.json will be used as configuration data:

    "probability_drop_items":{
    
    
        "interval":[5, 30],
        "life_time":[30, 40],
        "max": 4,
        "interval_weight_max": 1,
        "init_count":[7, 10],
        "weights":[0, 0.125,0.25, 0.375,0.75,1],
        "weights_max":[1, 1, 1, 1,2,2],
        "weights_group":[0, 1,2, 3,4,5]
    },

It can be seen that it is similar to monster generation, and some generation-related parameters are also configured.

generate

In level_events_items.ts , the actual item generation will not be processed, it will listen to the msg_remove_item event and adjust its own data.

When it is determined that a new item can be generated, the msg_remove_item event will be sent.

In level.ts , this event will be listened to and the addDrop method will be executed.

In the addDrop method, an instance of drop_item.prefab is generated .

pick up

On the sensor_detect_drop node of the protagonist player-tps.prefab , there are two components, SensorRays and ActorSensorDropItem .

SensorRays : Periodically check the objects around the protagonist and store them in the checkedNode variable.

ActorSensorDropItem : It will make some judgments, update the state, and save checkedNode as pickedNode .

There is a UIDisplayByState component on grp_take_info in ui_level.prefab , which will detect this state and display the prompt " Press E to pick up ".

When the item is picked, the Actor 's onPicked method will be called and the picked event will be emitted.

The DropItem component on drop_item.prefab responds to the picked event and destroys and recycles itself.

Pick up automatically

A CheckAutoPick component is hung on the protagonist player-tps.prefab . When it is on the mobile side, it will automatically call the Actor 's onPicked method.

last few words

Since the UI system and the pathfinding system involve too much content, two new articles will be devoted to explaining them.

I hope this article can help friends who want to study the source code of the Cocos Cyberpunk project.

The Gameplay that the boss spent several months writing is obviously something Qi Linzi couldn't understand in two days.

I hope that more friends can study together and produce some learning experiences and tutorials together, so that the open source project Cocos Cyberpunk can develop better.

In the next article, no accident, I will write a custom pipeline , so stay tuned.

Attachment 1: Class name <—> file name

In the Cocos Cyberpunk project, the class name adopts the big hump naming method, that is, the first letter is capitalized.

For the ts file corresponding to the class name, use lowercase words and connect them with "-".

for example:

  • UILoading -> ui-loading.ts
  • CameraController -> camera-controller.ts

Master this correspondence to ensure that you can quickly locate the desired code.

Attachment 2: Two singleton writing methods

Careful friends will find that there are two singleton writing methods in Cocos Cyberpunk .

One class is traditional and suitable for all object-oriented languages: Singleton .

Access through class name.Instance, such as:

  • UI.Instance
  • ResCache.Instance
  • Level.Instance
  • GameSet.Instance

The advantage of this is that it can be called at any time and will be initialized when it is called for the first time .

But the bad thing is: all classes will be directly referenced by different files. When the class name is modified or files are moved, there will be a lot of files involved .

The other type is suitable for languages ​​such as TS, JS, and C++ that support global variables. Refer to data-core.ts :

import {
    
     DataEquip } from "../../logic/data/data-equip";
import {
    
     DataSound } from "../../logic/data/data-sound";
import {
    
     DataCamera } from "./data-camera";
...

export const DataEquipInst = new DataEquip();
export const DataSoundInst = new DataSound();
export const DataCameraInst = new DataCamera();
...

export function Init () {
    
    
    //Init data.
    DataEquipInst.init('data-equips');
    DataSoundInst.init('data-sound');
    DataCameraInst.init('data-camera');
}

This way of writing is quite flexible to use.
can be used like this:

import * as dataCore from "./data-core";
dataCore.DataEquipInst.foo();

can also be used like this:

import {
    
     DataEquipInst } from '../data/data-core';
DataEquipInst.foo();

The advantage of this way of writing is that all referenced places only rely on one container file, and the impact of refactoring is very small .

But there are also some minor disadvantages: there may be multiple people maintaining the same container file at the same time, and a suitable place is needed to call the initialization function .

In contrast, Qilinzi prefers the second one, which will make the code easier to maintain.

**Tips:** In fact, the second method is not new, it is the same as holding each instance object in a static class. It is only possible to write in this way thanks to language features such as TS that can use global variables.

Guess you like

Origin blog.csdn.net/qq_36720848/article/details/129869309