今天打开我尘封的项目,发现我居然有点看不懂了

而且我的专业版许可证退化成了个人版 (⊙︿⊙) 太可恶了(不过影响不亚于没吃早饭)

​ image-20260323213252633

没办法,重新捋一遍吧,用进废退,不用就会遗忘如是说也

Scene场景

​ image-20260323213714075

当时我是创建了四个场景,TheBeginingScene(主场景),TVGame(小游戏选取界面),EarthingHitting(第一个小游戏),GentlePigs(第二个游戏)

PokerCrush(第三个游戏)

众所周知,unity中的scene场景文件互不干扰

TheBeginingScene(主场景)

所以TheBeginingScene(主场景)中的Hierarchy(层级)窗口里重新熟悉一下之前创建的场景树吧

​ image-20260323215050773

​ 这里主要说一下这个小角色,这个是当时跟着教程制作的半成品

其中,player是父对象,Pistol和shootGun这两把枪是子对象

​ image-20260323215444311

这里默认将两把枪对象禁用,注意,禁用父对象同时会禁用父对象底下的所有子对象

muzzle是枪口位置,bulletshell是开枪后弹壳生成的位置

这个我目前就放了transform组件用于定位子对象相对于父对象的local坐标系的位置

​ image-20260323215644035

​ image-20260323215938490

角色的动画状态机如下

分为那两个状态:

knight_idle和knight_run

状态也非常简单就两个,所以没有必要使用blend混合树(我之前 用混合树差点把我绕晕)

判断状态切换条件是布尔类型值isMoving,这个东西代码里会设置的

​ image-20260323220116321

建立状态之间的连接 make transition

​ image-20260323220326934

刚才忘记说了,在player父对象上需要增加这几个组件

transform转换组件——控制角色的位置,旋转,缩放

sprite renderer 精灵图渲染组件——-渲染精灵图到游戏画面

animator—–动画组件,用于放置角色的行为控制组件animate controller

rigidbody 2d ——2d刚体组件,控制角色的重力,默认gravity scale为0,如果大于0游戏开始时角色会获得重力

​ image-20260323221002342

​ image-20260323221548692

至于下面的这个是角色的C#控制脚本,控制角色的行为

C#已复制
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Player_Controller : MonoBehaviour
{
    // 玩家移动相关变量
    public float playerSpeed = 5f;//玩家速度
    private Rigidbody2D playerRigidbody;//玩家刚体
    private Animator playerAnimator;//玩家动画
    private Vector2 movementInput;//玩家输入,用于移动

    //武器相关变量
    public GameObject[] weapons;//武器数组
    private int currentWeaponIndex;//当前武器索引
    private Vector2 mousePosition;//鼠标位置


    void Start()
    {
        GetComponents();//获取组件引用

        weapons[0].SetActive(true);//默认激活第一把武器
    }

    void Update()
    {
        MovementLogic();//玩家控制行为
        SwitchWeapon();//切换武器
    }

    //获取组件引用
    void GetComponents()
    {
        playerRigidbody = GetComponent<Rigidbody2D>();
        playerAnimator = GetComponent<Animator>();
    }

    //玩家控制行为
    void MovementLogic()
    {
        //获取玩家输入
        movementInput.x = Input.GetAxisRaw("Horizontal");
        movementInput.y = Input.GetAxisRaw("Vertical");

        //移动玩家的刚体而非直接移动Transform
        playerRigidbody.velocity = movementInput.normalized * playerSpeed;

        //获取鼠标在世界坐标中的位置
        mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);

        //根据鼠标位置调整玩家朝向
        if (mousePosition.x > transform.position.x)
        {
            transform.rotation = Quaternion.Euler(new Vector3(0, 0, 0));
        }
        else
        {
            transform.rotation = Quaternion.Euler(new Vector3(0, 180, 0));
        }

        //根据角色移动状态切换动画
        if (movementInput != Vector2.zero)
        {
            playerAnimator.SetBool("isMoving", true);
        }
        else
        {
            playerAnimator.SetBool("isMoving", false);
        }
    }

    //切换武器
    void SwitchWeapon()
    {
        if (Input.GetKeyDown(KeyCode.Q))
        {
            weapons[currentWeaponIndex].SetActive(false);
            if (--currentWeaponIndex < 0)
            {
                currentWeaponIndex = weapons.Length - 1;
            }
            weapons[currentWeaponIndex].SetActive(true);
        }
        if (Input.GetKeyDown(KeyCode.E))
        {
            weapons[currentWeaponIndex].SetActive(false);
            if (++currentWeaponIndex > weapons.Length - 1)
            {
                currentWeaponIndex = 0;
            }
            weapons[currentWeaponIndex].SetActive(true);
        }
    }
}

这里就是首先Player_Controller 类继承 MonoBehaviour类(也就是unity自己定义的Start() Update() Awake()这些函数 )

这里定义了角色的很多属性,对了,unity中如果你在inspector检查器窗口给角色添加组件就必须在C#脚本中声明

比如就代表刚才的我们选择的刚体rigidbody2d组件 animator组件 还有Transform组件里的Vector2类,就是获得二维xy坐标值

对了,unity中的属性如果不加public 或者protected声明的话属性默认是私有的,私有就是这个属性只能在这个脚本里使用,如果是public公开的话其他脚本可以这样启用 首先看看有没有命名空间namespace定义,有的话就using这个命名空间,没有的话直接使用Player_Controller.playerSpeed获取这个属性

C#已复制
private Rigidbody2D playerRigidbody;//玩家刚体
private Animator playerAnimator;//玩家动画
private Vector2 movementInput;//玩家输入,用于移动

在游戏开始时我们需要让玩家角色获取组件(因为刚才只是定义而不是启用),为了保持start函数的干净整洁我把获取的行为放到GetComponents自定义函数里然后在start里调用这个函数

