游戏内容分析
SHMUP (Shoot 'em up),又称STG,即清版射击游戏, 是一种游戏类型,通常以玩家控制飞行器或角色为基础,目标是消灭敌人并躲避子弹和障碍物。SHMUP游戏通常具有快节奏的动作、丰富的视觉效果和多样化的武器系统。
image.png
本项目来自Introduction to Game Design, Prototyping, and Development 的 Space SHMUP. 含有五种敌人角色和两种武器类型, 玩家需要操纵飞船躲避敌人, 攻击敌人, 当击败敌人时可以获得加成道具.
玩家角色
image.png
玩家飞船的意义
当游戏开始时出现在地图中
当玩家飞船被消灭后游戏结束
玩家飞船的能力
在地图上移动
控制武器开火
玩家飞船与其他对象的交互
碰撞到敌人, 导致自生护盾减少, 当自身无护盾时被消灭
碰撞到加成道具, 导致武器系统发生变化
敌人角色
image.png
敌人包括五种
敌人飞船的意义
根据游戏时间进行, 敌人飞船会随机在地图中生成, 给玩家飞船的生存带来负面影响
[待扩展]将敌人生成预设为波次进攻模式, 根据关卡难度控制生成情况
敌人飞船的能力
在地图中移动, 不同的飞船有各自不同的移动能力
Enemy_0从地图上侧随机处生成, 向地图最下端直线移动, 从下侧离开地图
Enemy_1从地图上侧随机处生成, 向地图最下端移动的同时左右摇摆, 从下侧离开地图
Enemy_2从地图左右侧随机生成, 横向进入地图, 来回穿越一回合后从另一侧离开地图
Enemy_3从地图上侧随机处生成, 纵向进入地图, 抵达路径最低处后从上侧离开地图
Enemy_4从地图上侧随机处生成, 每隔一段时间, 在地图的四个象限没移动, 不会离开地图
掉落武器系统加成道具
[可扩展]获得武器并控制武器开火
敌人飞船与其他对象的交互
承受玩家武器的攻击(碰撞到子弹), 减少生命值, 当生命值归零时, 自身销毁.
Enemy_4持有护盾, 当收到攻击时, 优先消耗护盾的生命值
碰撞到玩家飞船, 导致自身销毁.
武器系统
image.png
玩家护盾虽与暂时玩家飞船绑定, 但可改造为武器系统的一部分
武器系统的意义
为玩家飞船提供清理敌人飞船的能力, 使得玩家飞船在游戏中生存下来
含有5个武器挂载点位, 为玩家提供了武器的选择和升级空间, 使游戏更加有趣
武器系统的能力
当玩家控制开火时, 武器可以以不同形式发射弹药, 而弹药可以攻击并消灭敌人
Blaster 每次开火向正前方发射一枚子弹, 子弹会向前移动直到地图边缘, 发射间隔较短
Spread 每次开火向前方扇形排布发射多枚子弹, , 子弹会向前移动直到地图边缘, 发射间隔较长
[待扩展]Missile 每次开火发射一枚导弹, 导弹可以自动跟踪敌人
武器系统与其他对象的交互
武器本身不与敌人交互, 而是通过弹药来与敌人交互, 武器的职责是是生成弹药, 当弹药接触到敌人时, 给敌人造成伤害
武器系统会根据玩家接触到的加成道具进行改变
玩家最多持有五个武器
当玩家获得的加成和已持有的武器类型一致时, 武器系统得到强化
当玩家获得的加成和已持有武器类型不一致时, 将玩家的武器替换为所加成的武器, 此时武器系统可能强化或弱化
加成道具
image.png
加成道具的意义
加成道具可以为玩家提供武器系统的加成选项
加成道具可以让玩家提供一个选择, 是否需要穿越危险的交战区, 去获得加成道具, 使得游戏局面变得复杂有趣
加成道具的能力
不同的加成道具可以给玩家提供不同的升级
Shield 增强玩家的护盾
Spread 给玩家添加Spread 武器
Blaster 给玩家添加Blaster 武器
[可扩展] 同步武器系统的武器类型, 提供加成
[可扩展] 对游戏局势产生重大效果, 如对所有敌人造成伤害
加成道具与其他对象的交互
与玩家飞船碰撞, 导致自身被消耗, 对玩家飞船造成相应影响
当敌人被消灭时, 根据概率设定, 生成不同的加成道具
[可扩展]与敌人飞船碰撞, 导致自身被销毁
[可扩展]与敌人飞船碰撞, 对敌人飞船产生影响
系统分析与实现
BoundsCheck
一个通用组件, 用于判断游戏对象是否在屏幕中, 获取相对屏幕的位置.
可以用来管理游戏对象, 包括玩家.
使用了复合枚举来保存位置信息.
为每一个游戏对象添加BoundsCheck 组件, 通过调用boundsCheck.isOnScreen()或boundsCheck.LocIs来获取信息.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 using System.Collections;using System.Collections.Generic;using UnityEngine;public class BoundsCheck : MonoBehaviour { [System.Flags ] public enum eScreenLocs { onScreen = 0 , offRight = 1 , offLeft = 2 , offTop = 4 , offBottom = 8 , } public enum eType { center, inset, outset } [Header("Inscribed" ) ] public eType boundsType = eType.center; public float radius = 1f ; public bool keepOnScreen = true ; [Header("Dynamic" ) ] public eScreenLocs screenLocs = eScreenLocs.onScreen; public float camWidth; public float camHeight; void LateUpdate () { } public bool isOnScreen { get { return screenLocs == eScreenLocs.onScreen; } } public bool LocIs (eScreenLocs checkLoc ) { if (checkLoc == eScreenLocs.onScreen) { return isOnScreen; } return (screenLocs & checkLoc) == checkLoc; } }
Enemy
"生成"
"管理预制件"
"继承"
"检测碰撞"
Main
- static Main S
- GameObject[] prefabEnemies
- float enemySpawnPerSecond
+Awake()
+SpawnEnemy()
«abstract»
Enemy
+ float speed
+ float health
+ Vector3 pos
+Move()
+OnCollisionEnter(Collision coll)
Enemy_1
+ float waveFrequency
+ float waveWidth
- float x0
- float birthTime
+Move()
ProjectileHero
+ float damageOnHit
prefabEnemies
Enemy生成
Enemy的生成为全局管理, 由游戏唯一Main对象管理, Main为单例, 且保存所有Enemy的预制件.
当Main对象被创建后, 每隔一段时间调用SpawnEnemy()方法, 由SpawnEnemy()生成一个Enemy, 在SpawnEnemy中选取要生成的具体Enemy可以要生成的具体坐标, 然后通过Invoke延时递归调用自身.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.SceneManagement;using Random = UnityEngine.Random;public class Main : MonoBehaviour { static private Main S; public GameObject[] prefabEnemies; public float enemySpawnPerSecond = 0.5f ; void Awake () { S = this ; Invoke(nameof (SpawnEnemy), 1f / enemySpawnPerSecond); } public void SpawnEnemy () { int ndx = Random.Range(0 , prefabEnemies.Length); GameObject newEnemy = Instantiate<GameObject>(prefabEnemies[ndx]); Vector3 pos = Vector3.zero; newEnemy.transform.position = pos; Invoke(nameof (SpawnEnemy), 1f / enemySpawnPerSecond); } }
image.png
Enemy基类和扩展类
Enemy预制件附带有对应的Enemy脚本, 控制对应Enemy的行为.
image.png
每一种Enemy的通用属性, 如移动速度, 血量, 可以定义在基类中
image.png
每一种Enemy的移动方式不同, 可以再基类中实现基本移动功能, 在具体类中实现扩展功能.
Enemy基类, 实现基本向下移动
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;[RequireComponent(typeof(BoundsCheck)) ] public class Enemy : MonoBehaviour { [Header("Inscribed" ) ] public float speed = 10f ; public float fireRate = 0.3f ; public float health = 10 ; public int score = 100 ; public Vector3 pos { get { return transform.position; } set { transform.position = value ; } } void Update () { Move(); } public virtual void Move () { Vector3 tempPos = pos; tempPos.y -= speed * Time.deltaTime; pos = tempPos; } }
例如: Enemy_1子类, 重写Move方法, 实现带有摆动的移动.
通过Sin函数和存货时间计算摆动横向偏移量, 而基础的向下移动功能, 又由Enemy基类完成.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Enemy_1 : Enemy { [Header("Enemy_1 Inscribed Fields" ) ] [Tooltip("# of seconds for a full sine wave" ) ] public float waveFrequency = 2 ; [Tooltip("Sine wave width in meters" ) ] public float waveWidth = 6 ; private float x0; private float birthTime; void Start () { x0 = pos.x; birthTime = Time.time; } public override void Move () { Vector3 tempPos = pos; float age = Time.time - birthTime; float theta = Mathf.PI * 2 * age / waveFrequency; float sin = Mathf.Sin(theta); tempPos.x = x0 + sin * waveWidth; pos = tempPos; base .Move(); } }
Enemy受击处理
Enemy可以和弹药类ProjectileHero进行碰撞, 通过Layer设置是否可以碰撞, 然后在OnCollisionEnter中处理具体碰撞逻辑.
弹药由武器生成, 弹药有伤害数值, 下文另谈.
弹药游戏对象持有ProjectileHero组件, 可用来判断Enemy碰撞到的是否是弹药.
当Enemy碰撞到弹药后, 销毁弹药. 同时扣除自身血量, 血量耗尽后销毁Enemy.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;[RequireComponent(typeof(BoundsCheck)) ] public class Enemy : MonoBehaviour { public float health = 10 ; void OnCollisionEnter (Collision coll ) { GameObject otherGO = coll.gameObject; ProjectileHero projectileHero = otherGO.GetComponent<ProjectileHero>(); if (projectileHero != null ) { if (bndCheck.isOnScreen) { health -= Main.GET_WEAPON_DEFINITION(projectileHero.type).damageOnHit; if (health < 0 ) { if (!calledShipDestroyed) { calledShipDestroyed = true ; Main.SHIP_DESTROYED(this ); } Destroy(gameObject); } } Destroy(otherGO); } else { Debug.Log("Enemy hit by non-ProjectileHero: " + otherGO.name); } } }
Hero
Hero生成
玩家飞船也为全局唯一对象, 使用单例模式, 使用Hero脚本控制行为.
因为不会重复生成, 所以游戏开始时直接布置在场景Scene中.
image.png
玩家移动控制
有参数最大速度. 根据帧间时间计算移动后所处位置, 直接改变.
每帧读取移动虚拟轴输入, 实现平滑移动.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class Hero : MonoBehaviour { static public Hero S { get ; private set ; } [Header("Inscribed" ) ] public float maxSpeed = 30 ; void Awake () { if (S == null ) { S = this ; } else { Debug.LogError("Hero.Awake() - Attempted to assign second Hero.S!" ); } } void Update () { float hAxis = Input.GetAxis("Horizontal" ); float vAxis = Input.GetAxis("Vertical" ); Vector3 pos = transform.position; pos.x += hAxis * maxSpeed * Time.deltaTime; pos.y += vAxis * maxSpeed * Time.deltaTime; transform.position = pos; } }
玩家开火控制
每帧读取开火虚拟轴输入, 触发开火事件. 具体开火实现见武器系统.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class Hero : MonoBehaviour { public delegate void WeaponFireDelegate () ; public event WeaponFireDelegate fireEvent; void Update () { if (Input.GetAxis("Jump" ) == 1 && fireEvent != null ) { fireEven(); } } }
Weapon
Weapon配置管理和切换
"管理"
"获取和应用"
"依赖"
"依赖"
"定义"
"管理预制件"
"管理预制件"
Main
- Dictionary WEAP_DICT
+ WeaponDefinition[] weaponDefinitions
+WeaponDefinition GET_WEAPON_DEFINITION(eWeaponType wt)
+Awake()
Weapon
- eWeaponType _type
- WeaponDefinition def
- float nextShotTime
- GameObject weaponModel
+SetType(eWeaponType type)
WeaponDefinition
+ eWeaponType type
+ GameObject weaponModelPrefab
+ GameObject projectilePrefab
+ float delayBetweenShots
+ float velocity
«enumeration»
eWeaponType
none
blaster
spread
weaponModelPrefab
projectilePrefab
在本系统中, 武器被实现为一个可以通过类型枚举来配置武器行为的对象. 武器对象是一直存在的, 会根据类型的切换改变表现的形态.
武器的具体参数又保存在具体的武器定义类中. 不仅仅是数值参数, 还有模型预制体参数和子弹预制体参数.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public enum eWeaponType { none, blaster, spread, } [System.Serializable ] public class WeaponDefinition { public eWeaponType type = eWeaponType.none; [Tooltip("附加到玩家飞船上的武器模型预制件" ) ] public GameObject weaponModelPrefab; [Tooltip("发射的投射物预制件" ) ] public GameObject projectilePrefab; [Tooltip("每次射击之间的延迟秒数" ) ] public float delayBetweenShots = 0 ; [Tooltip("单个投射物的速度" ) ] public float velocity = 50 ; }
所有的武器定义类被保存在Main对象中, 便于统一修改.
使用一个列表weaponDefinitions来保存所有的武器定义类, 在Unity中进行修改.
image.png
然后在Main脚本激活时将weaponDefinitions中的参数保存到字典Dictionary<eWeaponType, WeaponDefinition> WEAP_DICT中, 这样就可以通过GET_WEAPON_DEFINITION()方法, 在具体武器类想要获得具体武器参数是获得在Unity中写好的配置.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.SceneManagement;using Random = UnityEngine.Random;[RequireComponent(typeof(BoundsCheck)) ] public class Main : MonoBehaviour { static private Main S; static private Dictionary<eWeaponType, WeaponDefinition> WEAP_DICT; public WeaponDefinition[] weaponDefinitions; void Awake () { S = this ; bndCheck = GetComponent<BoundsCheck>(); Invoke(nameof (SpawnEnemy), 1f / enemySpawnPerSecond); WEAP_DICT = new Dictionary<eWeaponType, WeaponDefinition>(); foreach (WeaponDefinition def in weaponDefinitions) { WEAP_DICT[def.type] = def; } } } static public WeaponDefinition GET_WEAPON_DEFINITION (eWeaponType wt ) { if (WEAP_DICT.ContainsKey(wt)) return WEAP_DICT[wt]; return new WeaponDefinition(); } }
通过在具体武器类中保存武器类型定义类eWeaponType _type, 每次修改类型时, 武器对象通过Main.GET_WEAPON_DEFINITION(_type); 方法更新武器的定义, 从而加载不同的模型, 以及获取一些武器参数.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class Weapon : MonoBehaviour { private eWeaponType _type = eWeaponType.none; public WeaponDefinition def; private GameObject weaponModel; void Start () { SetType(_type); } public eWeaponType type { get { return _type; } set { SetType(value ); } } public void SetType (eWeaponType type ) { _type = type; def = Main.GET_WEAPON_DEFINITION(_type); if (weaponModel != null ) Destroy(weaponModel); weaponModel = Instantiate<GameObject>(def.weaponModelPrefab, transform); weaponModel.transform.localPosition = Vector3.zero; weaponModel.transform.localScale = Vector3.one; } }
图示
Weapon对象载入
依赖
依赖
管理
Weapon
- eWeaponType _type
+SetType(eWeaponType type)
Hero
+ Weapon[] weapons
+ClearWeapons()
«enumeration»
eWeaponType
none
blaster
spread
武器的实际挂载对象是玩家飞船, 通过Hero脚本进行管理.
在游戏场景的Hero游戏对象上, 有五个子游戏对象hardpoint, 每个绑定在玩家飞船的不同位置上, 然后在游戏对象hardpoint上有子游戏对象Weapon预制件实例, Weapon脚本作为Weapon预制件的组件, Hero脚本作为Hero游戏对象的组件. 在Hero脚本中有Weapon列表, 绑定这些Weapon预制件实例来进行管理.
通过调用Weapon.SetType(eWeaponType), 就可以设置具体的Weapon预制件实例的类型.
ClearWeapons()方法可以将所有的Weapon预制件实例类型改为eWeaponType.none.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class Hero : MonoBehaviour { static public Hero S { get ; private set ; } public Weapon[] weapons; void Awake () { if (S == null ) { S = this ; } else { Debug.LogError("Hero.Awake() - Attempted to assign second Hero.S!" ); } ClearWeapons(); weapons[0 ].SetType(eWeaponType.blaster); } public void ClearWeapons () { foreach (Weapon w in weapons) { w.SetType(eWeaponType.none); } } }
Weapon开火
"生成弹药实例"
"获取弹药定义"
"根据武器类型获取参数"
"控制物理运动"
"检测屏幕外销毁"
Weapon
+WeaponDefinition def
+float nextShotTime
+Transform shotPointTrans
+Start()
+Fire()
+MakeProjectile()
ProjectileHero
+Rigidbody rigid
+BoundsCheck boundsCheck
+Renderer rend
+eWeaponType type
+Vector3 vel
+SetType(eWeaponType eType)
+Awake()
+Update()
WeaponDefinition
+GameObject projectilePrefab
+float delayBetweenShots
+float velocity
+Color projectileColor
Rigidbody
BoundsCheck
对于每一个Weapon游戏对象实例, 由Weapon预制体的子对象ShotPoint提供射击点, 也就是弹药生成的位置.
Weapon 脚本通过WeaponDefinition def上保存的武器定义, 来获取弹药的参数, 包括弹药预制体和弹药速度等.
Fire()方法作为Weapon的开火方法, 当被触发时, 根据保存的武器类型, 进行相对应的弹药生成方法. 例如blaster就生成一个弹药, spread就生成三个弹药. 弹药
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 public class Weapon : MonoBehaviour { public WeaponDefinition def; public float nextShotTime; private Transform shotPointTrans; void Start () { shotPointTrans = transform.GetChild(0 ); } private void Fire () { if (Time.time < nextShotTime) return ; ProjectileHero projectileHero; Vector3 vel = Vector3.up * def.velocity; switch (type) { case eWeaponType.blaster: projectileHero = MakeProjectile(); projectileHero.vel = vel; break ; case eWeaponType.spread: projectileHero = MakeProjectile(); projectileHero.vel = vel; projectileHero = MakeProjectile(); projectileHero.transform.rotation = Quaternion.AngleAxis(10 , Vector3.back); projectileHero.vel = projectileHero.transform.rotation * vel; projectileHero = MakeProjectile(); projectileHero.transform.rotation = Quaternion.AngleAxis(-10 , Vector3.back); projectileHero.vel = projectileHero.transform.rotation * vel; break ; } } private ProjectileHero MakeProjectile () { GameObject gameObject = Instantiate<GameObject>(def.projectilePrefab, PROJECTILE_ANCHOR); ProjectileHero projectileHero = gameObject.GetComponent<ProjectileHero>(); Vector3 pos = shotPointTrans.position; pos.z = 0 ; projectileHero.transform.position = pos; projectileHero.type = type; nextShotTime = Time.time + def.delayBetweenShots; return projectileHero; } }
弹药ProjectileHero 实例由Weapon生成,
由Weapon控制其速度, 由刚体组件控制其运动,
由BoundsCheck组件提供是否飞出屏幕, 判断销毁,
由Enemy判断碰撞逻辑销毁.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;[RequireComponent(typeof(BoundsCheck)) ] public class ProjectileHero : MonoBehaviour { private BoundsCheck boundsCheck; private Renderer rend; [Header("Dynamic" ) ] public Rigidbody rigid; private eWeaponType _type; public eWeaponType type { get { return _type; } set { SetType(value ); } } public void SetType (eWeaponType eType ) { _type = eType; WeaponDefinition def = Main.GET_WEAPON_DEFINITION(_type); rend.material.color = def.projectileColor; } public Vector3 vel { get { return rigid.velocity; } set { rigid.velocity = value ; } } void Awake () { boundsCheck = GetComponent<BoundsCheck>(); rend = GetComponent<Renderer>(); rigid = GetComponent<Rigidbody>(); } void Update () { if (boundsCheck.LocIs(BoundsCheck.eScreenLocs.offTop)) { Destroy(gameObject); } } }
Hero开火事件
"注册开火事件"
"fireEvent 事件触发"
Hero
+delegate WeaponFireDelegate
+event WeaponFireDelegate fireEvent
+Update()
+fireEvent()
Weapon
+Start()
+Fire()
Weapon的开火事件由Hero控制触发.
在Hero上保存有委托fireEvent, 每帧检测是否有开火控制型号, 然后触发fireEvent.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class Hero : MonoBehaviour { static public Hero S { get ; private set ; } public delegate void WeaponFireDelegate () ; public event WeaponFireDelegate fireEvent; void Update () { if (Input.GetAxis("Jump" ) == 1 && fireEvent != null ) { fireEvent(); } } }
在Weapon上注册事件, 将Weapon的Fire方法绑定到Hero的fireEvent上.
这样当Hero触发开火时, 所有的Weapon都会开火.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Weapon : MonoBehaviour { void Start () { Hero hero = GetComponentInParent<Hero>(); if (hero != null ) hero.fireEvent += Fire; } private void Fire () { } }
PowerUp
升级道具由Enemy死亡掉落, 可以触发Weapon改变
PowerUp生成
"通知SHIP_DESTROYED"
"生成PowerUp"
Enemy
+bool calledShipDestroyed
+OnCollisionEnter(Collision coll)
PowerUp
+eWeaponType type
+SetType(eWeaponType wt)
+SetType()
Main
+static Main S
+GameObject prefabPowerUp
+eWeaponType[] powerUpFrequency
+static void SHIP_DESTROYED(Enemy e)
PowerUp有其类型, 对应武器类型.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class PowerUp : MonoBehaviour { [Header("Dynamic" ) ] public eWeaponType _type; public float birthTime; void Awake () { birthTime = Time.time; } public eWeaponType type { get { return _type; } set { SetType(value ); } } public void SetType (eWeaponType wt ) { WeaponDefinition def = Main.GET_WEAPON_DEFINITION(wt); cubeMat.color = def.powerUpColor; letter.text = def.letter; _type = wt; } }
为了使系统保持简洁, 也就是可以生成实例的类不要太多, 这里吧生成PowerUP的职责放在Main中.
当Enemy被销毁时, 通知Main. 使用Main.SHIP_DESTROYED(this);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class Enemy : MonoBehaviour { void OnCollisionEnter (Collision coll ) { GameObject otherGO = coll.gameObject; ProjectileHero projectileHero = otherGO.GetComponent<ProjectileHero>(); if (projectileHero != null ) { if (bndCheck.isOnScreen) { health -= Main.GET_WEAPON_DEFINITION(projectileHero.type).damageOnHit; if (health < 0 ) { if (!calledShipDestroyed) { calledShipDestroyed = true ; Main.SHIP_DESTROYED(this ); } Destroy(gameObject); } } Destroy(otherGO); } else { Debug.Log("Enemy hit by non-ProjectileHero: " + otherGO.name); } } }
Main中保存有PowerUP预制体, 以及一个eWeaponType列表, 用来控制PowerUP生成类型的概率.
当Main.SHIP_DESTROYED被触发时, 生成PowerUP并设定类型.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 using Random = UnityEngine.Random;public class Main : MonoBehaviour { static private Main S; public GameObject prefabPowerUp; public eWeaponType[] powerUpFrequency = new eWeaponType[]{ eWeaponType.blaster, eWeaponType.blaster, eWeaponType.spread, eWeaponType.shield, }; static public void SHIP_DESTROYED (Enemy e ) { if (Random.value <= e.powerUpDropChance) { eWeaponType pUpType = S.powerUpFrequency[Random.Range(0 , S.powerUpFrequency.Length)]; GameObject go = Instantiate<GameObject>(S.prefabPowerUp); PowerUp pUp = go.GetComponent<PowerUp>(); pUp.SetType(pUpType); pUp.transform.position = e.transform.position; } } }
PowerUp触发
"碰撞检测"
"改变武器类型"
"触发PowerUp效果"
Hero
+void OnTriggerEnter(Collider other)
+void AbsorbPowerUp(PowerUp powerUp)
PowerUp
+eWeaponType type
+type "PowerUp类型"
+void AbsorbedBy(GameObject go)
Weapon
+void SetType(eWeaponType type)
当PowerUP被Hero碰撞后, 被消化使用. 在Hero中判断powerUp.type, 根据具体类型产生不同效果, 同时销毁powerUp.
若powerUp.type为一种武器, 触发weapon.SetType(powerUp.type), 到Weapon类型被改变.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class Hero : MonoBehaviour { static public Hero S { get ; private set ; } private GameObject lastTriggerGo = null ; void OnTriggerEnter (Collider other ) { Transform rootT = other.gameObject.transform.root; GameObject gameObject = rootT.gameObject; if (gameObject == lastTriggerGo) return ; lastTriggerGo = gameObject; Enemy enemy = gameObject.GetComponent<Enemy>(); PowerUp powerUp = gameObject.GetComponent<PowerUp>(); if (enemy != null ) { shieldLevel--; Destroy(gameObject); } else if (powerUp != null ) { AbsorbPowerUp(powerUp); } else { Debug.LogWarning("Shield trigger hit by non-Enemy: " + gameObject.name); } } public void AbsorbPowerUp (PowerUp powerUp ) { Debug.Log("Absorbed PowerUp: " + powerUp.type); switch (powerUp.type) { case eWeaponType.shield: shieldLevel++; break ; default : if (powerUp.type == weapons[0 ].type) { Weapon weapon = GetEmptyWeaponSlot(); if (weapon != null ) { weapon.SetType(powerUp.type); } } else { ClearWeapons(); weapons[0 ].SetType(powerUp.type); } break ; } powerUp.AbsorbedBy(gameObject); } }
当PowerUP被触发时, 销毁自己
1 2 3 4 5 6 7 public class PowerUp : MonoBehaviour { public void AbsorbedBy (GameObject target ) { Destroy(this .gameObject); } }
视觉效果
飞船运动倾角
Hero中的应用
移动时, 根据虚拟轴输入, 计算旋转角度
image.png
image.png
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;public class Hero : MonoBehaviour { static public Hero S { get ; private set ; } [Header("Inscribed" ) ] public float maxSpeed = 30 ; public float rollMult = -45 ; public float pitchMult = 30 ; void Update () { float hAxis = Input.GetAxis("Horizontal" ); float vAxis = Input.GetAxis("Vertical" ); transform.rotation = Quaternion.Euler(vAxis * pitchMult, hAxis * rollMult, 0 ); } }
Enemy_1中的应用
移动时, 根据侧向偏移量, 计算旋转角度
image.png
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Enemy_1 : Enemy { [Header("Enemy_1 Inscribed Fields" ) ] [Tooltip("# of seconds for a full sine wave" ) ] public float waveFrequency = 2 ; [Tooltip("Sine wave width in meters" ) ] public float waveWidth = 6 ; [Tooltip("Amount the ship will roll left and right with the sine wave" ) ] public float waveRotY = 45 ; private float x0; private float birthTime; void Start () { x0 = pos.x; birthTime = Time.time; } public override void Move () { Vector3 tempPos = pos; float age = Time.time - birthTime; float theta = Mathf.PI * 2 * age / waveFrequency; float sin = Mathf.Sin(theta); tempPos.x = x0 + sin * waveWidth; pos = tempPos; Vector3 rot = new Vector3(0 , sin * waveRotY, 0 ); transform.rotation = Quaternion.Euler(rot); base .Move(); } }
Enemy_2中的应用
移动时, 根据AnimationCurve 曲线, 计算旋转角度
image.png
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Enemy_2 : Enemy { [Header("Enemy_2 Inscribed Field" ) ] public float lifeTime = 10f ; [Tooltip("波幅控制" ) ] public float sinEccentricity = 0.6f ; public AnimationCurve rotCurve; [Header("Enemy_2 Private Fields" ) ] [SerializeField ] private float birthTime; private Quaternion baseRotation; [SerializeField ] private Vector3 p0, p1; void Start () { p0 = Vector3.zero; p0.x = -bndCheck.camWidth - bndCheck.radius; p0.y = Random.Range(-bndCheck.camHeight, bndCheck.camHeight); p1 = Vector3.zero; p1.x = bndCheck.camWidth + bndCheck.radius; p1.y = Random.Range(-bndCheck.camHeight, bndCheck.camHeight); if (Random.value > 0.5f ) { p0.x *= -1 ; p1.x *= -1 ; } birthTime = Time.time; transform.position = p0; transform.LookAt(p1, Vector3.back); baseRotation = transform.rotation; } public override void Move () { float u = (Time.time - birthTime) / birthTime; if (u > 1 ) { Destroy(gameObject); return ; } float shipRot = rotCurve.Evaluate(u) * 360 ; transform.rotation = baseRotation * Quaternion.Euler(-shipRot, 0 , 0 ); u = u + sinEccentricity * Mathf.Sin(u * Mathf.PI * 2 ); pos = (1 - u) * p0 + u * p1; } }
平滑移动路径
贝塞尔曲线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 using System.Linq;using UnityEngine;public class Utils : MonoBehaviour { static public Vector3 Bezier (float t, params Vector3[] points ) { if (points.Length == 1 ) return points[0 ]; return Bezier(t, points.Take(points.Length - 1 ) .Zip(points.Skip(1 ), (p0, p1) => Vector3.LerpUnclamped(p0, p1, t)) .ToArray()); } }
Enemy_3中的应用
image.png
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Enemy_3 : Enemy { [Header("Enemy_3 公共字段" ) ] public float lifeTime = 5 ; public Vector2 midpointYRange = new Vector2(1.5f , 3 ); [Tooltip("如果为true,则在Scene面板中绘制Bézier点和路径。" ) ] public bool drawDebugInfo = true ; [Header("Enemy_3 私有字段" ) ] [SerializeField ] private Vector3[] points; [SerializeField ] private float birthTime; void Start () { points = new Vector3[3 ]; points[0 ] = pos; float xMin = -bndCheck.camWidth + bndCheck.radius; float xMax = bndCheck.camWidth - bndCheck.radius; points[1 ] = Vector3.zero; points[1 ].x = Random.Range(xMin, xMax); float midYMult = Random.Range(midpointYRange[0 ], midpointYRange[1 ]); points[1 ].y = -bndCheck.camHeight * midYMult; points[2 ] = Vector3.zero; points[2 ].y = pos.y; points[2 ].x = Random.Range(xMin, xMax); birthTime = Time.time; if (drawDebugInfo) DrawDebug(); } public override void Move () { float u = (Time.time - birthTime) / lifeTime; if (u > 1 ) { Destroy(this .gameObject); return ; } transform.rotation = Quaternion.Euler(u * 180 , 0 , 0 ); u = u - 0.1f * Mathf.Sin(u * Mathf.PI * 2 ); pos = Utils.Bezier(u, points); } void DrawDebug () { Debug.DrawLine(points[0 ], points[1 ], Color.cyan, lifeTime); Debug.DrawLine(points[1 ], points[2 ], Color.yellow, lifeTime); float numSections = 20 ; Vector3 prevPoint = points[0 ]; Color col; Vector3 pt; for (int i = 1 ; i < numSections; i++) { float u = i / numSections; pt = Utils.Bezier(u, points); col = Color.Lerp(Color.cyan, Color.yellow, u); Debug.DrawLine(prevPoint, pt, col, lifeTime); prevPoint = pt; } } }
敌人受击反馈
新建受击组件
保存所有子对象材质
检测碰撞, 当被子弹命中时改变材质, 产生效果.
效果根据时间消失.
image.png
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 using System.Collections;using System.Collections.Generic;using System.Linq;using UnityEngine;[DisallowMultipleComponent ] public class BlinkColorOnHit : MonoBehaviour { private static float blinkDuration = 0.1f ; private static Color blinkColor = Color.red; [Header("动态参数" ) ] public bool showingColor = false ; public float blinkCompleteTime; public bool ignoreOnCollisionEnter = false ; private Material[] materials; private Color[] originalColors; private BoundsCheck bndCheck; void Awake () { bndCheck = GetComponentInParent<BoundsCheck>(); materials = Utils.GetAllMaterials(gameObject); originalColors = materials.Select(m => m.color).ToArray(); } void Update () { if (showingColor && Time.time > blinkCompleteTime) RevertColors(); } void OnCollisionEnter (Collision coll ) { if (ignoreOnCollisionEnter) return ; if (bndCheck != null && !bndCheck.isOnScreen) { return ; } ProjectileHero p = coll.gameObject.GetComponent<ProjectileHero>(); if (p != null ) { SetColors(); } } public void SetColors () { foreach (Material m in materials) { m.color = blinkColor; } showingColor = true ; blinkCompleteTime = Time.time + blinkDuration; } public void RevertColors () { for (int i = 0 ; i < materials.Length; i++) { materials[i].color = originalColors[i]; } showingColor = false ; } }
无缝太空背景
滚动数组思想.
使用两块背景, 循环播放, 当一张移出屏幕后, 接替到另一张上面.
image.png
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Parallax : MonoBehaviour { [Header("Inscribed" ) ] public Transform playerTrans; public Transform[] panels; [Tooltip("Speed at which the panels move in Y" ) ] public float scrollSpeed = -30f ; [Tooltip("Controls how much panels react to player movement (Default 0.25)" ) ] public float motionMult = 0.25f ; private float panelHt; private float depth; void Start () { panelHt = panels[0 ].localScale.y; depth = panels[0 ].position.z; panels[0 ].position = new Vector3(0 , 0 , depth); panels[1 ].position = new Vector3(0 , panelHt, depth); } void Update () { float tY, tX = 0 ; tY = Time.time * scrollSpeed % panelHt + (panelHt * 0.5f ); if (playerTrans != null ) { tX = -playerTrans.transform.position.x * motionMult; } panels[0 ].position = new Vector3(tX, tY, depth); if (tY >= 0 ) { panels[1 ].position = new Vector3(tX, tY - panelHt, depth); } else { panels[1 ].position = new Vector3(tX, tY + panelHt, depth); } } }
扩展实践
Missile
创建一种新的武器-导弹. 可以在发射后自动跟踪敌人.
创建新的武器配置
在Main.cs→WeaponDefinitions中创建Missile
image.png
在Main.cs→PowerUPfrequency中创建Missile
image.png
此时运行游戏
敌人可掉落M道具
image.png
拾取后也可正常装备
image.png
但此时还没有发射功能
添加发射功能
修改Weapon类, 在射击方法中添加处理导弹类型的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class Weapon : MonoBehaviour { private void Fire () { switch (type) { case eWeaponType.missile: projectileHero = MakeProjectile(); projectileHero.vel = vel; break ; } } }
此时可以发生导弹类型的弹药, 由上一步配置为红色.
image.png
实现目标检测
实现思路
给ProjectileHero创建一个球形碰撞箱来检测敌人
通过向量差值获得指向敌人的向量来修改导弹的速度使其朝向敌人
ProjectileHero已经自身持有一个碰撞箱用来检测是否与敌人碰撞, 所以我们给Missile类型的ProjectileHero创建一个子对象detectionRange, 再在这个子对象detectionRange上添加SphereCollider.
在ProjectileHero上新增代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;[RequireComponent(typeof(BoundsCheck)) ] public class ProjectileHero : MonoBehaviour { private Transform detectionRangeTrans; public void SetType (eWeaponType eType ) { if (_type == eWeaponType.missile) { AddDetectionRange(); } } private void AddDetectionRange () { GameObject detectionRange = new GameObject("DetectionRange" ); detectionRangeTrans = detectionRange.transform; detectionRangeTrans.SetParent(this .transform); detectionRangeTrans.localPosition = Vector3.zero; detectionRangeTrans.gameObject.layer = gameObject.layer; SphereCollider sphereCollider = detectionRange.AddComponent<SphereCollider>(); sphereCollider.isTrigger = true ; sphereCollider.radius = 10f ; } }
实现效果, 可见碰撞箱半径
image.png
实现目标追踪
为刚才创建的对象添加一个脚本, 专门用来处理追踪逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;[RequireComponent(typeof(BoundsCheck)) ] public class ProjectileHero : MonoBehaviour { private void AddDetectionRange () { detectionRange.AddComponent<MissileTargetDetector>(); } }
保存变量projectileHero, 用于修改其运动
保存变量enemy 用于确认目标
在OnTriggerEnter中设定目标
在OnTriggerExit中放弃目标
在Update使用线性插值修改速度方向追踪目标
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 using System.Collections;using System.Collections.Generic;using UnityEngine;public class MissileTargetDetector : MonoBehaviour { ProjectileHero projectileHero; Enemy enemy = null ; void Awake () { projectileHero = gameObject.GetComponentInParent<ProjectileHero>(); } void Update () { if (enemy != null && enemy.gameObject != null ) { Vector3 origin = projectileHero.rigid.velocity; Vector3 direction = (enemy.transform.position - transform.position).normalized * origin.magnitude; projectileHero.rigid.velocity = Vector3.Lerp(origin, direction, 0.2f ); } else { enemy = null ; } } void OnTriggerEnter (Collider other ) { Enemy detectedEnemy = other.gameObject.GetComponent<Enemy>(); if (enemy == null && detectedEnemy != null ) { enemy = detectedEnemy; } } void OnTriggerExit (Collider other ) { if (enemy != null && other.gameObject == enemy.gameObject) { enemy = null ; } } }
实现效果, 当导弹靠近目标之后, 会指向目标
image.png
发现Bug1, 当导弹跟踪的目标丢失以后会失去速度滞留在地图上
image.png
修复: 使用线性插值直接修改速度会改变速度大小, 下文优化追踪方案
发现Bug2, 导弹对Enemy_1没有追踪作用
修复: Enemy_1没有碰撞箱, 添加碰撞箱后功能恢复
image.png
优化目标追踪
将原本的线性插值改变速度方向, 改为旋转导弹的方向, 更加平滑, 视觉表现也更好.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using System.Collections;using System.Collections.Generic;using UnityEngine;public class MissileTargetDetector : MonoBehaviour { public float rotationSpeed = 4.0f ; void Update () { if (enemy != null && enemy.gameObject != null ) { Vector3 targetDirection = (enemy.transform.position - projectileHero.transform.position).normalized; Quaternion targetRotation = Quaternion.LookRotation(Vector3.forward, targetDirection); projectileHero.transform.rotation = Quaternion.Slerp( projectileHero.transform.rotation, targetRotation, Time.deltaTime * rotationSpeed ); projectileHero.vel = projectileHero.transform.up * projectileHero.speed; } else { enemy = null ; } } }
同时在ProjectileHero保存速度的大小, 修复了导弹静止的问题.
1 2 3 4 5 6 7 8 public class ProjectileHero : MonoBehaviour { public float speed; public void SetType (eWeaponType eType ) { speed = def.velocity; } }
image.png