【Unity】TankBattle联机坦克大战(四)坦克的核心功能(上)

更新日期:2026年8月12日。

项目源码:获取源码。

索引

坦克的核心功能

本篇我们介绍坦克的核心功能实现,在上一篇的基础上,整个关卡地图已经创建完成,下一步便是创建坦克单位,实现游戏真正的核心玩法。

一、坦克类

定义坦克类TB_Tank,由于其需要进行帧同步,所以继承至我们前文中提到的LogicFrameBehaviour(支持逻辑帧更新的行为组件)

csharp 复制代码
    /// <summary>
    /// 坦克
    /// </summary>
    public class TB_Tank : LogicFrameBehaviour
    {
        public override void OnRenderFrame(float factor)
        {
            base.OnRenderFrame(factor);

            //渲染帧更新,渲染相关的功能在此处理
        }
        public override void OnLogicFrame(int frameIndex)
        {
            base.OnLogicFrame(frameIndex);

            //逻辑帧更新,游戏逻辑相关的功能在此处理
        }
    }

二、坦克基础属性

坦克类拥有如下基础属性:

csharp 复制代码
        /// <summary>
        /// 是否已诞生
        /// </summary>
        public bool IsBirthed
        {
            get
            {
                return _isBirthed;
            }
            set
            {
                _isBirthed = value;
                _isUpdateGraphic = true;
            }
        }
        /// <summary>
        /// 坦克阵营
        /// </summary>
        public TankCamp Camp
        {
            get
            {
                return _camp;
            }
            set
            {
                _camp = value;
                _isUpdateGraphic = true;
            }
        }
        /// <summary>
        /// 玩家阵营身份(玩家阵营)
        /// </summary>
        public PlayerIdentity Identity
        {
            get
            {
                return _identity;
            }
            set
            {
                _identity = value;
                _isUpdateGraphic = true;
            }
        }
        /// <summary>
        /// 朝向
        /// </summary>
        public Direction Dir
        {
            get
            {
                return _dir;
            }
            set
            {
                _dir = value;
                switch (_dir)
                {
                    case Direction.Up:
                        transform.localRotation = Quaternion.Euler(0, 0, 0);
                        break;
                    case Direction.Down:
                        transform.localRotation = Quaternion.Euler(0, 0, 180);
                        break;
                    case Direction.Left:
                        transform.localRotation = Quaternion.Euler(0, 0, 90);
                        break;
                    case Direction.Right:
                        transform.localRotation = Quaternion.Euler(0, 0, -90);
                        break;
                }
            }
        }
        /// <summary>
        /// 在地图上的位置
        /// </summary>
        public Vector2Int Pos
        {
            get
            {
                return _pos;
            }
            private set
            {
                _pos = value;
                Rect.anchoredPosition = _pos;
            }
        }
        /// <summary>
        /// 星级(0-3)
        /// </summary>
        [PropertyDisplay("星级")]
        public int Star
        {
            get
            {
                return _star;
            }
            set
            {
                _star = Mathf.Clamp(value, 0, 3);
                _isUpdateGraphic = true;
            }
        }
        /// <summary>
        /// 敌人坦克类型(敌人阵营)(1-4)
        /// </summary>
        [PropertyDisplay("敌人坦克类型(敌人阵营)")]
        public int EnemyType
        {
            get
            {
                return _enemyType;
            }
            set
            {
                _enemyType = Mathf.Clamp(value, 1, 4);
                _isUpdateGraphic = true;
            }
        }
        /// <summary>
        /// 携带道具数量(敌人阵营)
        /// </summary>
        [PropertyDisplay("携带道具数量(敌人阵营)")]
        public int ItemCount
        {
            get
            {
                return _itemCount;
            }
            set
            {
                _itemCount = value;
                _isUpdateGraphic = true;
            }
        }
        /// <summary>
        /// 移动速度
        /// </summary>
        [PropertyDisplay("移动速度")]
        public int Speed { get; set; } = 0;
        /// <summary>
        /// 坦克携带的分数(敌人阵营)
        /// </summary>
        public int Score { get; set; } = 0;