这里角色的行为逻辑由Update和Fixupdate控制,Update是按帧刷新界面,FixUpdate是按固定时间刷新界面,

而且Update主要控制角色的输入行为,FixUpdate控制物理逻辑

这里你观察MovementLogic和SwitchWeapon函数就可以发现主要是键盘的输入输出行为所以我们使用Update 函数

C#已复制
//玩家控制行为
   void MovementLogic()
   {
       //获取玩家输入
       movementInput.x = Input.GetAxisRaw("Horizontal");
       movementInput.y = Input.GetAxisRaw("Vertical");

       //移动玩家的刚体而非直接移动Transform
       playerRigidbody.velocity = movementInput.normalized * playerSpeed;

       //获取鼠标在世界坐标中的位置
       mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);

       //根据鼠标位置调整玩家朝向
       if (mousePosition.x > transform.position.x)
       {
           transform.rotation = Quaternion.Euler(new Vector3(0, 0, 0));
       }
       else
       {
           transform.rotation = Quaternion.Euler(new Vector3(0, 180, 0));
       }

       //根据角色移动状态切换动画
       if (movementInput != Vector2.zero)
       {
           playerAnimator.SetBool("isMoving", true);
       }
       else
       {
           playerAnimator.SetBool("isMoving", false);
       }
   }

首先让玩家的输入的值x和y值分别为Horizontal(水平轴)和 Vertical(垂直轴)上输入返回值

Horizontal 会返回A 和 D 和左箭头 和右箭头的值 默认值为1或-1

Vertical 会返回W 和S 还有上箭头和下箭头 默认值为1或-1

C#已复制
//获取玩家输入
        movementInput.x = Input.GetAxisRaw("Horizontal");
        movementInput.y = Input.GetAxisRaw("Vertical");

​ image-20260323230446828

C#已复制
//移动玩家的刚体而非直接移动Transform
       playerRigidbody.velocity = movementInput.normalized * playerSpeed;

然后将返回的二维坐标normalized归一化(确保各个方向的x平方加y的平方始终为1,保证各个方向移动速度一样,防止角色在斜方向移动速度加快的问题)乘以角色定义的速度得到角色的移动速度,这里选择移动角色的刚体Rigidbody的velocity速度,即赋予刚体一个速度

然后在update里速度乘以帧时间,当按下wasd(用来决定移动方向)时,通过移动角色的刚体小人就可以移动了

之前我们定义了一个参数用来存储鼠标在游戏界面上的位置,并将鼠标实时输入到游戏界面(就是以摄像机可以看到的范围的中心建立坐标系,这个范围是实时变动的)中的位置返回鼠标的相对位置

C#已复制
//获取鼠标在世界坐标中的位置
       mousePosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
C#已复制
//根据鼠标位置调整玩家朝向
        if (mousePosition.x > transform.position.x)
        {
            transform.rotation = Quaternion.Euler(new Vector3(0, 0, 0));
        }
        else
        {
            transform.rotation = Quaternion.Euler(new Vector3(0, 180, 0));
        }

然后判断这个鼠标的位置和角色的X轴的相对位置,因为我的小骑士角色默认的朝向是右边,所以,当鼠标的x轴位置大于角色的x轴的位置时不改变角色的旋转值,反之则使用四元数欧拉旋转将角色翻转180°

C#已复制
//根据角色移动状态切换动画
        if (movementInput != Vector2.zero)
        {
            playerAnimator.SetBool("isMoving", true);
        }
        else
        {
            playerAnimator.SetBool("isMoving", false);
        }

还记得刚才说的isMoving吗,此时就排上用场了,判断角色走或者不走的状态

这个其实是在animator的参数面板parameters里新建的bool值,当然这个也是属于animator组件的属性

​ image-20260323232839346

