更新日期:2026年8月12日。 项目源码:获取源码。
索引
- 坦克的核心功能
-
- 一、坦克类
- 二、坦克基础属性
- 三、坦克模板
-
- 1.玩家坦克
- 2.敌人坦克
- 四、更新坦克图形
- 五、坦克诞生过程
-
- 1.诞生动画
- 2.诞生计时
- 六、坦克无敌效果
-
- 1.无敌帧
- 2.无敌帧渲染画面
- 七、坦克移动
-
- 1.对齐到网格
- 2.设置坦克位置
- 3.移动计算
- 4.限制坦克超出地图
- 5.碰撞地块检测
坦克的核心功能
本篇我们介绍坦克的核心功能实现,在上一篇的基础上,整个关卡地图已经创建完成,下一步便是创建坦克单位,实现游戏真正的核心玩法。
一、坦克类
定义坦克类TB_Tank,由于其需要进行帧同步,所以继承至我们前文中提到的LogicFrameBehaviour(支持逻辑帧更新的行为组件):
/// <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);
//逻辑帧更新,游戏逻辑相关的功能在此处理
}
}
二、坦克基础属性
坦克类拥有如下基础属性:
/// <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代表携带道具的红坦克。
/// <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星):低移速,高射速,每多携带一个道具就多一个护盾,每多一个星级就多一个护盾。
四、更新坦克图形
在坦克的基础属性中我们可以看到,很多属性的改变都将触发图形脏状态,在下一渲染帧便会自动更新图形(根据坦克的类型、星级等更新指定的图形):
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形式):
//诞生动画图像
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秒,因为属于游戏玩法逻辑,所以其需要在逻辑帧循环中进行计时:
//诞生帧计时器
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.无敌帧
定义一个无敌帧属性:
/// <summary>
/// 当前的无敌帧(大于0则表示处于无敌状态中)
/// </summary>
public int InvincibleFrame
{
get
{
return _invincibleFrame;
}
set
{
_invincibleFrame = value;
_isUpdateGraphic = true;
}
}
该无敌帧会在逻辑帧循环中持续衰减:
public override void OnLogicFrame(int frameIndex)
{
base.OnLogicFrame(frameIndex);
if (InvincibleFrame > 0)
{
InvincibleFrame -= 1;
}
}
2.无敌帧渲染画面
回到更新坦克图形的方法中,判断当无敌帧>0时,便启用无敌的渲染画面:
/// <summary>
/// 更新坦克图形
/// </summary>
private void UpdateGraphic()
{
//……
_invincibleRoot.gameObject.SetActive(InvincibleFrame > 0);
}
其渲染图像也即是在坦克的图像上覆盖了一层波动的白色条纹:

七、坦克移动
坦克移动时会碰到地块,砖墙、钢板等地块会阻碍坦克的移动,由于我们不能使用碰撞检测来实现(物理效果在不同的设备上会有不确定性,使用碰撞盒的触发器检测也不行,因为其也是基于float运算的),那么我们只能自己实现简单的碰撞检测。
1.对齐到网格
首先,坦克基于当前的朝向进行转向时(比如上下转左右朝向),需要对齐到网格,何为对齐到网格呢?
我们先来看整个关卡地图,被划分为了13*13个地块(也即是格子):

坦克在某一横向、纵向道路上移动时,自身相对该行道路的位置只能有两种情况:
- 1.完全处于道路中间;
- 2.处于道路两边。
完全处于道路中间的情况(红框标记的道路):

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

所以我们只需要在坦克转向时,且是从横向转纵向,或纵向转横向时,才需要进行对齐到网格的操作。
/// <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);
}
在移动前,判断是否触发了转向,再进行对齐操作:
/// <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即可:
/// <summary>
/// 在地图上的位置
/// </summary>
public Vector2Int Pos
{
get
{
return _pos;
}
private set
{
_pos = value;
Rect.anchoredPosition = _pos;
}
}
Pos代表的是坦克渲染图形在地图上的位置(也即是渲染位置),其不能直接参与游戏逻辑计算,仅做渲染显示。
那么我们必须再定义一个真实的坦克位置属性RealPos,主要用于逻辑计算,而LastPos用于记录上一帧的位置,以便于渲染位置在两帧间插值实现平滑效果:
/// <summary>
/// 当前移动帧的上一次记录位置
/// </summary>
public Vector2Int LastPos { get; private set; }
/// <summary>
/// 当前移动帧的真实位置
/// </summary>
public Vector2Int RealPos { get; private set; }
立即设置坦克位置的方法(该方法用于需要立即设置坦克位置的情况,比如坦克生成后,对齐到网格后):
/// <summary>
/// 设置坦克的位置
/// </summary>
public void SetPos(Vector2Int pos)
{
//同步设置真实位置及渲染位置
Pos = LastPos = RealPos = pos;
}
3.移动计算
移动计算时,便针对真实位置RealPos进行计算:
/// <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.限制坦克超出地图
经过移动计算后,新的坦克真实位置需要限制在地图范围内:
/// <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联机坦克大战(五)坦克的核心功能(下)。
网硕互联帮助中心




评论前必须登录!
注册