用Unity制作一个简单的大鱼吃小鱼游戏
完成效果:
0.一些准备工作
简单准备了一些鱼的图片素材和一张海底的背景图
将这些鱼的图片拖动到游戏中,创建一个拥有简单动画效果的游戏对象。将它们的z轴都设为0。
将背景也拖入游戏,并将z轴改为1。
将摄像机的Projection改为Orthographic。
1.控制鱼的移动
接下来我们需要控制鱼的移动。新建脚本Fish,在update里获得玩家的输入输出。
void Update()
{
Vector3 playerInput = new Vector3(0, 0, 0);
playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
transform.localPosition += playerInput;
}
将脚本挂在一条鱼上,我们发现我们已经可以控制鱼的移动了,但此时我们的移动过快了,需要做一些修改。
给Fish添加属性speed,再将playerInput乘上speed和Time.deltaTime。
public float speed = 5f;
void Update()
{
Vector3 playerInput = new Vector3(0, 0, 0);
playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
transform.localPosition += playerInput * speed * Time.deltaTime;
}
现在我们可以移动的更慢一些,每一帧不再移动那么多的距离。同时,我们现在可以通过调节speed来控制移动速度。
再为我们的移动增加一个加速的过程。
增加两个属性velocity和desiredVelocity,表示鱼当前的速度和我们期望的速度,并在awake里将它们初始化。
Vector3 velocity;
Vector3 desiredVelocity;
void Awake()
{
velocity = new Vector3(0, 0, 0);
desiredVelocity = new Vector3(0, 0, 0);
}
增加属性acceleration,表示鱼的加速度。
public float acceleration = 1f;
修改鱼的移动,将transform.localPosition += playerInput * speed * Time.deltaTime;改为
desiredVelocity = playerInput * speed;
velocity = Vector3.MoveTowards(velocity, desiredVelocity, acceleration);
transform.localPosition += velocity * Time.deltaTime;
现在鱼虽然已经可以移动,但还无法切换移动方向,会出现向后移动的情况。我们可以通过欧拉角改变鱼的方向。将刚刚的移动修改为
desiredVelocity = playerInput * speed;
velocity = Vector3.MoveTowards(velocity, desiredVelocity, acceleration);
if (velocity.x > 0) transform.localEulerAngles = new Vector3(0, 0, 0);
else if (velocity.x < 0) transform.localEulerAngles = new Vector3(0, 180, 0);
transform.localPosition += velocity * Time.deltaTime;
鱼的移动基本完成了。
2.创造更多的鱼
在这个游戏中,我们需要源源不断的生成新的不同的鱼,因此让我们创建一个新的脚本FishFactory,来为我们生产鱼类。
FishFactory需要继承ScriptableObject而不是MonoBehaviour,我们还要在类前面加上[CreateAssetMenu]。
[CreateAssetMenu]
public class FishFactory : ScriptableObject
现在,我们可以在Assets里,右键->Create找到Fish Factory并创建它。
添加一个属性fishes,来存放生产的鱼类的预制体。
public Fish[] fishes;
添加get方法,输入序号来获得实例化的鱼类。
public Fish Get(int id) {
Fish fish = Instantiate(fishes[id]);
return fish;
}
回到场景,将我们之前用素材制作的鱼全部加上Fish脚本,并添加碰撞体。我这里是用的Box Collider 2D,并将边缘调小了点。
当两只鱼正面相遇时,我们希望它们穿过对方继续向前(不考虑吃掉的情况下),所以我们将这些碰撞体都勾上Is Trigger。
为所有的鱼添加刚体。将BodyType改为Kinematic,使其不受重力影响。
将所有的鱼拖入Assets,保存为预制体。
将其按大小从小到大拖入到Fish Factory中。
创建一个新的脚本Control,来控制游戏。
首先要添加一个FishFactory属性。
public FishFactory fishFactory;
添加lastTime和delayTime属性,来使鱼能隔一段时间生成一次。
public float delayTime = 3f;
float lastTime = 0f;
先填写update方法,来让游戏在(0,0,0)的地方生成新的鱼。
void Update()
{
lastTime += Time.deltaTime;
if (lastTime >= delayTime){
lastTime = 0f;
Fish fish = fishFactory.Get(0);
fish.transform.localPosition = new Vector3(0, 0, 0);
}
}
创建空物体GameControl,并将Control脚本挂在上面,将之前创建的FishFactory挂在脚本上。
此时我们运行游戏,已经可以看到屏幕中间不断出现新的鱼了。
3.鱼的活动范围
接下来,我们要把我们的鱼的移动限制在我们的摄像机视野内。
修改Fish脚本,我们要添加两个属性leftBottomPoint, rightTopPoint,来记录摄像机投射到屏幕上的左下角和右上角的坐标。
Vector3 leftBottomPoint, rightTopPoint;
在Awake中,使用ViewportToWorldPoint函数来将视口坐标转换为世界坐标。
void Awake()
{
......
leftBottomPoint = Camera.main.ViewportToWorldPoint(new Vector3(0f, 0f,
Mathf.Abs(Camera.main.transform.position.z)));
rightTopPoint = Camera.main.ViewportToWorldPoint(new Vector3(1f, 1f,
Mathf.Abs(Camera.main.transform.position.z)));
}
编写一个新的函数Clamp,来约束鱼的移动。当鱼的坐标要超出边框时,将鱼的坐标移到边框上并把相应速度置0。
void Clamp() {
Vector3 position = transform.localPosition;
if (position.x > rightTopPoint.x) {
position.x = rightTopPoint.x;
velocity.x = 0;
}
else if (position.x < leftBottomPoint.x) {
position.x = leftBottomPoint.x;
velocity.x = 0;
}
if (position.y > rightTopPoint.y) {
position.y = rightTopPoint.y;
velocity.y = 0;
}
else if (position.y < leftBottomPoint.y) {
position.y = leftBottomPoint.y;
velocity.y = 0;
}
transform.localPosition = position;
}
在鱼移动后,调用这个函数来约束鱼的位置。
transform.localPosition += velocity * Time.deltaTime;
Clamp();
现在鱼已经不能超出摄像机的视野范围了。
4.鱼的生成方式
在这之前,游戏已经可以源源不断的生成新的鱼在屏幕中央了,并都由我们控制,但这显然不是我们所期望的。我们希望能够有鱼在外界不断生成并游到我们的视野范围,而我们只需要控制一条鱼来和它们交互。
所以,让我们先移除Fish中的玩家输入部分。将所有和playerInput有关的语句删掉。
//Vector3 playerInput = new Vector3(0, 0, 0);
//playerInput.x = Input.GetAxis("Horizontal");
//playerInput.y = Input.GetAxis("Vertical");
//desiredVelocity = playerInput * speed;
给Fish添加一个SetDesiredVelocity函数,让我们在外部仅给鱼一个移动方向,鱼就可以向该方向移动。
public void SetDesiredVelocity(Vector3 Input){
desiredVelocity = Input * speed;
}
如前文所说,这个输入应该是一个方向。所以我们用ClampMagnitude函数约束它,让它不大于1。
Vector3.ClampMagnitude(Input, 1f);
desiredVelocity = Input * speed;
在Control中添加属性player,用来控制玩家操作的鱼
Fish player;
在Awake函数中实例化它。
void Awake()
{
player = fishFactory.Get(1);
player.transform.localPosition = new Vector3(0, 0, 0);
}
添加PlayerUpdate函数,来让玩家操控鱼。
void PlayerUpdate() {
Vector3 playerInput = new Vector3(0, 0, 0);
playerInput.x = Input.GetAxis("Horizontal");
playerInput.y = Input.GetAxis("Vertical");
player.SetDesiredVelocity(playerInput);
}
将PlayerUpdate放入update函数中。
void Update()
{
PlayerUpdate();
...
}
现在我们只能控制游戏最开始生成在屏幕中央的鱼了。
接下来我们要修改其他鱼的生成方式,首先删掉原来测试时生成鱼的代码。
if (lastTime >= delayTime) {
lastTime = 0f;
//Fish fish = fishFactory.Get(0);
//fish.transform.localPosition = new Vector3(0, 0, 0);
}
添加新的函数MakeFish
void MakeFish() {
Fish fish = fishFactory.Get(0);
}
之前说过我们希望其他鱼一开始生成在边框外。因此Control也需要获得屏幕边框的位置。和上面一样,给Control添加leftBottomPoint, rightTopPoint属性,并在Awake里用ViewportToWorldPoint函数获取坐标。这里就不贴代码了,毕竟和上面一样。
回到MakeFish函数,我们用Random.Range函数来随机决定新的鱼是在屏幕左边还是右边,以及鱼的坐标。
Fish fish = fishFactory.Get(0);
if (Random.Range(0, 2) == 0) {
fish.transform.position = new Vector3(leftBottomPoint.x - 2f,
Random.Range(leftBottomPoint.y, rightTopPoint.y), 0f);
}
else {
fish.transform.position = new Vector3(rightTopPoint.x + 2f,
Random.Range(leftBottomPoint.y, rightTopPoint.y), 0f);
}
给左边生成的鱼一个向右的方向,给右边生成的鱼一个向左的方向。
Fish fish = fishFactory.Get(0);
if (Random.Range(0, 2) == 0) {
fish.transform.position = new Vector3(leftBottomPoint.x - 2f,
Random.Range(leftBottomPoint.y, rightTopPoint.y), 0f);
fish.SetDesiredVelocity(Vector3.right);
}
else {
fish.transform.position = new Vector3(rightTopPoint.x + 2f,
Random.Range(leftBottomPoint.y, rightTopPoint.y), 0f);
fish.SetDesiredVelocity(Vector3.left);
}
在update函数中调用MakeFish函数。
if (lastTime >= delayTime) {
lastTime = 0f;
MakeFish();
}
但还有一个问题。我们刚刚限制了鱼的活动范围,但现在我们又希望不由我们控制的鱼可以从屏幕外进来并离开屏幕。我们可以利用Tag来区分它们。
在Awake中,将我们生成的鱼的Tag设为“Player”
player = fishFactory.Get(1);
player.transform.localPosition = new Vector3(0, 0, 0);
player.tag = "Player";
来到Fish脚本,在update函数里的Clamp();前面,添加限制条件
if(this.tag == "Player") Clamp();
我们还需要让其他的鱼在离开屏幕后死亡,防止生成的鱼过多。因为这些鱼只会往一个方向移动,方法有很多。我这里的处理方法是让鱼在进入屏幕时标记,在离开屏幕时死亡。
使用一个属性isInScreen,记录是否进入屏幕。
bool isInScreen = false;
创建新的函数Die。当鱼进入屏幕时,将isInScreen的值变为true。
void Die() {
float x = transform.position.x;
float y = transform.position.y;
if ((x >= leftBottomPoint.x && x <= rightTopPoint.x) &&
(y >= leftBottomPoint.y && y <= rightTopPoint.y)) {
isInScreen = true;
}
}
当鱼进入屏幕并离开时,删去它。
else {
if (isInScreen) {
Destroy(this.gameObject);
}
}
在update里调用Die函数
if (this.tag == "Player") Clamp();
else Die();
现在这些鱼会被删去了,但它们在中心触碰边框后就立即消失了。我们不希望这样。修改Die函数,让它在2s后消失。
Destroy(this.gameObject, 2f);
这样,鱼的生成逻辑基本完成了。
5.鱼的等级与交互
现在,我们要实现这个游戏最关键的部分——“大鱼吃小鱼”。
首先,给Fish脚本增加一个属性level,表示鱼的大小等级。
int level;
添加函数SetLevel,设定鱼的等级。
public void SetLevel(int level) {
this.level = level;
}
添加函数GetLevel,获得鱼的等级。
public int GetLevel() {
return this.level;
}
来到FishFactory脚本。之前放进这些鱼的预制体时,我们是按照从小到大的顺序放的,所以数组中的编号就是鱼的等级。我们在Get函数里直接修改鱼的等级。
public Fish Get(int id) {
Fish fish = Instantiate(fishes[id]);
fish.SetLevel(id);
return fish;
}
回到Fish。用OnTriggerEnter2D函数检测碰撞。我们设定当玩家的等级小于等于这条鱼的等级时,玩家会被吃掉;当玩家的等级大于这条鱼的等级时,这条鱼会被玩家吃掉。
private void OnTriggerEnter2D(Collider2D other)
{
if (other.tag == "Player") {
Fish player = other.GetComponent();
if (player.GetLevel() <= level) {
Destroy(other.gameObject);
}
else {
Destroy(this.gameObject);
}
}
}
接下来,我们要添加鱼的升级机制。
首先,在Control脚本里添加一个属性score,来记录吃鱼的量。使用public和static标识符,我们之后可以直接在Fish脚本里更改得分。
public static int score = 0;
我们还要添加一个属性playerLevel,表示玩家等级。这个等级我们初始化为1,因为按我们的规则,玩家控制的鱼只能吃比他小的鱼,所以玩家的鱼不能是最小的。
int playerLevel = 1;
添加一个属性maxLevel,表示最大等级。它和FishFactory里数组的大小有关,我们可以在Awake里初始化它。
int maxLevel;
void Awake() {
maxLevel = fishFactory.fishes.Length - 1;
...
}
创建新的函数MakePlayerFish,来根据玩家等级生成对应的鱼。
首先我们要删去旧的鱼,删之前要保留下它的位置和方向。
void MakePlayerFish() {
Vector3 playerPosition = new Vector3(0f, 0f, 0f);
Vector3 playerEulerAngles = new Vector3(0f, 0f, 0f);
if (player != null) {
playerPosition = player.transform.localPosition;
playerEulerAngles = player.transform.localEulerAngles;
Destroy(player.gameObject);
}
}
之后再根据等级生成新的鱼。
player = fishFactory.Get(playerLevel);
player.transform.localPosition = playerPosition;
player.transform.localEulerAngles = playerEulerAngles;
player.tag = "Player";
现在可以将Awake里原来生成玩家鱼的代码换成MakePlayerFish函数。
//player = fishFactory.Get(0);
//player.transform.localPosition = new Vector3(0, 0, 0);
//player.tag = "Player";
MakePlayerFish();
添加属性levelLine,表示每次升级需要的吃鱼数。
public int levelLine = 20;
添加函数LevelUpdate,我们将(score/levelLine)与当前的playerLevel相比较,检测是否需要升级。因为playerLevel开始是1,所以我们给(score/levelLine)+1。
void LevelUpdate() {
if ((score / levelLine + 1) > playerLevel) {
playerLevel++;
MakePlayerFish();
}
}
将LevelUpdate函数添加到update里。
void Update()
{
LevelUpdate();
PlayerUpdate();
}
更改Fish脚本里的OnTriggerEnter2D函数。当鱼被吃掉时,加一得分。
else {
Control.score++;
Destroy(this.gameObject);
}
现在鱼已经可以升级了。但是还有一个隐患。玩家的等级可能超过最大等级。回到LevelUpdate函数,做一下修改。
void LevelUpdate() {
if ((score / levelLine + 1) > playerLevel && (playerLevel < maxLevel)) {
playerLevel++;
MakePlayerFish();
}
}
我们已经完成了玩家鱼的部分,但随机生成的鱼依然是固定的。因此我们还需要修改MakeFish。
删掉原来的Fish fish = fishFactory.Get(0);,根据概率生成大鱼小鱼。我这边设定是三分之二生成比玩家小一级的鱼,六分之一生成比玩家大一级的鱼(如果有的话),剩下的概率生成和玩家同级的鱼。
//Fish fish = fishFactory.Get(0);
Fish fish;
if (Random.Range(0, 3) != 0) {
fish = fishFactory.Get(playerLevel - 1);
}
else if (Random.Range(0, 2) == 0 && playerLevel < maxLevel) {
fish = fishFactory.Get(playerLevel + 1);
}
else {
fish = fishFactory.Get(playerLevel);
}
至此,一个简单的大鱼吃小鱼就完成了。虽然这里只有三种鱼,但只要素材足够,按大小放入FishFactory中,就可以给游戏加入更多等级的鱼。