三、坦克模板

坦克依旧使用Image组件绘制,在坦克模板中,预制了所有类型的坦克图像:

名称中Player代表玩家,Enemy代表敌人,P1代表玩家1,P2代表玩家2,Star代表星级,Red代表携带道具的红坦克。

csharp 复制代码
    /// <summary>
    /// 坦克
    /// </summary>
    public class TB_Tank : LogicFrameBehaviour
    {
        [SerializeField, ReadOnly, Drawer("图片")] private Sprite Player_P1_Star0_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star0_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star1_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star1_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star2_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star2_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star3_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P1_Star3_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star0_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star0_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star1_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star1_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star2_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star2_Piece2;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star3_Piece1;
        [SerializeField, ReadOnly] private Sprite Player_P2_Star3_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_1_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_1_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_1_Red_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_1_Red_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_2_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_2_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_2_Red_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_2_Red_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_3_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_3_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_3_Red_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_3_Red_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Star0_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Star0_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Star1_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Star1_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Star2_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Star2_Piece2;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Red_Piece1;
        [SerializeField, ReadOnly] private Sprite Enemy_4_Red_Piece2;
    }

1.玩家坦克

玩家1坦克(0-3星):

玩家2坦克(0-3星):

玩家坦克(0星):低移速,低射速,0护盾。

玩家坦克(1星):低移速,射速,0护盾。

玩家坦克(2星):低移速,射速,0护盾,可双发子弹

玩家坦克(3星):低移速,射速,1护盾,可双发子弹可破坏钢板

2.敌人坦克

敌人坦克1:

敌人坦克2:

敌人坦克3:

敌人坦克4(0-3星):

敌人坦克1(0星):低移速,低射速,每多携带一个道具就多一个护盾

敌人坦克2(0星):移速,低射速,每多携带一个道具就多一个护盾

敌人坦克3(0星):低移速,射速,每多携带一个道具就多一个护盾

敌人坦克4(0-3星):低移速,射速,每多携带一个道具就多一个护盾每多一个星级就多一个护盾

四、更新坦克图形

在坦克的基础属性中我们可以看到,很多属性的改变都将触发图形脏状态,在下一渲染帧便会自动更新图形(根据坦克的类型、星级等更新指定的图形):

