Unity 制作的经典打鸭子

1,160 阅读6分钟

复刻经典打鸭子游戏

万兴优转_20220412153238.gif

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

前言

这个游戏的来源是小时候玩的卡带游戏,之前做过一个Unity的XLua版本,但是年代久远,看到掘金活动就重新用C#实现了一遍。

安装

因为iOS版本发布有点麻烦,就提供一个安卓的版本,欢迎各位安装游玩,比较粗糙,哈哈,源码传送门在最后。 下载链接:d.maps9.com/vm5n 密码:lq123

游戏规则

1.使用左下角摇杆控制光标移动,使用右下角的发射按钮控制射击。
2.有两种鸭子,代表不同分数,会随机从底部出生,在屏幕内按照路线移动。
3.每次有三颗子弹,射击成功加分,子弹全部用尽或者时间耗尽本关卡就失败。

实现

1,开发环境

使用Unity 2020.3.311f1c1 长期版本,Visual Studio 2022。

2,新建项目

2.1,新建一个3D空白项目

image.png

2.2,可以在项目目录里新建Scripts,Materials,Prefabs,Textures等标准文件夹用于管理导入项目资源。

3,游戏开发素材准备

3.1,因为是一个简单的二维游戏,需要的游戏资源主要是大量序列帧图片,所以出于资源优化,首先使用TexturePacker把使用的图片资源打包成为图集。如下图所示,具体使用较为简单,如有兴趣可以自行测试。

image.png

3.2,导入图集到Unity中,如下所示,可以看到摇杆和按钮的图片都打包到了一个图集中。

image.png

3.3,把准备好的字体和音频导入到项目工程目录中,拖拽mp3和.tff的到对于的项目文件夹就可以。

4,搭建UI

4.1,使用UGUI来进行UI的搭建,按照原型效果考虑需要使用的控件。这个部分相对比较流程化,如果不清楚,可以参考项目的一些层级关系和布局即可。
4.2,需要注意的是屏幕适配,需要使用Canvas Scaler这个组件,这个提供了多种适配方案,我这里使用的按照基准分辨率缩放的解决方案,我们先提供一个基准分辨率,然后根据是横屏还是竖屏游戏设置Match,如下所示,细节各位可以多调试。

image.png

4.3,布局,考虑好UI的层级关系,使用锚点对UI控件进行布局,这里就不进行细节说明。

image.png

4.4,完成搭建之后,最好对UI控件进行合理命名,便于组件引用资源时候比较清楚具体的功能,整体效果如下。

image.png

5,动画效果

5.1,开场动画和狗的一些动画,主要是一些序列帧图片,使用脚本实现一个简单GIF功能即可。

private void Play(int iFrame)
        {
            if (iFrame >= FrameCount)
            {
                iFrame = 0;
            }
            //没有Loop的情况,只会播放一次就结束
            if (!Loop)
                //Animation is end 
                if (iFrame + 1 == FrameCount)
                {
                    isPlay = false;
                    OnAnimationPlayEnd.Invoke();
                }
            shower.sprite = LSprites[iFrame];
            curFrame = iFrame;
        }

5.2,鸭子的动画需要使用状态机进行管理,在Unity中新建动画控制器,再使用各组序列帧图片生成动画状态,比如飞行为fly,死亡是die,在控制器中把各种切换状态进行关联,使用触发器进行动画效果切换,如下代码实现了鸭子死亡之后触发到die的动画状态,主要是对Animator组件的API使用。

private IEnumerator DelayChangeFallAni()
    {
        yield return new WaitForSeconds(0.5f);
        target.GetComponent<Animator>().SetTrigger("duckDie");
        ScoreFlag();
        IsFall = true;
    }

animatorGif.gif

5.3,动画资源的释放,因为开场动画使用引用大量的序列帧图片加载到内存,所以使用后可以对这部分资源进行释放,实现也相对简单,在合适的时机对这个对象进行销毁,释放无用资源即可。