然后结合状态机里的conditions里的设置大家懂了吗(也就是说,unity中的状态虽然在编辑器里设置了,但是只有在C#脚本中定义触发条件了才会发挥作用)

​ image-20260323233225176

之前我们说过武器的存在,这里先不说武器

C#已复制
//切换武器
    void SwitchWeapon()
    {
        if (Input.GetKeyDown(KeyCode.Q))
        {
            weapons[currentWeaponIndex].SetActive(false);
            if (--currentWeaponIndex < 0)
            {
                currentWeaponIndex = weapons.Length - 1;
            }
            weapons[currentWeaponIndex].SetActive(true);
        }
        if (Input.GetKeyDown(KeyCode.E))
        {
            weapons[currentWeaponIndex].SetActive(false);
            if (++currentWeaponIndex > weapons.Length - 1)
            {
                currentWeaponIndex = 0;
            }
            weapons[currentWeaponIndex].SetActive(true);
        }
    }

Input.GetKeyDown 这个是获取键盘持续按下的信号,而其他的比如Input.GetKey是键盘按钮按一次的信号,Input.GetKeyUP是捕获按钮松开的那一刻的信号,这里的意思是当我按下Q或者E时可以切换武器数组的索引值来切换武器,因为我们在start里设置,角色默认在游戏开始时拿着的武器是索引为0的第一把武器 即

C#已复制
weapons[0].SetActive(true);

当按下Q时先将当前角色正在拿着的武器对象禁用然后判断当前的索引值的上一个索引是否小于0(主要是应对初始是角色手持装备索引为0的情况),比如我们最开始的武器索引值是0,但一共就两把枪,数组的长度为2,所以索引值等于数组的长度减一即获得倒数第一个装备,然后激活这个装备,形成循环,这样我一直按Q就能一直切换武器

同理,按E也是相同的逻辑,只是反过来了

然后只需要去inspector检查器窗口放入两把枪的对象即可

​ image-20260324000104471

这个蓝色的我们管它叫做prefabs预制体,这个不说了

对了忘记说了,再次之前我们需要先制作动画剪辑片段,这个无法用文字描述,大概就是新建一个状态,然后把切割出来的精灵图序列帧拖进到这个动画状态机中形成animation clip剪辑片段

然后状态会有一个对应animation controller来控制相关的所有clip,而controller 是放到animator组件里启用

​ image-20260323220638719

然后在状态机里把这个剪辑clip片段文件放入对应的状态中

​ image-20260323235756331

ok主场景熟悉完了,接下来我们要熟悉的就是TVgame场景

明天继续,今天睡觉

TVGame(小游戏选取界面)

OK,睡醒了,现在熟悉一下TVgame场景

​ image-20260324200118221

首先我们需要了解一下UI系统

​ image-20260324200232407

UI就是玩家与游戏交互的界面,unity中所有UI都是以canvas为父节点,包括界面上的Text文字和Button按钮都是

比如你玩王者荣耀,原神,明日方舟终末地,界面上的设置界面,人物的血条,还有技能按钮攻击按钮这些,都是UI

不过我对UI了解还是太少了,以后学一下更高级的UI吧,比如UI树,这种最直接的方法就是反编译别的unity游戏看看大佬的怎么做的

canvas是在摄像机边界右上角,非常巨大

​ image-20260324201616430

​ image-20260324201729180

这里我是放了两个canvas,分别是gamesetting canvas 和 gamebutton

也就是一个掌管游戏的设置界面,一个掌管交互按钮

先说这个gamesetting canvas 吧

哦对了有一个细节就是

​ image-20260324202525167

UI这个缩放最好选择随屏幕大小缩放,不然窗口大小改变时会发生UI的变形,一般会选择窗口大小为1920×1080的大小

这里我使用了一个pannel,用来作为设置UI的父节点,也就是背后的白色面板

​ image-20260324205321225

对了,这个是游戏背景音乐的设置脚本,然后需要更改对象物体的标签

C#已复制
using UnityEngine.UI;
using UnityEngine;


namespace MyAssets.Framework
{
    public class VolumeController : MonoBehaviour
    {
        //需要控制的声音
        private AudioSource MenuAudio;
        
        //获取到滑动条
        public Slider VolumeSlider;
        void Start()
        {
            MenuAudio = GameObject.FindGameObjectWithTag("Menu").transform.GetComponent<AudioSource>();
            VolumeSlider=GameObject.FindGameObjectWithTag("GameSetting").transform.GetComponent<Slider>();
        }
        
        void Update()
        {
            VolumeControl();
        }
        
        //控制声音音效
        public void VolumeControl()
        {
            MenuAudio.volume = VolumeSlider.value;
        }
        
    }
}

为了实现Button的移动效果(说是button,其实适用于所有的游戏对象),我用AI做了这个脚本

C#已复制
using UnityEngine;

namespace MyAssets.Framework
{
    public class ButtonSlide : MonoBehaviour
    {
        public enum MoveDirection
        {
            Right,
            Left,
            Up,
            Down
        }

        [Header("移动模式选择")] [Tooltip("是否使用自定义终点位置,如果勾选则禁用距离选项")]
        public bool useCustomTarget = false;

        [Header("自定义终点(当 useCustomTarget = true 时启用)")]
        public Vector2 targetPosition; // 终点坐标(相对 RectTransform 父级的 anchoredPosition)

        [Header("按方向与距离移动(当 useCustomTarget = false 时启用)")]
        public MoveDirection direction = MoveDirection.Right;

        public float distance = 500f;

        [Header("运动参数")] [Tooltip("初速度(像素/秒)")]
        public float initialSpeed = 800f;

        [Tooltip("加速度(默认为0时自动计算,使末速度为0)")] public float acceleration = 0f;

        [Header("控制选项")] public bool autoPlay = true;
        public bool loop = false;

        private RectTransform rect;
        private Vector2 startPos;
        private Vector2 endPos;
        private float currentSpeed;
        private float usedAcceleration;
        private bool isPlaying = false;
        private float traveled = 0f;
        private float totalDistance = 0f;

        private void Awake()
        {
            rect = GetComponent<RectTransform>();
            startPos = rect.anchoredPosition;

            // 确定终点位置
            if (useCustomTarget)
            {
                endPos = targetPosition;
                totalDistance = Vector2.Distance(startPos, endPos);
            }
            else
            {
                endPos = startPos + DirectionVector() * distance;
                totalDistance = distance;
            }

            // 自动计算加速度(使终点速度=0)
            if (acceleration == 0f)
            {
                acceleration = -(initialSpeed * initialSpeed) / (2 * totalDistance);
            }

            usedAcceleration = acceleration;
        }

        private void Start()
        {
            if (autoPlay) Play();
        }

        public void Play()
        {
            isPlaying = true;
            traveled = 0f;
            currentSpeed = initialSpeed;
            startPos = rect.anchoredPosition;

            // 每次播放都重新计算终点(防止运行时更改)
            if (useCustomTarget)
            {
                endPos = targetPosition;
                totalDistance = Vector2.Distance(startPos, endPos);
            }
            else
            {
                endPos = startPos + DirectionVector() * distance;
                totalDistance = distance;
            }

            if (acceleration == 0f)
            {
                acceleration = -(initialSpeed * initialSpeed) / (2 * totalDistance);
            }

            usedAcceleration = acceleration;
        }

        private void Update()
        {
            if (!isPlaying) return;

            float delta = currentSpeed * Time.deltaTime;
            traveled += Mathf.Abs(delta);

            if (traveled >= totalDistance)
            {
                rect.anchoredPosition = endPos;
                currentSpeed = 0f;
                isPlaying = false;

                if (loop)
                {
                    // 返回原位重新开始
                    (startPos, endPos) = (endPos, startPos);
                    Play();
                }

                return;
            }

            // 更新位置(根据移动模式计算方向)
            Vector2 dir = (endPos - startPos).normalized;
            rect.anchoredPosition += dir * delta;

            // 减速
            currentSpeed += usedAcceleration * Time.deltaTime;
            if (currentSpeed < 0) currentSpeed = 0;
        }

        private Vector2 DirectionVector()
        {
            switch (direction)
            {
                case MoveDirection.Left: return Vector2.left;
                case MoveDirection.Right: return Vector2.right;
                case MoveDirection.Up: return Vector2.up;
                case MoveDirection.Down: return Vector2.down;
                default: return Vector2.right;
            }
        }

        public void Stop() => isPlaying = false;

        // 为了在编辑器中更清晰显示当前禁用选项
#if UNITY_EDITOR
        private void OnValidate()
        {
            if (useCustomTarget)
            {
                // 如果使用终点,就锁定距离和方向
                distance = Mathf.Max(0.01f, distance);
            }
        }
#endif
    }
}

然后就是设定好移动速度和移动方向和移动距离,这里我是想让这个按钮向上移动

​ image-20260324205732577

但是开始的时候我是把这个设置面板设置为false状态

当且仅当我点击这个设置按钮–也就是这个骰子ui按钮时,才会弹出来这个

​ image-20260324210710215

就是在按钮的on click里选择Panel对象,点击事件为SetActive激活物品对象,这个还需要另一个脚本放到一个空对象上

来控制弹出的设置界面的关闭(说实在,感觉真的没必要,但是我没有更好的解决办法)

C#已复制
using UnityEngine;


namespace  MyAssets.Framework
{
    public class SetActiveObject : MonoBehaviour
    {
        public GameObject myObject;
        bool isActive = false;
        
        void Update()
        {
            OpenObject();
        }
        
        void OpenObject()
        {
            if (Input.GetKeyDown(KeyCode.Escape))
            {
                isActive=!isActive;
                myObject.SetActive(isActive);
            }
        }
    }
}

这里为了给按钮增加一个鼠标放上去按钮就会放大一点,离开就会恢复原状,这里增加一个通用脚本button scale

这里的命名空间MyAssets.Framework 是指所有对象都可以公用的脚本

C#已复制
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

namespace MyAssets.Framework
{
    public class ButtonScale : MonoBehaviour
    {
        [Header("按钮")] public Button btn; // 拖入开始按钮

        [Header("放大设置")] public  float scaleMultiplier = 1.15f; // 放大多少
        public float animSpeed = 8f; // 越大越快

        // 记录原始缩放
        protected Vector3 originalScale;

        // 记录鼠标是否悬停
        protected bool isHovering;

        protected virtual void Awake()
        {
            originalScale = transform.localScale;

            btn = GetComponent<Button>();

            if (btn == null)
            {
                Debug.LogError("HoverScale 需要挂在 Button 物体上");
            }
        }

        protected virtual void OnEnable()
        {
            // 监听鼠标进出
            btn.onClick.AddListener(() => { }); // 防止按钮无响应,可忽略

            var trigger = btn.gameObject.GetComponent<EventTrigger>();

            if (trigger == null)
            {
                trigger = btn.gameObject.AddComponent<EventTrigger>();
            }

            // 进入
            var entry = new EventTrigger.Entry
            {
                eventID = EventTriggerType.PointerEnter
            };

            entry.callback.AddListener(_ => isHovering = true);

            trigger.triggers.Add(entry);

            // 离开
            var exit = new EventTrigger.Entry
            {
                eventID = EventTriggerType.PointerExit
            };

            exit.callback.AddListener(_ => isHovering = false);

            trigger.triggers.Add(exit);
        }

        protected virtual void Update()
        {
            Vector3 target = isHovering ? originalScale * scaleMultiplier : originalScale;
            transform.localScale = Vector3.Lerp(transform.localScale, target, animSpeed * Time.deltaTime);
        }
    }
}

说实话,我的逻辑十分混乱我把函数都作为 protected virtual属性目的就是为了实现可以继承,就是让startbutton和exitbutton继承按钮可以缩放的功能,现在看来真的是完全没有必要啊

结果导致屎山的出现

然后就导致接下来的两个脚本exitbutton和stratbutton离不开这个脚本,也就是必须继承这个脚本的属性,甚至为这个东西搞了一个接口ISceneLoader来切换场景

C#已复制
using UnityEngine;
using UnityEngine.UI;

namespace MyAssets.Framework
{
    public class ExitButton : ButtonScale, ISceneLoader
    {
        public string targetSceneName = "TVSelectionScene";
        private IButtonEscape escapeLogic;

        protected override void Awake()
        {
            base.Awake();
            scaleMultiplier = 0.85f;

            if (btn == null) btn = GetComponent<Button>();
            btn?.onClick.RemoveAllListeners();
            btn?.onClick.AddListener(OnClick);

            escapeLogic = GetComponent<IButtonEscape>();
        }

        void Update()
        {
            base.Update();
            escapeLogic?.HandleEscape(transform as RectTransform, GetComponentInParent<Canvas>());
        }

        private void OnClick()
        {
            btn.interactable = false;
            ButtonManager.Instance.DisableOther(btn);
            SwitchScene(1f);
        }

        public void SwitchScene(float delay = 0f)
        {
            ButtonManager.Instance.LoadScene(targetSceneName, delay);
        }
    }
}
C#已复制
using UnityEngine;
using UnityEngine.UI;

namespace MyAssets.Framework
{
    public class StartButton : ButtonScale, ISceneLoader
    {
        public string targetSceneName = "EarthHittingGameScene";

        protected override void Awake()
        {
            base.Awake();
            scaleMultiplier = 1.15f;

            if (btn == null) btn = GetComponent<Button>();

            if (btn != null)
            {
                btn.onClick.RemoveAllListeners();
                btn.onClick.AddListener(OnClick);
            }
            else
            {
                Debug.LogError($"{name}: StartButton 缺少 Button 组件!");
            }
        }

        private void OnClick()
        {
            btn.interactable = false;
            ButtonManager.Instance.DisableOther(btn);
            SwitchScene(1f);
        }

        public void SwitchScene(float delay = 0f)
        {
            ButtonManager.Instance.LoadScene(targetSceneName, delay);
        }
    }
}
C#已复制
namespace MyAssets.Framework
{
    public interface ISceneLoader
    {
        void SwitchScene(float delay = 0f);
    }
}

但是我目前没有更好的解决办法,还需要多看看大佬的教学,现在的代码完全是可以跑的屎山

​ image-20260324212800176

​ image-20260324212838265

unity中只有把场景scene文件添加到这里才能被编译器识别

我当时也是神经病,因为我是这样想的,开始按钮脚本startbutton放到这三个彩色的按钮上,然后退出这个场景(本质是切换场景)按这个装载了exitbutton的按钮,然后在每个按钮对象的Inventory检查器窗口里设置点击后要切换的目标场景也就是Target scene name,不过先需要指定当前的要被点击的按钮是哪一个按钮(怎么感觉有点小题大做)

​ image-20260324212311316

这里开始按钮和退出按钮本质上没有区别,唯一的区别就是退出按钮增加一个逻辑就是退出按钮我是模仿米塔做的一个好玩的效果—-当我试图退出游戏时按钮会尝试逃离鼠标指针不让玩家退出

C#已复制
using UnityEngine;

namespace MyAssets.Framework
{
    //通用按钮逃跑逻辑组件,实现 IButtonEscape。
    //可与其他按钮脚本组合使用。

    [RequireComponent(typeof(RectTransform))]
    public class ButtonEscapeLogic : MonoBehaviour, IButtonEscape
    {
        [Header("功能开关")] public bool enableEscape = true;

        public bool EnableEscape
        {
            get => enableEscape;
            set => enableEscape = value;
        }

        [Header("检测范围")] public Vector2 detectBoxSize = new Vector2(300f, 150f);

        public Vector2 DetectBoxSize
        {
            get => detectBoxSize;
            set => detectBoxSize = value;
        }

        [Header("逃跑参数")] public float escapeDistance = 200f;

        public float EscapeDistance
        {
            get => escapeDistance;
            set => escapeDistance = value;
        }

        public float moveSpeed = 6f;

        public float MoveSpeed
        {
            get => moveSpeed;
            set => moveSpeed = value;
        }

        [Header("可视化设置")] public Color gizmoColor = new Color(0f, 191f, 255f, 0.3f);

        private RectTransform rect;
        private Canvas canvas;
        private Vector2 originalPos;
        private Vector2 targetPos;

        void Awake()
        {
            rect = GetComponent<RectTransform>();
            canvas = GetComponentInParent<Canvas>();
            originalPos = rect.anchoredPosition;
            targetPos = originalPos;
        }

        void Update()
        {
            HandleEscape(rect, canvas);
        }

        public void HandleEscape(RectTransform rect, Canvas canvas)
        {
            if (!enableEscape || !rect || !canvas)
            {
                return;
            }

            Vector2 mousePos = Input.mousePosition;
            Vector2 buttonCenter = RectTransformUtility.WorldToScreenPoint(canvas.worldCamera, rect.position);

            bool inside =
                mousePos.x >= buttonCenter.x - detectBoxSize.x / 2 &&
                mousePos.x <= buttonCenter.x + detectBoxSize.x / 2 &&
                mousePos.y >= buttonCenter.y - detectBoxSize.y / 2 &&
                mousePos.y <= buttonCenter.y + detectBoxSize.y / 2;

            if (inside)
            {
                Vector2 dir = GetEscapeDirection(mousePos, buttonCenter);
                targetPos = originalPos + dir * escapeDistance;
            }
            else
            {
                targetPos = originalPos;
            }

            rect.anchoredPosition = Vector2.Lerp(rect.anchoredPosition, targetPos, moveSpeed * Time.deltaTime);
        }

        private Vector2 GetEscapeDirection(Vector2 mouse, Vector2 center)
        {
            float dx = mouse.x - center.x;
            float dy = mouse.y - center.y;

            Vector2 dir = Vector2.zero;

            if (dx >= 0 && dy >= 0)
                dir = new Vector2(-1, -1); // 第一象限 -> 第三象限
            else if (dx < 0 && dy >= 0)
                dir = new Vector2(1, -1); // 第二象限 -> 第四象限
            else if (dx < 0 && dy < 0)
                dir = new Vector2(1, 1); // 第三象限 -> 第一象限
            else if (dx >= 0 && dy < 0)
                dir = new Vector2(-1, 1); // 第四象限 -> 第二象限

            // 若在坐标轴上,仅沿垂直方向逃跑
            if (Mathf.Approximately(dx, 0))
                dir.x = 0;
            if (Mathf.Approximately(dy, 0))
                dir.y = -Mathf.Sign(dy);

            return dir.normalized;
        }

        void OnDrawGizmos()
        {
            if (rect == null) rect = GetComponent<RectTransform>();
            if (rect == null) return;

            Vector3[] corners = new Vector3[4];
            rect.GetWorldCorners(corners);
            Vector3 center = (corners[0] + corners[2]) / 2;

            Gizmos.color = gizmoColor;
            Gizmos.DrawWireCube(center, new Vector3(detectBoxSize.x, detectBoxSize.y, 0));
        }
    }
}
C#已复制
using UnityEngine;

namespace MyAssets.Framework
{
    //按钮逃跑逻辑接口,用于定义统一的行为。
    //任何按钮类或组件都可以实现它。

    public interface IButtonEscape
    {
        bool EnableEscape { get; set; }
        Vector2 DetectBoxSize { get; set; }
        float EscapeDistance { get; set; }
        float MoveSpeed { get; set; }


        //检查鼠标并执行逃跑逻辑。

        void HandleEscape(RectTransform rect, Canvas canvas);
    }
}

这里我也感觉和屎山一样,我是让这个检测范围可视化,这样方便我调整检测范围的大小,以及逃跑的距离

​ image-20260324214035232

效果非常好是非常好,和米塔那个一模一样,但是我感觉自己脚本管理能力太差了,不能实现解耦合

这里游戏的背景我原本采用纯黑色,但是感觉太单调了,于是我在Pinterest上找灵感,然后找到了一种效果感觉非常好看,于是我的灵感是吧扑克牌的四种花色在asperite软件里画成这个效果,然后让这些图案随机移动

​ image-20260324215209249

这里是通过一个脚本BackGroundItemController来实现这个扑克牌的四种花色的出现数量和一定范围还有移动逻辑

​ image-20260324215918992

C#已复制
using System.Collections.Generic;
using UnityEngine;

namespace MyAssets.Framework
{
    public class BackgroundItemController : MonoBehaviour
    {
        // XY范围
        [Header("生成范围设置")] public Vector2 areaSize = new Vector2(10f, 5f);

        [Header("是否显示检测范围")] public bool showArea = true;

        [Header("范围颜色设置")] public Color areaColor = new Color(0f, 1f, 0f, 0.2f);

        //可以在Inspector中加减
        [Header("预制体设置")] public GameObject[] prefabs;

        //预制体同时存在的最大数量
        [Range(0, 100)] public int maxObjects = 10;

        [Header("移动速度随机范围")] public float moveSpeedMin = 0.5f;
        public float moveSpeedMax = 2f;

        [Header("旋转速度随机范围")] public float rotateSpeedMin = 10f;
        public float rotateSpeedMax = 50f;

        // 避免预制体重叠的最小距离
        public float minDistanceBetweenObjects = 0.5f;

        // 当前活动的预制体列表
        private List<GameObject> activeObjects = new List<GameObject>();

        void Start()
        {
            // 初始化时生成目标数量
            while (activeObjects.Count < maxObjects)
            {
                SpawnRandomObject();
            }
        }

        void Update()
        {
            // 检查并补充被销毁的对象
            for (int i = activeObjects.Count - 1; i >= 0; i--)
            {
                if (!activeObjects[i])
                {
                    activeObjects.RemoveAt(i);
                }
                else
                {
                    // 检查是否超出范围
                    Vector3 pos = activeObjects[i].transform.position;

                    if (!IsInsideArea(pos))
                    {
                        Destroy(activeObjects[i]);
                        activeObjects.RemoveAt(i);
                    }
                }
            }

            // 如果数量不足,则生成新对象
            while (activeObjects.Count < maxObjects)
            {
                SpawnRandomObject();
            }
        }

        void SpawnRandomObject()
        {
            if (prefabs == null || prefabs.Length == 0)
            {
                return;
            }

            // 随机选择一个预制体
            GameObject prefab = prefabs[Random.Range(0, prefabs.Length)];

            // 在边界随机选一个出生点
            Vector2 spawnPos = GetRandomEdgePosition();
            Vector3 worldPos = transform.position + new Vector3(spawnPos.x, spawnPos.y, 0);

            // 检查与现有对象的距离,避免重叠
            foreach (var obj in activeObjects)
            {
                if (!obj)
                {
                    continue;
                }

                if (Vector3.Distance(obj.transform.position, worldPos) < minDistanceBetweenObjects)
                {
                    return; // 太近就放弃这次生成
                }
            }

            GameObject newObj = Instantiate(prefab, worldPos, Quaternion.identity, transform);

            // 随机旋转角度与旋转方向
            float randomAngle = Random.Range(0f, 360f);
            newObj.transform.rotation = Quaternion.Euler(0, 0, randomAngle);

            // 添加控制组件,来设置移动和旋转速度
            BackgroundItemMover mover = newObj.AddComponent<BackgroundItemMover>();
            mover.Init(this);

            activeObjects.Add(newObj);
        }


        //判断预制体是否在生成范围内
        bool IsInsideArea(Vector3 pos)
        {
            Vector3 localPos = pos - transform.position;

            return (Mathf.Abs(localPos.x) <= areaSize.x / 2f) && (Mathf.Abs(localPos.y) <= areaSize.y / 2f);
        }


        //获取预制体初始位置,设定在长方形生成范围边界上随机位置
        Vector2 GetRandomEdgePosition()
        {
            // 随机选择边界:上下左右之一
            int edge = Random.Range(0, 4);
            float x = 0, y = 0;
            switch (edge)
            {
                case 0: // 上边
                    x = Random.Range(-areaSize.x / 2f, areaSize.x / 2f);
                    y = areaSize.y / 2f;
                    break;
                case 1: // 下边
                    x = Random.Range(-areaSize.x / 2f, areaSize.x / 2f);
                    y = -areaSize.y / 2f;
                    break;
                case 2: // 左边
                    x = -areaSize.x / 2f;
                    y = Random.Range(-areaSize.y / 2f, areaSize.y / 2f);
                    break;
                case 3: // 右边
                    x = areaSize.x / 2f;
                    y = Random.Range(-areaSize.y / 2f, areaSize.y / 2f);
                    break;
            }

            return new Vector2(x, y);
        }

        void OnDrawGizmos()
        {
            //判断是否显示范围
            if (!showArea)
            {
                return;
            }

            //指定范围颜色并绘制
            Gizmos.color = areaColor;
            Gizmos.DrawCube(transform.position, new Vector3(areaSize.x, areaSize.y, 0.01f));
        }
    }
}

然后又是屎山的出现,当时不知道怎么想的,把物品移动的逻辑单独分开了

C#已复制
using UnityEngine;

namespace MyAssets.Framework
{
    public class BackgroundItemMover : MonoBehaviour
    {
        private BackgroundItemController controller;
        private Vector3 direction;
        private float moveSpeed;
        private float rotateSpeed;
        private int rotateDir;

        public void Init(BackgroundItemController ctrl)
        {
            controller = ctrl;
            // 随机方向指向中心区域
            direction = (ctrl.transform.position - transform.position).normalized + new Vector3(Random.Range(-0.3f, 0.3f), Random.Range(-0.3f, 0.3f), 0);
            moveSpeed = Random.Range(ctrl.moveSpeedMin, ctrl.moveSpeedMax);
            rotateSpeed = Random.Range(ctrl.rotateSpeedMin, ctrl.rotateSpeedMax);
            rotateDir = Random.value > 0.5f ? 1 : -1;
        }

        void Update()
        {
            // 移动
            transform.position += direction * (moveSpeed * Time.deltaTime);
            // 自转
            transform.Rotate(Vector3.forward, rotateDir * rotateSpeed * Time.deltaTime);
        }
    }
}

然后我又干了一个傻逼的事,我给物品添加的碰撞逻辑,对,你没听错,碰撞逻辑,就是给物体添加碰撞体,还给这东西写了一套智能避障脚本,还搞了一个接口,纯纯屎山

结果一启动我的渣本卡的要死,现在已经考虑要不要去掉这个东西了

​ image-20260324221003468

C#已复制
using UnityEngine;

namespace MyAssets.Framework
{
    public class ItemElasticMover : MonoBehaviour
    {
        private Rigidbody2D rb;
        private BackgroundItemController controller;

        private float moveSpeed;
        private float rotateSpeed;
        private int rotateDir;

        [Header("检测与避让参数")] [Tooltip("检测范围半径(单位:世界空间单位)")]
        public float detectRadius = 1.5f;

        [Tooltip("两次方向重置之间的冷却时间(秒)")] public float resetCooldown = 0.5f;

        [Tooltip("检测的层(默认全部)")] public LayerMask detectionLayer = ~0;

        private float lastResetTime = -999f;
        private float minSpeedThreshold = 0.05f;

        [Header("检测范围可视化")] public bool showDetectRange = true;
        public Color detectRangeColor = new Color(1f, 0.5f, 0f, 0.3f);

        public void Init(BackgroundItemController ctrl)
        {
            controller = ctrl;
            rb = GetComponent<Rigidbody2D>();

            // === 刚体设置 ===
            rb.gravityScale = 0;
            rb.drag = 0.3f;
            rb.angularDrag = 0.3f;
            rb.mass = 1f;
            rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;

            // === 设置碰撞材质 ===
            var col = GetComponent<Collider2D>();
            var mat = new PhysicsMaterial2D("ElasticMat");
            mat.bounciness = 0.5f;
            mat.friction = 0f;
            col.sharedMaterial = mat;

            if (detectionLayer == 0)
                detectionLayer = LayerMask.GetMask("Default");

            ResetDirection();
        }

        public void Move()
        {
            DetectAndReset();
            MaintainMotion();
        }

        private void DetectAndReset()
        {
            Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, detectRadius, detectionLayer);
            Vector2 separationForce = Vector2.zero;
            int nearbyCount = 0;

            foreach (var hit in hits)
            {
                if (hit.gameObject == gameObject) continue;
                Vector2 diff = (Vector2)(transform.position - hit.transform.position);
                float dist = diff.magnitude;
                if (dist > 0.001f)
                {
                    // 距离越近,推开力度越大
                    separationForce += diff.normalized / dist;
                    nearbyCount++;
                }
            }

            if (nearbyCount > 0)
            {
                separationForce /= nearbyCount;
                separationForce.Normalize();

                // 添加微量随机扰动
                separationForce += Random.insideUnitCircle * 0.3f;
                separationForce.Normalize();

                // 用力而非瞬间设置速度
                rb.AddForce(separationForce * (moveSpeed * 1.2f), ForceMode2D.Impulse);

                // 限制最大速度
                if (rb.velocity.magnitude > controller.moveSpeedMax)
                    rb.velocity = rb.velocity.normalized * controller.moveSpeedMax;
            }

            // 保持转动
            rb.angularVelocity = rotateSpeed * rotateDir;
        }

        private void ResetDirection()
        {
            if (!rb) return;

            rb.velocity = Vector2.zero;
            rb.angularVelocity = 0f;

            // 新方向:随机+轻微偏向中心
            Vector2 dir = (controller.transform.position - transform.position).normalized;
            dir += Random.insideUnitCircle * 0.6f;
            dir.Normalize();

            moveSpeed = Random.Range(controller.moveSpeedMin, controller.moveSpeedMax);
            rotateSpeed = Random.Range(controller.rotateSpeedMin, controller.rotateSpeedMax);
            rotateDir = Random.value > 0.5f ? 1 : -1;

            rb.velocity = dir * moveSpeed;
            rb.angularVelocity = rotateSpeed * rotateDir;
        }

        private void MaintainMotion()
        {
            if (rb.velocity.magnitude < minSpeedThreshold && Time.time - lastResetTime > 0.3f)
            {
                ResetDirection();
            }
        }

        private void OnCollisionEnter2D(Collision2D collision)
        {
            ResetDirection();
        }

        private void Update()
        {
            Move();
        }

        // === Gizmo 可视化 ===
        private void OnDrawGizmosSelected()
        {
            if (!showDetectRange) return;

            Gizmos.color = detectRangeColor;
            Gizmos.DrawWireSphere(transform.position, detectRadius);
        }
    }
}