csharp 复制代码
        public override void OnRenderFrame(float factor)
        {
            base.OnRenderFrame(factor);

            if (_isUpdateGraphic)
            {
                _isUpdateGraphic = false;
                UpdateGraphic();
            }
        }

        /// <summary>
        /// 更新坦克图形
        /// </summary>
        private void UpdateGraphic()
        {
            _birthLightRoot.gameObject.SetActive(false);
            _playerRoot.gameObject.SetActive(false);
            _enemyRoot.gameObject.SetActive(false);

            if (IsBirthed)
            {
                if (Camp == TankCamp.Player)
                {
                    _playerRoot.gameObject.SetActive(true);
                    if (Identity == PlayerIdentity.P1)
                    {
                        switch (Star)
                        {
                            case 0:
                                _playerImage1.sprite = Player_P1_Star0_Piece1;
                                _playerImage2.sprite = Player_P1_Star0_Piece2;
                                _playerRoot.sizeDelta = new Vector2(-6, -6);
                                break;
                            case 1:
                                _playerImage1.sprite = Player_P1_Star1_Piece1;
                                _playerImage2.sprite = Player_P1_Star1_Piece2;
                                _playerRoot.sizeDelta = new Vector2(0, 0);
                                break;
                            case 2:
                                _playerImage1.sprite = Player_P1_Star2_Piece1;
                                _playerImage2.sprite = Player_P1_Star2_Piece2;
                                _playerRoot.sizeDelta = new Vector2(0, 0);
                                break;
                            case 3:
                                _playerImage1.sprite = Player_P1_Star3_Piece1;
                                _playerImage2.sprite = Player_P1_Star3_Piece2;
                                _playerRoot.sizeDelta = new Vector2(0, 0);
                                break;
                        }
                    }
                    else
                    {
                        switch (Star)
                        {
                            case 0:
                                _playerImage1.sprite = Player_P2_Star0_Piece1;
                                _playerImage2.sprite = Player_P2_Star0_Piece2;
                                _playerRoot.sizeDelta = new Vector2(-6, -6);
                                break;
                            case 1:
                                _playerImage1.sprite = Player_P2_Star1_Piece1;
                                _playerImage2.sprite = Player_P2_Star1_Piece2;
                                _playerRoot.sizeDelta = new Vector2(0, 0);
                                break;
                            case 2:
                                _playerImage1.sprite = Player_P2_Star2_Piece1;
                                _playerImage2.sprite = Player_P2_Star2_Piece2;
                                _playerRoot.sizeDelta = new Vector2(0, 0);
                                break;
                            case 3:
                                _playerImage1.sprite = Player_P2_Star3_Piece1;
                                _playerImage2.sprite = Player_P2_Star3_Piece2;
                                _playerRoot.sizeDelta = new Vector2(0, 0);
                                break;
                        }
                    }
                }
                else
                {
                    _enemyRoot.gameObject.SetActive(true);
                    switch (EnemyType)
                    {
                        case 1:
                            _enemyRoot.sizeDelta = new Vector2(0, 0);
                            if (ItemCount > 0)
                            {
                                _enemyImage1.sprite = Enemy_1_Red_Piece1;
                                _enemyImage2.sprite = Enemy_1_Red_Piece2;
                            }
                            else
                            {
                                _enemyImage1.sprite = Enemy_1_Piece1;
                                _enemyImage2.sprite = Enemy_1_Piece2;
                            }
                            break;
                        case 2:
                            _enemyRoot.sizeDelta = new Vector2(0, 0);
                            if (ItemCount > 0)
                            {
                                _enemyImage1.sprite = Enemy_2_Red_Piece1;
                                _enemyImage2.sprite = Enemy_2_Red_Piece2;
                            }
                            else
                            {
                                _enemyImage1.sprite = Enemy_2_Piece1;
                                _enemyImage2.sprite = Enemy_2_Piece2;
                            }
                            break;
                        case 3:
                            _enemyRoot.sizeDelta = new Vector2(0, 0);
                            if (ItemCount > 0)
                            {
                                _enemyImage1.sprite = Enemy_3_Red_Piece1;
                                _enemyImage2.sprite = Enemy_3_Red_Piece2;
                            }
                            else
                            {
                                _enemyImage1.sprite = Enemy_3_Piece1;
                                _enemyImage2.sprite = Enemy_3_Piece2;
                            }
                            break;
                        case 4:
                            _enemyRoot.sizeDelta = new Vector2(0, 0);
                            if (ItemCount > 0)
                            {
                                _enemyImage1.sprite = Enemy_4_Red_Piece1;
                                _enemyImage2.sprite = Enemy_4_Red_Piece2;
                            }
                            else
                            {
                                switch (Star)
                                {
                                    case 0:
                                        _enemyImage1.sprite = Enemy_4_Star0_Piece1;
                                        _enemyImage2.sprite = Enemy_4_Star0_Piece2;
                                        break;
                                    case 1:
                                        _enemyImage1.sprite = Enemy_4_Star1_Piece1;
                                        _enemyImage2.sprite = Enemy_4_Star1_Piece2;
                                        break;
                                    case 2:
                                    case 3:
                                        _enemyImage1.sprite = Enemy_4_Star2_Piece1;
                                        _enemyImage2.sprite = Enemy_4_Star2_Piece2;
                                        break;
                                }
                            }
                            break;
                    }
                }
            }
            else
            {
                _birthLightRoot.gameObject.SetActive(true);
            }
        }