private IEnumerator DelayDestroyOpeningAnimation()
    {
        yield return new WaitForSeconds(1);
        DestroyImmediate(OpeningMovie, true);
        OpeningMovie = null;
        Resources.UnloadUnusedAssets();
    }

6,射击光标实现

6.1,遥控杆主要是使用UGUI中的Event Trigger里面的拖拽事件进行实现,这部分是事件系统中的相关使用,如下。

public void BeginDrag()
        {
            touchPresent = true;
            if (TouchStateEvent != null)
                TouchStateEvent(touchPresent);
        }

        public void EndDrag()
        {
            touchPresent = false;
            movementVector = joystickArea.anchoredPosition = Vector2.zero;

            if (TouchStateEvent != null)
                TouchStateEvent(touchPresent);

        }

        public void OnValueChanged(Vector2 value)
        {
            if (touchPresent)
            {
                // convert the value between 1 0 to -1 +1
                movementVector.x = ((1 - value.x) - 0.5f) * 2f;
                movementVector.y = ((1 - value.y) - 0.5f) * 2f;

                if (TouchEvent != null)
                {
                    TouchEvent(movementVector);
                }
            }
        }

6.2,使用摇杆的偏移数值计算光标的位置,摇杆的上下左右移动会有正负的偏移值,把这部分偏移值乘上一个固定的数值(光标移动速度)。之后获取光标的本地坐标,本地坐标加上偏移值就是需要移动的目标坐标。因为光标是一个UI组件,这里需要把本地坐标转换为世界坐标,再把世界坐标转换为屏幕坐标,其目的是为了限定光标只能在屏幕范围内移动。UGUI坐标->屏幕坐标,再获取当前设备的屏幕长宽判断移动的屏幕坐标位置不超出屏幕位置,核心实现如下。

private void aimPointController()
    {
        if (joystick)
        {
            Vector3 localPos = ImageAim.transform.localPosition;
            float x = localPos.x + joystick.GetTouchPosition.x * JoyXmoveSpeed;
            float y = localPos.y + joystick.GetTouchPosition.y * JoyYmoveSpeed;
            Vector3 movePos = new Vector3(x, y, localPos.z);
            //Debug.Log(movePos);
            
            //--local position to world position order conver to screenposition 需要本地转换为世界才能正确转换为ScreenPOS
            Vector3 screenPos = RectTransformUtility.WorldToScreenPoint(mainCam, Canvas.transform.TransformPoint(movePos));
            Debug.Log(screenPos);
            //--clamp x in 0 - width clamp y in 0 - height 对移动范围限定在屏幕范围内
            if (screenPos.x > 0 && screenPos.x < duckHuntUtility.GetScreenWidthAndHeight().x && screenPos.y > 0 && screenPos.y < duckHuntUtility.GetScreenWidthAndHeight().y)
            {
                ImageAim.transform.localPosition = movePos;
            }
        }
    }

7,鸭子的控制器

7.1,鸭子的生成,首先我在生成的区间左右个放置了一个空游戏对象,使用随机函数生成这两个点中的一个随机数,作为出生点。

private Vector3 RandomBornPoint()
    {
        Vector3 leftPointPos = PointLeft.transform.localPosition;
        Vector3 rightPointPos = PointRight.transform.localPosition;
        float ranX = Random.Range(leftPointPos.x, rightPointPos.x);
        return new Vector3(ranX, leftPointPos.y, leftPointPos.z);
    }

生成出生点之后,实例化这个对象,把它放置在预制的父物体之下,下面就在FixedUpdate()里面编写生成鸭子轨迹的方法就可以了。

7.2,鸭子的屏幕内弹射移动,这里主要也是判断鸭子的移动是否到了屏幕的限定边界(上下左右的屏幕边缘),到了之后对鸭子的移动方向取反,生成新的路线,使用Translate就可以达到效果,因为是二维空间移动,只需要计算x和y的坐标即可。这里还有一个点是飞行方向改变之后对鸭子Sprite进行一次镜像,鸭子就可以转身了,当然这只是我的一种思路,主要实现如下。