移动相关的接口(这个东西的出现纯粹屎山)

C#已复制
using UnityEngine;

namespace MyAssets.Framework
{
    public interface IMovable
    {
        void Init(BackgroundItemController controller);
        void Move();
    }
}

然后就是背景音乐的管理

​ image-20260324222618990

然后就是在一个空对象上方放入audio source音源组件,把背景音乐ogg或者wav文件放入,勾选loop循环播放

然后就是我还做了一个更屎山的东西用来管理界面上所有的按钮来确保界面上只能按一个按钮,当按下这个按钮时其他按钮自动禁用

​ image-20260324223133228

C#已复制
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

namespace MyAssets.Framework
{
    public class ButtonManager : MonoBehaviour
    {
        public static ButtonManager Instance;

        [Header("按钮组")] 
        public Button[] buttons; // 存放所有需要统一管理的按钮

        private void Awake()
        {
            // 单例模式
            if (Instance == null)
            {
                Instance = this;
            }
            else Destroy(gameObject);

            // 检查是否绑定
            if (buttons == null || buttons.Length == 0)
            {
                Debug.LogWarning("ButtonManager: 未绑定任何按钮!");
            }
        }

      
        //禁用除点击按钮外的其他所有按钮
        public void DisableOther(Button clickedButton)
        {
            if (buttons == null || buttons.Length == 0)
            {
                return;
            }

            foreach (Button btn in buttons)
            {
                if (btn == null)
                {
                    continue;
                }

                if (btn != clickedButton)
                {
                    btn.interactable = false;
                }
            }
        }

      
        /// 加载场景(可延时)
        public void LoadScene(string sceneName, float delay = 0f)
        {
            StartCoroutine(LoadSceneCoroutine(sceneName, delay));
        }