IsBirthed表示坦克是否已诞生完毕,未诞生时代表处于正在诞生的过程。

五、坦克诞生过程

1.诞生动画

每一个坦克实例在创建之初,都会有一个短暂的诞生过程,其类似一个四芒星闪烁动画:

当其处于诞生中时,会循环播放这个动画(pingpong形式):

csharp 复制代码
		//诞生动画图像
        private List<Image> _birthImages = new List<Image>();
        private bool _isBirthed = false;
        private float _birthTimer = 0;
        private int _birthLightIndex = 0;
        private int _birthLightDir = 1;
        
        public override void OnRenderFrame(float factor)
        {
            base.OnRenderFrame(factor);

            UpdateBirthAnimation();
        }
        /// <summary>
        /// 更新坦克诞生动画
        /// </summary>
        private void UpdateBirthAnimation()
        {
            if (!IsBirthed)
            {
                _birthTimer += Time.deltaTime;
                if (_birthTimer >= _animationInterval)
                {
                    _birthTimer -= _animationInterval;

                    _birthImages[_birthLightIndex].gameObject.SetActive(false);
                    _birthLightIndex += _birthLightDir;
                    if (_birthLightDir == 1)
                    {
                        if (_birthLightIndex >= _birthImages.Count)
                        {
                            _birthLightIndex = _birthImages.Count - 1;
                            _birthLightDir = -1;
                            _birthLightIndex += _birthLightDir;
                        }
                    }
                    else
                    {
                        if (_birthLightIndex < 0)
                        {
                            _birthLightIndex = 0;
                            _birthLightDir = 1;
                            _birthLightIndex += _birthLightDir;
                        }
                    }
                    _birthImages[_birthLightIndex].gameObject.SetActive(true);
                }
            }
        }

2.诞生计时

诞生过程持续1秒,因为属于游戏玩法逻辑,所以其需要在逻辑帧循环中进行计时:

csharp 复制代码
		//诞生帧计时器
        private int _birthNumber = 0;

        public override void OnLogicFrame(int frameIndex)
        {
            base.OnLogicFrame(frameIndex);

            if (!IsBirthed)
            {
            	//诞生中(每一帧加1)
                _birthNumber += 1;
                //当大于等于游戏逻辑帧率(LogicFPS = 30),也即是时间流逝了1秒
                if (_birthNumber >= LogicFPS)
                {
                	//诞生完毕
                    IsBirthed = true;
                    //玩家坦克赋予2秒无敌
                    if (Camp == TankCamp.Player)
                    {
                        InvincibleFrame = LogicFPS * 2;
                    }
                }
            }
            else
            {
                //诞生后......
            }
        }

六、坦克无敌效果

玩家坦克在诞生后会赋予2秒无敌效果,还有在拾取钢盔道具后,也能获得一定时长的无敌效果。

1.无敌帧

定义一个无敌帧属性:

csharp 复制代码
        /// <summary>
        /// 当前的无敌帧(大于0则表示处于无敌状态中)
        /// </summary>
        public int InvincibleFrame
        {
            get
            {
                return _invincibleFrame;
            }
            set
            {
                _invincibleFrame = value;
                _isUpdateGraphic = true;
            }
        }

该无敌帧会在逻辑帧循环中持续衰减:

csharp 复制代码
        public override void OnLogicFrame(int frameIndex)
        {
            base.OnLogicFrame(frameIndex);

            if (InvincibleFrame > 0)
            {
                InvincibleFrame -= 1;
            }
		}

2.无敌帧渲染画面

回到更新坦克图形的方法中,判断当无敌帧>0时,便启用无敌的渲染画面:

csharp 复制代码
        /// <summary>
        /// 更新坦克图形
        /// </summary>
        private void UpdateGraphic()
        {
        	//......
        	
            _invincibleRoot.gameObject.SetActive(InvincibleFrame > 0);
        }

其渲染图像也即是在坦克的图像上覆盖了一层波动的白色条纹:

七、坦克移动

坦克移动时会碰到地块,砖墙、钢板等地块会阻碍坦克的移动,由于我们不能使用碰撞检测来实现(物理效果在不同的设备上会有不确定性,使用碰撞盒的触发器检测也不行,因为其也是基于float运算的),那么我们只能自己实现简单的碰撞检测。

1.对齐到网格

首先,坦克基于当前的朝向进行转向时(比如上下左右朝向),需要对齐到网格,何为对齐到网格呢?

我们先来看整个关卡地图,被划分为了13*13个地块(也即是格子):

坦克在某一横向、纵向道路上移动时,自身相对该行道路的位置只能有两种情况:

  • 1.完全处于道路中间;
  • 2.处于道路两边。

完全处于道路中间的情况(红框标记的道路):

处于道路两边的情况,左右两边同理(红框标记的道路):

所以我们只需要在坦克转向时,且是从横向纵向,或纵向横向时,才需要进行对齐到网格的操作。

csharp 复制代码
        /// <summary>
        /// 将坦克位置水平对齐到网格(以地图、坦克尺寸的一半为单位)
        /// </summary>
        /// <param name="pos">原位置</param>
        private Vector2Int AlignToGridHorizontal(Vector2Int pos)
        {
            int y = pos.y;
            if (Dir == Direction.Up)
            {
                int remnantY = y % BlockSizeHalf;
                y -= remnantY;
            }
            else if (Dir == Direction.Down)
            {
                int remnantY = y % BlockSizeHalf;
                if (remnantY > 0)
                {
                    y += (BlockSizeHalf - remnantY);
                }
            }
            return new Vector2Int(pos.x, y);
        }
        /// <summary>
        /// 将坦克位置垂直对齐到网格(以地图、坦克尺寸的一半为单位)
        /// </summary>
        /// <param name="pos">原位置</param>
        private Vector2Int AlignToGridVertical(Vector2Int pos)
        {
            int x = pos.x;
            if (Dir == Direction.Right)
            {
                int remnantX = x % BlockSizeHalf;
                x -= remnantX;
            }
            else if (Dir == Direction.Left)
            {
                int remnantX = x % BlockSizeHalf;
                if (remnantX > 0)
                {
                    x += (BlockSizeHalf - remnantX);
                }
            }
            return new Vector2Int(x, pos.y);
        }

在移动前,判断是否触发了转向,再进行对齐操作:

csharp 复制代码
        /// <summary>
        /// 移动
        /// </summary>
        /// <param name="direction">移动方向</param>
        public void Move(Direction direction)
        {
        	//还未诞生完毕,无法控制移动
            if (!IsBirthed)
                return;

            //坦克原本为上下朝向
            if (Dir == Direction.Up || Dir == Direction.Down)
            {
                //更改为左右朝向
                if (direction == Direction.Left || direction == Direction.Right)
                {
                    //坦克水平转向,位置对齐到网格
                    SetPos(AlignToGridHorizontal(RealPos));
                }
            }
            //坦克原本为左右朝向
            else if (Dir == Direction.Left || Dir == Direction.Right)
            {
                //更改为上下朝向
                if (direction == Direction.Up || direction == Direction.Down)
                {
                    //坦克垂直转向,位置对齐到网格
                    SetPos(AlignToGridVertical(RealPos));
                }
            }
        }

2.设置坦克位置

设置坦克位置并非单纯的设置Pos即可:

csharp 复制代码
        /// <summary>
        /// 在地图上的位置
        /// </summary>
        public Vector2Int Pos
        {
            get
            {
                return _pos;
            }
            private set
            {
                _pos = value;
                Rect.anchoredPosition = _pos;
            }
        }