private void borderCheck()
    {
        Vector3 screenPos = mainCam.WorldToScreenPoint(target.transform.position);
        //-- x > width axis judge
        if (screenPos.x > duckHuntUtility.GetScreenWidthAndHeight().x)
        {
            vel_x = -vel_x;
            //--在x轴方向发生改变之后,镜像鸭子来改变飞行动画方向
            if (judgeDirection(vel_x) == FlyDirection.left)
            {
                target.GetComponent<SpriteRenderer>().flipX = false;
            }
            else
            {
                target.GetComponent<SpriteRenderer>().flipX = true;
            }
        }
        //--x < width axis judge
        if (screenPos.x < 0)
        {
            vel_x = -vel_x;
            if (judgeDirection(vel_x) == FlyDirection.left)
            {
                target.GetComponent<SpriteRenderer>().flipX = false;
            }
            else
            {
                target.GetComponent<SpriteRenderer>().flipX = true;
            }
        }
        //--limit top position(Screen.Height)
        if (screenPos.y > duckHuntUtility.GetScreenWidthAndHeight().y)
        {
            vel_y = -vel_y;
        }
        //--limit bottom position
        if (screenPos.y < duckHuntUtility.GetScreenWidthAndHeight().y * bottomPercent)
        {
            vel_y = -vel_y;
        }
    }

7.3,鸭子的死亡和销毁,击中鸭子之后,鸭子需要下落,在离开游戏画面之后需要销毁对象。这里,我为鸭子的预制体添加一个向下的力来达到下落的效果。需要先给组件添加一个刚体组件,这个就可以达到物理系统模拟。

target.GetComponent<Rigidbody>().AddForce(-Vector3.up * 99, UnityEngine.ForceMode.Acceleration);

在鸭子下落到一定的位置,这里使用一个碰撞检测就可以,现在场景中添加一个碰撞体,再给鸭子预制也添加上碰撞体。这里我简单做了一层封装,可以更加方便使用事件的注册来处理碰撞之后的逻辑,如下。
(1),封装一些碰撞类,添加到组件

public delegate void LuaCollisionDelegate(Collision _col);
    /// <summary>
    /// 3D Collision Event
    /// </summary>
    public class DuckHuntCollision : MonoBehaviour
    {
        public event LuaCollisionDelegate EventEnter;
        public event LuaCollisionDelegate EventStay;
        public event LuaCollisionDelegate EventExit;
        private void OnCollisionEnter(Collision collision)
        {
            EventEnter(collision);
        }

        private void OnCollisionStay(Collision collision)
        {
            EventStay(collision);
        }

        private void OnCollisionExit(Collision collision)
        {
            EventExit(collision);
        }
    }

(2),监听下落碰撞

target.GetComponent<DuckHuntCollision>().EventEnter += CheckDuckFailDownEvent;

image.png

8,射击检测

这里使用射线检测,实现思路是每次点击射击就从射击光标位置生成射线,如果和移动的鸭子产出碰撞检测,执行后续的逻辑即可。因为编辑器比较难操作,游戏开始后可以按下键盘space来直接模拟射击中鸭子的逻辑,具体参考代码即可。

private void raycastAimPoint(Vector3 _screenPos)
    {
        Ray ray = mainCam.ScreenPointToRay(new Vector3(_screenPos.x, _screenPos.y, 0));
        RaycastHit hit = AimRaycast(ray, Mathf.Infinity);
        if (hit.collider)
        {
            chooseObj = hit.collider.gameObject;
        }
        else
        {
            chooseObj = null;
        }
    }

9,屏幕特效等

点击射击之后,屏幕会有一个闪光效果,这里使用一个屏幕特效,每次对数值进行处理即可达到效果。

image.png

音频播放需要注意如果音效重叠,需要考虑播放的时机。

10,源码地址

源码仓库点这里