        private IEnumerator LoadSceneCoroutine(string sceneName, float delay)
        {
            if (delay > 0f)
            {
                yield return new WaitForSeconds(delay);
            }

            if (!string.IsNullOrEmpty(sceneName))
            {
                SceneManager.LoadSceneAsync(sceneName);
            }
            else
            {
                Debug.LogWarning("ButtonManager: 未设置目标场景名!");
            }
        }
    }
}

然后为了让文字飘动好看的效果,然后又在canvas文字组件里面添加了一个通用脚本控制文字浮动的振幅频率波长

​ image-20260324223350372

C#已复制
using TMPro;
using UnityEngine;

namespace MyAssets.Framework
{
    public class FloatingFont : MonoBehaviour
    {
        [Header("振幅")]
        public float amplitude = 5f;       // 波动幅度
        [Header("频率")]
        public float frequency = 2f; // 频率(速度)
        [Header("波长")]
        public float waveLength = 0.5f;    // 波长(相邻字符相位差)
        [Header("随机字母相位")]
        public bool randomPhase = true;    // 每个字符起始相位随机

        private TMP_Text textMesh;
        private TMP_TextInfo textInfo;
        private Vector3[][] originalVertices;
        private float[] phaseOffsets;

        private void Awake()
        {
            textMesh = GetComponent<TMP_Text>();
        }

