用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中,就可以给游戏加入更多等级的鱼。