Pos代表的是坦克渲染图形在地图上的位置(也即是渲染位置),其不能直接参与游戏逻辑计算,仅做渲染显示。

那么我们必须再定义一个真实的坦克位置属性RealPos,主要用于逻辑计算,而LastPos用于记录上一帧的位置,以便于渲染位置在两帧间插值实现平滑效果:

csharp 复制代码
        /// <summary>
        /// 当前移动帧的上一次记录位置
        /// </summary>
        public Vector2Int LastPos { get; private set; }
        /// <summary>
        /// 当前移动帧的真实位置
        /// </summary>
        public Vector2Int RealPos { get; private set; }

立即设置坦克位置的方法(该方法用于需要立即设置坦克位置的情况,比如坦克生成后,对齐到网格后):

csharp 复制代码
        /// <summary>
        /// 设置坦克的位置
        /// </summary>
        public void SetPos(Vector2Int pos)
        {
        	//同步设置真实位置及渲染位置
            Pos = LastPos = RealPos = pos;
        }

3.移动计算

移动计算时,便针对真实位置RealPos进行计算:

csharp 复制代码
        /// <summary>
        /// 是否正在移动中
        /// </summary>
        public bool IsMoving { get; private set; } = false;
        
        /// <summary>
        /// 移动
        /// </summary>
        /// <param name="direction">移动方向</param>
        public void Move(Direction direction)
        {
            if (!IsBirthed)
                return;

            //对齐到网格......

            //记录当前移动帧真实位置
            IsMoving = true;
            Dir = direction;
            LastPos = RealPos;
            //根据坦克朝向,移动真实位置
            switch (Dir)
            {
                case Direction.Up:
                    RealPos += new Vector2Int(0, Speed);
                    break;
                case Direction.Down:
                    RealPos += new Vector2Int(0, -Speed);
                    break;
                case Direction.Left:
                    RealPos += new Vector2Int(-Speed, 0);
                    break;
                case Direction.Right:
                    RealPos += new Vector2Int(Speed, 0);
                    break;
            }
        }

4.限制坦克超出地图

经过移动计算后,新的坦克真实位置需要限制在地图范围内:

csharp 复制代码
        /// <summary>
        /// 限制位置在地图范围内
        /// </summary>
        private Vector2Int LimitPosInMap(Vector2Int pos)
        {
            pos.x = Math.Clamp(pos.x, BlockSizeHalf, MapSize - BlockSizeHalf);
            pos.y = Math.Clamp(pos.y, BlockSizeHalf, MapSize - BlockSizeHalf);
            return pos;
        }

5.碰撞地块检测

内容接下篇:【Unity】TankBattle联机坦克大战(五)坦克的核心功能(下)。

相关推荐
KindSuper_liu6 小时前
Unity 游戏网络基础:从 TCP、UDP 到 HTTP、TLS 与 Best HTTP
网络·游戏·unity
weixin_404679319 小时前
godot + vscode ai开发
ide·vscode·编辑器·游戏引擎·godot
还是大剑师兰特1 天前
Unity3D(Unity)完整详细介绍
unity·游戏引擎
ellis19701 天前
u3d插件xLua[七]例6 Coroutine
unity
还是大剑师兰特1 天前
Unity 从零搭建简易3D场景(新手分步教程)
3d·unity·游戏引擎
神码编程1 天前
【Unity】TankBattle联机坦克大战(三)创建关卡(生成地块)
unity·游戏引擎·帧同步·坦克大战
淡海水1 天前
C#与常用数据结构源码剖析-全篇导览
开发语言·数据结构·unity·面试·职场和发展·c#
玖玥拾2 天前
Unity3D RPG 入门项目(四)背包拖拽、物品悬浮提示、项目开发思维
3d·unity·游戏引擎
xcLeigh2 天前
Unity基础:Start与Update方法——Unity脚本生命周期初探
java·unity·游戏引擎·教程