        private void Start()
        {
            textMesh.ForceMeshUpdate();
            textInfo = textMesh.textInfo;

            int charCount = textInfo.characterCount;
            originalVertices = new Vector3[charCount][];
            phaseOffsets = new float[charCount];

            for (int i = 0; i < charCount; i++)
            {
                if (!textInfo.characterInfo[i].isVisible) continue;

                int matIndex = textInfo.characterInfo[i].materialReferenceIndex;
                int vertIndex = textInfo.characterInfo[i].vertexIndex;
                var verts = textInfo.meshInfo[matIndex].vertices;

                originalVertices[i] = new Vector3[4];
                for (int j = 0; j < 4; j++)
                {
                    originalVertices[i][j] = verts[vertIndex + j];
                }

                phaseOffsets[i] = randomPhase
                    ? Random.Range(0f, Mathf.PI * 2f)
                    : i * waveLength;
            }
        }

        private void Update()
        {
            textMesh.ForceMeshUpdate();
            textInfo = textMesh.textInfo;

            for (int i = 0; i < textInfo.characterCount; i++)
            {
                if (!textInfo.characterInfo[i].isVisible) continue;

                int matIndex = textInfo.characterInfo[i].materialReferenceIndex;
                int vertIndex = textInfo.characterInfo[i].vertexIndex;
                var verts = textInfo.meshInfo[matIndex].vertices;

                float wave = Mathf.Sin(Time.time * frequency + phaseOffsets[i]) * amplitude;

                for (int j = 0; j < 4; j++)
                {
                    Vector3 orig = originalVertices[i][j];
                    verts[vertIndex + j] = orig + new Vector3(0f, wave, 0f);
                }
            }

            // 更新网格
            for (int i = 0; i < textInfo.meshInfo.Length; i++)
            {
                var meshInfo = textInfo.meshInfo[i];
                meshInfo.mesh.vertices = meshInfo.vertices;
                textMesh.UpdateGeometry(meshInfo.mesh, i);
            }
        }
    }
}

哇,这些屎山真的不想多看两眼,幸亏这些只是在TVgame场景(TVgame就是游戏场景里的一个游戏机)里的,而不是游戏主世界的,所以后续实在不行就废弃前面所有的操作,重新搞一个吧,这些代码虽然我很清楚脚本之间的联系,但是毋庸置疑这就是屎山,或者这一部分的代码就不要改了,先把别的部分做出来再说别的

所以接下来我会把精力放到游戏玩法上,而不是这个破UI,UI真的讨厌,麻烦至极

下一阶段就是学习新的技巧,以上代码全部搁置不管了