【Unity】案例 —— 胡闹厨房联机案例(持续更新)

游戏流程

默认进入开始场景 -> 展示主界面UI -> 按下Play按钮 -> 同步加载游戏场景 ->

显示教程页面,关闭后倒计时三秒开始游戏 -> 控制玩家处理食材(拿取,烹饪,切割,组合,丢弃),然后按照要求提交即可得分 -> 倒计时结束后显示得分,完成游戏循环

网络部分
选择多人模式 -> 创建房间 -> 定义自己名称和颜色 -> 等待所有玩家准备 -> 等待所有玩家进入场景并按下互动 -> 倒计时开始游戏

以下分为几大部分进行解析

框架部分

KitchenGameManager

公共字段

本类是游戏部分的管理单例类,内部主要向外部提供了三个可绑定的委托

    public event EventHandler OnStateChanged;
    public event EventHandler OnGamePaused;
    public event EventHandler OnGameUnpaused;
私有字段

而其内部的私有字段则有关于游戏状态的记录,游戏已运行时间等

    private enum State {
        WaitingToStart,
        CountdownToStart,
        GamePlaying,
        GameOver,
    }

    private State state;
    private float countdownToStartTimer = 3f;
    private float gamePlayingTimer;
    private float gamePlayingTimerMax = 90f;
    private bool isGamePaused = false;
Awake & Start

Awake主要进行了初始化Instance和游戏的状态变量state,Start则负责将GameInput中的暂停动作绑定到本类的函数中

private void Awake() {
    Instance = this;
    state = State.WaitingToStart;
}

private void Start() {
    GameInput.Instance.OnPauseAction += GameInput_OnPauseAction;
    GameInput.Instance.OnInteractAction += GameInput_OnInteractAction;
}
Update

在Update中,简单的进行了状态之间的计时转换,并在计时结束后进行状态的转换,计时器初始化,委托调用,因为状态之间转换简单,因此无需使用状态机

private void Update() {
    switch (state) {
        case State.WaitingToStart:
            break;
        case State.CountdownToStart:
            countdownToStartTimer -= Time.deltaTime;
            if (countdownToStartTimer < 0f) {
                state = State.GamePlaying;
                gamePlayingTimer = gamePlayingTimerMax;
                OnStateChanged?.Invoke(this, EventArgs.Empty);
            }
            break;
        case State.GamePlaying:
            gamePlayingTimer -= Time.deltaTime;
            if (gamePlayingTimer < 0f) {
                state = State.GameOver;
                OnStateChanged?.Invoke(this, EventArgs.Empty);
            }
            break;
        case State.GameOver:
            break;
    }
}

除此之外类中还提供了许多访问函数来让外界访问类内变量,如IsGamePlaying(),IsCountdownToStartActive()等,这里不一一列举

SoundManager

该类负责播放游戏内所有的音效部分,类内部绑定了需要发出声音的委托,提供了许多特化的在位置处发出特定声音的函数。

同时还提供了ChangeVolume的方法来调整音效大小,并且存入PlayerPrefs

MusicManager

用于播放背景音乐,代码量少,和SoundManager一样提供了ChangeVolume的方法来调整音效大小,并且存入PlayerPrefs中。

输入部分(Input System)

输入部分分两大部分:

GameInput

内部拥有一个PlayerInputActions

  • 负责向外界提供互动,暂停时的委托以及玩家的轴输入的数值
  • 负责读取PlayerPrefs,查找是否存有映射Json并赋值到内部PlayerInputActions(可以进行这个操作是因为PlayerInputActions继承了IInputActionCollection2接口)
  • 向外界提供按枚举获取绑定按键值和按枚举重新绑定值的方法

PlayerInputActions

内部拥有一个InputActionAsset,一个InputActionMap,和几个InputAction,其中asset在初始化时由json构建,InputActionMapInputAction则读取asset进行初始化。

除此外还在类内声明了一个IPlayerActions接口和一个PlayerActions结构体,前者内部声明了和内部InputAction字段对应的函数,后者则是一个用于调用PlayerInputActions内部变量的wrapper对象,其内部用简洁的属性名称访问PlayerInputActions的变量,同时PlayerInputActions内部将其声明为Player字段并将自身输入进其内部。

该两个类关系和调用方式如下

public class PlayerINputActions
{
    public struct PlayerActions
    {
        private @PlayerInputActions m_Wrapper;
        public PlayerActions(@PlayerInputActions wrapper) { m_Wrapper = wrapper; }
        public InputAction @Move => m_Wrapper.m_Player_Move;
        public InputAction @Interact => m_Wrapper.m_Player_Interact;
        public InputAction @InteractAlternate => m_Wrapper.m_Player_InteractAlternate;
        public InputAction @Pause => m_Wrapper.m_Player_Pause;
        public InputActionMap Get() { return m_Wrapper.m_Player; }
    }
    
    public PlayerActions @Player => new PlayerActions(this);
    
    private readonly InputActionMap m_Player;
    private readonly InputAction m_Player_Move;
    private readonly InputAction m_Player_Interact;
    private readonly InputAction m_Player_InteractAlternate;
    private readonly InputAction m_Player_Pause;
}

//外部调用方式
public PlayerINputActions playerINputActions;
playerINputActions.Player.Move.started += someFunction;

其他类

其他类是对输入系统响应的末端类,比如Player和GameManager,前者通过将自身实现的Interact和InteractAlternate函数绑定到对应的GameInput的对应委托上,同时每帧通过GameInput对象监听玩家的轴输入来进行人物的移动操作。后者则是将自身调度Pause函数绑定到了GameInput的Pause相关委托上。

柜台与食材部分

这一部分是游戏内的除操作以外的程序响应部分,在本程序中以继承和多态的形式来扩展不同的柜台和食材以及对应的功能

先将大体框架列出来,再说几个重要的

IKitchenObjectParent

实现该接口即可将食物挂载在该类的物体上,提供的接口如下,所有需要挂载食物的类都继承了该接口,如柜台,玩家。

public interface IKitchenObjectParent {
	//获取挂载物品的点
    public Transform GetKitchenObjectFollowTransform();
	//将物品挂载到点上
    public void SetKitchenObject(KitchenObject kitchenObject);
	//获取挂载的物品
    public KitchenObject GetKitchenObject();
	//清空挂载的物品
    public void ClearKitchenObject();
	//查询是否挂载了物品
    public bool HasKitchenObject();

}

KitchenObjectSO

该类用于创建食物的ScriptableObject,内部有预制体Transform,精灵图标,名称三个公开字段

KitchenObject

该类为所有食物的基类,内部有在编辑器中分配的KitchenObjectSO私有字段和IKitchenObjectParent私有字段

该类向外部提供的接口有:

//获取内部的KitchenObjectSO私有字段
public KitchenObjectSO GetKitchenObjectSO();

//设置该食物的父级
public void SetKitchenObjectParent(IKitchenObjectParent kitchenObjectParent);

//获取该食物的父级
public IKitchenObjectParent GetKitchenObjectParent();

//删除该对象
public void DestroySelf();

//尝试获取盘子
public bool TryGetPlate(out PlateKitchenObject plateKitchenObject);

//静态函数,按照SO和给定父级生成一个KitchenObject
public static KitchenObject SpawnKitchenObject(KitchenObjectSO kitchenObjectSO, IKitchenObjectParent kitchenObjectParent);

Counter

BaseCounter
  • 该类为所有柜台的基类,其实现了IKitchenObjectParent接口
  • 有Transform counterTopPoint和KitchenObject kitchenObject两个私有字段用于实现接口的内容
  • 同时,提供了Interact和InteractAlternate两个虚函数供子类重写
  • 提供了一个委托OnAnyObjectPlacedHere供其他类绑定,在SetKitchenObject时触发
ClearCounter

​ 内部重写了Interact函数,并提供了一个意义不明的KitchenObjectSO私有字段(编辑器分配),没找到引用

条件判断写的有点冗余,但保证了每个可能分支都有自己的作用域,主要是两个变量的可能性组合,即当前柜台有没有物品和玩家手上有没有物品

public override void Interact(Player player) {
    if (!HasKitchenObject()) {
        // 桌上没有物品
        if (player.HasKitchenObject()) {
            // 玩家拿着物品
            player.GetKitchenObject().SetKitchenObjectParent(this);
        } else {
            // 玩家也没拿着物品
        }
    } else {
        // 桌上有物品
        if (player.HasKitchenObject()) {
            // 玩家也拿着物品
            if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject)) {
                // 如果玩家拿的是个碟子
                if (plateKitchenObject.TryAddIngredient(GetKitchenObject().GetKitchenObjectSO())) {
                    GetKitchenObject().DestroySelf();
                }
            } else {
                // 玩家拿的不是碟子
                if (GetKitchenObject().TryGetPlate(out plateKitchenObject)) {
                    // 桌子上有碟子
                    if (plateKitchenObject.TryAddIngredient(player.GetKitchenObject().GetKitchenObjectSO())) {
                        player.GetKitchenObject().DestroySelf();
                    }
                }
            }
        } else {
            // 玩家没有拿着物品
            GetKitchenObject().SetKitchenObjectParent(player);
        }
    }
}
ContainerCounter & Visual

游戏中的逻辑和视觉是相互分离的,像这种分离的两个类,基本上都通过一个委托来通知动画更新,因此,在逻辑中存在一个委托OnPlayerGrabbedObject供Visual订阅

public class ContainerCounterVisual : MonoBehaviour {
    private const string OPEN_CLOSE = "OpenClose";
    [SerializeField] private ContainerCounter containerCounter;
    private Animator animator;

    private void Awake() {
        animator = GetComponent<Animator>();
    }

    private void Start() {
        containerCounter.OnPlayerGrabbedObject += ContainerCounter_OnPlayerGrabbedObject;
    }

    private void ContainerCounter_OnPlayerGrabbedObject(object sender, System.EventArgs e) {
        animator.SetTrigger(OPEN_CLOSE);
    }
}

该类逻辑也很简单,内部和ClearCounter一样,重写了Interact函数,并提供了一个KitchenObjectSO私有字段(编辑器分配),KitchenObjectSO用于生成物品给玩家持有,以下是Interact内容

public override void Interact(Player player) {
    if (!player.HasKitchenObject()) {
        // Player is not carrying anything
        KitchenObject.SpawnKitchenObject(kitchenObjectSO, player);

        OnPlayerGrabbedObject?.Invoke(this, EventArgs.Empty);
    }
}
CuttingCounter & Visual
  • 该类实现了接口IHasProgress,用于表示该类存在进度(切割进度)

    public interface IHasProgress {
        public class OnProgressChangedEventArgs : EventArgs
        {
            public float progressNormalized;
        }
        public event EventHandler<OnProgressChangedEventArgs> OnProgressChanged;
    }
    
  • 该类存在一个CuttingRecipeSO数组,CuttingRecipeSO内部存在input和Output表示切割前和切割后的KitchenObjectSO,同时还有一个int字段表示需要切割的时间(对应切割次数)

  • 该类有一个私有字段存储当前切割进度

  • 提供静态委托OnAnyCut用于在任何物品被切时触发,并提供静态函数用于清除绑定的所有函数

  • 提供OnCut用于在某个柜台在切割时触发,用于执行正在使用的柜台的委托

  • 下面是关于其Interact(放上物品)和InteractAlternate(切割物品)的逻辑重写

    public override void Interact(Player player) {
        if (!HasKitchenObject()) {
            //柜台上没有物品
            if (player.HasKitchenObject()) {
                // 玩家持有物品
                if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO())) 			{
                    // 确保持有物品可以被切割
                    player.GetKitchenObject().SetKitchenObjectParent(this);
                    cuttingProgress = 0;
    
                    CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
    
                    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                        progressNormalized = (float)cuttingProgress / cuttingRecipeSO.cuttingProgressMax
                    });
                }
            } else {
                // 玩家什么也没拿
            }
        } else {
            // 柜台上已经有了物品
            if (player.HasKitchenObject()) {
                // 且玩家拿了物品
                if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject)) {
                    // 如果拿的是个盘子,则尝试放在玩家的盘子上
                    if (plateKitchenObject.TryAddIngredient(GetKitchenObject().GetKitchenObjectSO())) {
                        GetKitchenObject().DestroySelf();
                    }
                }
            } else {
                // 玩家没拿东西,将物品放到玩家手上
                GetKitchenObject().SetKitchenObjectParent(player);
            }
        }
    }
    
    public override void InteractAlternate(Player player) {
        if (HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO())) {
            // 确保存在物品且可以被切割
            cuttingProgress++;
    
            //调用切割委托
            OnCut?.Invoke(this, EventArgs.Empty);
            OnAnyCut?.Invoke(this, EventArgs.Empty);
    
            //获取对应的Output和进度最大值
            CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
    
            //传递进度值
            OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                progressNormalized = (float)cuttingProgress / cuttingRecipeSO.cuttingProgressMax
            });
    
            //切割完成,原地销毁原物品,创建新物品
            if (cuttingProgress >= cuttingRecipeSO.cuttingProgressMax) {
                KitchenObjectSO outputKitchenObjectSO = GetOutputForInput(GetKitchenObject().GetKitchenObjectSO());
    
                GetKitchenObject().DestroySelf();
    
                KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this);
            }
        }
    }
    

    Visual部分:

    简单的获取动画组件并触发动画

    public class CuttingCounterVisual : MonoBehaviour {
        private const string CUT = "Cut";
        [SerializeField] private CuttingCounter cuttingCounter;
        private Animator animator;
    
        private void Awake() {
            animator = GetComponent<Animator>();
        }
    
        private void Start() {
            cuttingCounter.OnCut += CuttingCounter_OnCut;
        }
        
        private void CuttingCounter_OnCut(object sender, System.EventArgs e) {
            animator.SetTrigger(CUT);
        }
    }
    

    进度条部分:

    由编辑器中分配要添加进度条的物品和进度条Image。

    初始化时赋值并将进度条隐藏,添加委托,并在委托触发时按照给定的值更新。

    public class ProgressBarUI : MonoBehaviour {
        [SerializeField] private GameObject hasProgressGameObject;
        [SerializeField] private Image barImage;
    
        private IHasProgress hasProgress;
    
        private void Start() {
            hasProgress = hasProgressGameObject.GetComponent<IHasProgress>();
            if (hasProgress == null) {
                Debug.LogError("Game Object " + hasProgressGameObject + " does not have a component that implements IHasProgress!");
            }
    
            hasProgress.OnProgressChanged += HasProgress_OnProgressChanged;
            barImage.fillAmount = 0f;
    
            Hide();
        }
    
        private void HasProgress_OnProgressChanged(object sender, IHasProgress.OnProgressChangedEventArgs e) {
            barImage.fillAmount = e.progressNormalized;
    
            if (e.progressNormalized == 0f || e.progressNormalized == 1f) {
                Hide();
            } else {
                Show();
            }
        }
    
        private void Show() {
            gameObject.SetActive(true);
        }
    
        private void Hide() {
            gameObject.SetActive(false);
        }
    }
    
StoveCounter & Visual

该类在逻辑上与Cutting类似,主要说下不同的地方

第一是拥有自身的状态枚举值,原因是烹饪时存在煎炸一次后继续煎炸会烤糊,因此会使用多个枚举值描述当前的柜台状态,除此之外,还声明了与状态改变的配套委托事件

public event EventHandler<OnStateChangedEventArgs> OnStateChanged;
public class OnStateChangedEventArgs : EventArgs {
    public State state;
}

public enum State {
    Idle,	//闲置
    Frying,	//初次烹饪	生 -> 熟
    Fried,	//烹饪完成	熟 -> 糊
    Burned,	//糊啦	 糊
}

第二是类内部维护了fryingRecipeSOArray和burningRecipeSOArray数组,分别表示了从哪个对象烹饪到哪个对象,花费多长时间,以及从哪个对象烤糊到哪个对象,花费多长时间。

内部的私有字段维护了相关的信息:

private State state;
private float fryingTimer;
private FryingRecipeSO fryingRecipeSO;
private float burningTimer;
private BurningRecipeSO burningRecipeSO;

初始化时状态字段设置为闲置,等待玩家Interact,以下是其Interact重写函数

流程中伴随着对state的改写和OnStateChanged的调用

public override void Interact(Player player) {
    if (!HasKitchenObject()) {
        // 柜台无物品
        if (player.HasKitchenObject()) {
            // 玩家有物品
            if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO())) {
                // 玩家物品可被烹饪
                player.GetKitchenObject().SetKitchenObjectParent(this);

                fryingRecipeSO = GetFryingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());

                state = State.Frying;
                fryingTimer = 0f;

                OnStateChanged?.Invoke(this, new OnStateChangedEventArgs {
                    state = state
                });

                OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                    progressNormalized = fryingTimer / fryingRecipeSO.fryingTimerMax
                });
            }
        } else {
            // Player not carrying anything
        }
    } else {
        // There is a KitchenObject here
        if (player.HasKitchenObject()) {
            // Player is carrying something
            if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject)) {
                // Player is holding a Plate
                if (plateKitchenObject.TryAddIngredient(GetKitchenObject().GetKitchenObjectSO())) {
                    GetKitchenObject().DestroySelf();

                    state = State.Idle;

                    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs {
                        state = state
                    });

                    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                        progressNormalized = 0f
                    });
                }
            }
        } else {
            // Player is not carrying anything
            GetKitchenObject().SetKitchenObjectParent(player);

            state = State.Idle;

            OnStateChanged?.Invoke(this, new OnStateChangedEventArgs {
                state = state
            });

            OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                progressNormalized = 0f
            });
        }
    }
}

之后是在Update中持续增加过程值并自动改变状态

private void Update() {
    if (HasKitchenObject()) {
        switch (state) {
            case State.Idle:
                break;
            case State.Frying:
                fryingTimer += Time.deltaTime;

                OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                    progressNormalized = fryingTimer / fryingRecipeSO.fryingTimerMax
                });

                if (fryingTimer > fryingRecipeSO.fryingTimerMax) {
                    // Fried
                    GetKitchenObject().DestroySelf();

                    KitchenObject.SpawnKitchenObject(fryingRecipeSO.output, this);

                    state = State.Fried;
                    burningTimer = 0f;
                    burningRecipeSO = GetBurningRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());

                    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs {
                        state = state
                    });
                }
                break;
            case State.Fried:
                burningTimer += Time.deltaTime;

                OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                    progressNormalized = burningTimer / burningRecipeSO.burningTimerMax
                });

                if (burningTimer > burningRecipeSO.burningTimerMax) {
                    // Fried
                    GetKitchenObject().DestroySelf();

                    KitchenObject.SpawnKitchenObject(burningRecipeSO.output, this);

                    state = State.Burned;

                    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs {
                        state = state
                    });

                    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs {
                        progressNormalized = 0f
                    });
                }
                break;
            case State.Burned:
                break;
        }
    }
}

Visual部分:

这里没有什么好说的,就是订阅了状态的转换,并且确保状态在Frying和Fried时才会去激活火焰特效

public class StoveCounterVisual : MonoBehaviour {
    [SerializeField] private StoveCounter stoveCounter;
    [SerializeField] private GameObject stoveOnGameObject;
    [SerializeField] private GameObject particlesGameObject;

    private void Start() {
        stoveCounter.OnStateChanged += StoveCounter_OnStateChanged;
    }

    private void StoveCounter_OnStateChanged(object sender, StoveCounter.OnStateChangedEventArgs e) {
        bool showVisual = e.state == StoveCounter.State.Frying || e.state == StoveCounter.State.Fried;
        stoveOnGameObject.SetActive(showVisual);
        particlesGameObject.SetActive(showVisual);
    }

}

Sound部分:

该部分值得注意的地方是,和Visual一样事先持有了柜台的引用,但是同时注册了OnStateChanged和OnProgressChanged

OnStateChanged:保证只在状态在Frying和Fried时才会去播放音效

OnProgressChanged:负责设置是否播放警报的变量,在update中轮询,一旦进度大于一半就会开始警报

进度条部分:

在进度条的效果上,为尽量提示玩家进度,在普通的进度条外还增加了StoveBurnFlashingBarUI和StoveBurnWarningUI脚本,前者控制进度条即将完成时的闪光,后者控制进度条即将完成时的感叹号显示

这两个的效果控制均用Animator的参数控制,其判断条件代码如下

float burnShowProgressAmount = .5f;
bool show = stoveCounter.IsFried() && e.progressNormalized >= burnShowProgressAmount;
PlatesCounter & Visual

该柜台用于拿取盘子,并且数量有限,随时间恢复

  • 编辑器提供盘子的SO

  • 向外界提供了OnPlateSpawned和OnPlateRemoved委托

  • 内部存储了生成盘子的间隔和最大数量以及当前的相关状态

  • 在Update函数中进行盘子的刷新

    private void Update() {
        spawnPlateTimer += Time.deltaTime;
        if (spawnPlateTimer > spawnPlateTimerMax) {
            spawnPlateTimer = 0f;
    
            if (KitchenGameManager.Instance.IsGamePlaying() && platesSpawnedAmount < platesSpawnedAmountMax) {
                platesSpawnedAmount++;
                OnPlateSpawned?.Invoke(this, EventArgs.Empty);
            }
        }
    }
    
  • 重写了Interact函数

    public override void Interact(Player player) {
        if (!player.HasKitchenObject()) {
            // Player is empty handed
            if (platesSpawnedAmount > 0) {
                // There's at least one plate here
                platesSpawnedAmount--;
                KitchenObject.SpawnKitchenObject(plateKitchenObjectSO, player);
                OnPlateRemoved?.Invoke(this, EventArgs.Empty);
            }
        }
    }
    

Visual部分:

视觉部分主要是要保证盘子的数量和模型的堆叠数量一致

  • 编辑器配置对应的柜台和桌面点
  • 要求提供盘子的Prefab用于模型生成,绑定了对应的OnPlateSpawned和OnPlateRemoved委托函数
  • 当盘子移除和添加时,对对应的顶部模型执行相应操作,添加时根据当前数量乘以固定间隔来偏移y轴生成
TrashCounter

这个柜台的功能是将玩家手上的物品丢弃,功能简单,没有动画,以下是其内容

public class TrashCounter : BaseCounter {
    public static event EventHandler OnAnyObjectTrashed;

    new public static void ResetStaticData() {
        OnAnyObjectTrashed = null;
    }

    public override void Interact(Player player) {
        if (player.HasKitchenObject()) {
            player.GetKitchenObject().DestroySelf();

            OnAnyObjectTrashed?.Invoke(this, EventArgs.Empty);
        }
    }
}
DeliveryCounter

这个柜台是玩家的提交柜台,全场景只应有一个,并且只要玩家持有的物品有盘子就会提交成功,之后在其他地方验证是否正确

以下是其类内容

public class DeliveryCounter : BaseCounter {
    public static DeliveryCounter Instance { get; private set; }

    private void Awake() {
        Instance = this;
    }

    public override void Interact(Player player) {
        if (player.HasKitchenObject()) {
            if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject)) {
                DeliveryManager.Instance.DeliverRecipe(plateKitchenObject);
                player.GetKitchenObject().DestroySelf();
            }
        }
    }
}

提交与积分部分

DeliveryManager

该类负责发布订单和确认提交订单的物品的正确性

  • 类对外提供了订单生成,订单完成,订单成功,订单失败四个委托以供绑定

  • 类需要编辑器提供一个RecipeListSO,该类内部记录了一个订单列表,发布时,根据该订单列表随机选取

  • 内部通过私有字段记录等待的订单,当前状态和得分

    private List<RecipeSO> waitingRecipeSOList;
    private float spawnRecipeTimer;
    private float spawnRecipeTimerMax = 4f;
    private int waitingRecipesMax = 4;
    private int successfulRecipesAmount;
    
  • update函数中进行订单的生成

    private void Update() {
        spawnRecipeTimer -= Time.deltaTime;
        if (spawnRecipeTimer <= 0f) {
            spawnRecipeTimer = spawnRecipeTimerMax;
    
            if (KitchenGameManager.Instance.IsGamePlaying() && waitingRecipeSOList.Count < waitingRecipesMax) {
                RecipeSO waitingRecipeSO = recipeListSO.recipeSOList[UnityEngine.Random.Range(0, recipeListSO.recipeSOList.Count)];
    
                waitingRecipeSOList.Add(waitingRecipeSO);
    
                OnRecipeSpawned?.Invoke(this, EventArgs.Empty);
            }
        }
    }
    
  • DeliverRecipe函数负责确认提交的订单的正确性

    public void DeliverRecipe(PlateKitchenObject plateKitchenObject) {
        //枚举每一个等待的订单
        for (int i = 0; i < waitingRecipeSOList.Count; i++) {
            RecipeSO waitingRecipeSO = waitingRecipeSOList[i];
    
            //首先判断是否和提交的素材长度相等,过滤明显不符合的条目
            if (waitingRecipeSO.kitchenObjectSOList.Count == plateKitchenObject.GetKitchenObjectSOList().Count) {
                bool plateContentsMatchesRecipe = true;
                //枚举等待条目的每个物品,从提供的物品中遍历发现是否存在对应物品
                foreach (KitchenObjectSO recipeKitchenObjectSO in waitingRecipeSO.kitchenObjectSOList) {
                    bool ingredientFound = false;
                    foreach (KitchenObjectSO plateKitchenObjectSO in plateKitchenObject.GetKitchenObjectSOList()) {
                        if (plateKitchenObjectSO == recipeKitchenObjectSO) {
                            ingredientFound = true;
                            break;
                        }
                    }
                    if (!ingredientFound) {
                        plateContentsMatchesRecipe = false;
                    }
                }
    
                //匹配成功的话
                if (plateContentsMatchesRecipe) {
    
                    successfulRecipesAmount++;
    
                    waitingRecipeSOList.RemoveAt(i);
    
                    OnRecipeCompleted?.Invoke(this, EventArgs.Empty);
                    OnRecipeSuccess?.Invoke(this, EventArgs.Empty);
                    return;
                }
            }
        }
    
        //匹配失败的话
        OnRecipeFailed?.Invoke(this, EventArgs.Empty);
    }
    

联机部分

联机使用Unity官方的同步方案NetCode进行同步

运动同步

在玩家预制体下挂载NetworkObjectNetworkTransform,运行时主机和客户端即可同步Transform

但是由于NetworkTransform是服务器权威的,因此客户端对其的改动会被服务器改回,所以有两种方案完成客户端的移动同步

  • RPC:

    通过客户端向服务器发送RPC调用,让客户端在服务器移动并同步给其他客户端

  • Client:

    继承NetworkTransform并重写OnIsServerAuthoritative函数,使返回值始终为false,即可不经过服务器直接同步,服务器也会将更新分发给其他客户端

    public class ClientNetworkTransform : NetworkTransform
    {
        protected override bool OnIsServerAuthoritative()
        {
            return false;
        }
    }
    

动画同步

动画同步与玩家类似,但是由于玩家已挂载NetworkObject,因此此处无需再挂载。因为要同步动画,所以要挂载NetworkAnimator,并将玩家的Animator拖入其中。

动画同步也有和运动同步同样的问题,即客户端进行的改动不会被服务器端接受,需要在触发动画的地方使用RPC调用或者继承一个NetworkAnimator并重写OnIsServerAuthoritative方法

public class ClientNetworkAnimator : NetworkAnimator
{
    protected override bool OnIsServerAuthoritative()
    {
        return false;
    }
}

除了玩家的动画同步,还有对柜台操作时的柜台动画同步,这些的方法都是一样的,柜台动画同步可以同步委托而不用特别同步动画

逻辑同步

订单同步

订单即DeliveryManager生成的订单列表,订单需要保证所有客户端一致,因此其只能由服务器进行生成,并同步给所有客户端

  • 首先让DeliveryManager重新继承自NetworBehavior

  • 接着在Update中确保只有服务器才会进行订单生成

    private void Update() {
        if (!IsServer) return;
        //Spawn logic...
    }
    
  • 针对客户端的订单生成同步,可以选择使用RPC或者网络变量进行同步,最大的区别是网络变量时刻同步这个列表本身,而RPC仅仅在创建或销毁时发一个通知,客户端收到后进行自己的逻辑处理,这意味着中途加入的玩家将不会获得之前的数据

    使用客户端RPC将随机的id发送给所有客户端,由客户端去自己调用生成订单UI和数据

    [ClientRpc]
    private void SpawnNewWaitingRecipeClientRPC(int randIndex)
    {
        waitingRecipeSOList.Add(recipeListSO.recipeSOList[randIndex]);
        OnRecipeSpawned?.Invoke(this, EventArgs.Empty);
    }
    
  • 针对客户端的订单提交同步,同样使用RPC进行,操作流程如下:

    由于采用Host - Client模式,因此主机端可同时视为主机和客户端。

    订单的提交所有客户端都可能触发,因此调用的函数需要是一个ServerRPC(客户端调用,服务器执行)

    在ServerRPC中,又执行一个ClientRPC(服务器调用,客户端执行)让所有客户端做同样的操作,而这个ClientRPC函数就是原本的单机逻辑操作

    代码如下:

    public void DeliverRecipe(PlateKitchenObject plateKitchenObject) {
        bool matched = false;
        int i = 0;
    
        //logic to match recipe...
    
        DeliverRecipeServerRPC(matched, i);
    }
    
    [ServerRpc(RequireOwnership = false)]	//RequireOwnership = false保证任何客户端都可以触发,而不是限制为有权限的客户端才能触发
    private void DeliverRecipeServerRPC(bool  successful, int index = 0)
    {
        DeliverRecipeClientRPC(successful, index);
    }
    
    [ClientRpc]
    private void DeliverRecipeClientRPC(bool  successful, int index = 0)
    {
        if(successful)
            //logic...
        else
            //logic...
    }
    
物品同步

物品同步即要求物品的生成,销毁的同步,这其中涉及到相关的生成函数,设置父级的函数的同步

生成同步
  • 首先需要在NetworkManager中添加需要生成的PrefabList

  • 使用专门的单例类来替代原本的静态函数SpawnKitchenObject(只需要改变原函数内容为调用单例类的对应函数),因为静态函数无法作为RPC函数进行调用

  • 物品的生成只能放在服务端,并且对于RPC而言,不该传输复杂类型,一是难以序列化,二是占用带宽,因此,对于需要生成的物品信息,使用index进行记录,对于需要挂载到的玩家或柜台的信息,使用NetworkObjectReference进行替代

  • 以下是相关函数内容

    KitchenObject内静态函数修改后

    public static void SpawnKitchenObject(KitchenObjectSO kitchenObjectSO, IKitchenObjectParent kitchenObjectParent) {
        KichenGameMultiplayer.Instance.SpawnKitchenObject(kitchenObjectSO, kitchenObjectParent);
    }
    

    KitchenGameMultiplayer内函数

    注意因为我使用的是新版Netcode,因为本来就创建了一个NetworkPrefabsList对象存储网络生成用的Prefab,因此直接拿来用了,所以kitchenObjList.PrefabList[kitchenObjectSOIndex].Prefab这句会和原教程不一样

    public void SpawnKitchenObject(KitchenObjectSO kitchenObjectSO, IKitchenObjectParent kitchenObjectParent)
    {
        SpawnKitchenObjectServerRPC(GetKitchenObjectSOIndex(kitchenObjectSO), kitchenObjectParent.GetNetworkObject());
    }
    
    [ServerRpc(RequireOwnership = false)]
    private void SpawnKitchenObjectServerRPC(int kitchenObjectSOIndex, NetworkObjectReference kitchenObjectParent)
    {
        //创建新物品
        Transform kitchenObjTransform = Instantiate(kitchenObjList.PrefabList[kitchenObjectSOIndex].Prefab).transform;
        //获得物品身上的NetworkObject,同步创建新物品
        kitchenObjTransform.GetComponent<NetworkObject>().Spawn(true);
        //获得新物品身上的KitchenObject,用于将其添加到玩家身上
        KitchenObject kitchenObj = kitchenObjTransform.GetComponent<KitchenObject>();
    
        //获取玩家身上的接口,将新物品放到玩家身上
        kitchenObjectParent.TryGet(out NetworkObject kitchenParentNetObj);
        IKitchenObjectParent kitchenObjParent = kitchenParentNetObj.GetComponent<IKitchenObjectParent>();
    
        kitchenObj.SetKitchenObjectParent(kitchenObjParent);
    }
    

上面是生成的同步,生成同步后还需要玩家进行拿取跟随的同步,由于Netcode对修改父级方面有麻烦的限制,因此直接通过一个组件跟着需要跟随的Transform在Update中同步即可

跟随同步

下面是相关类

public class FollowTransform : MonoBehaviour {
    private Transform targetTransform;

    public void SetTargetTransform(Transform targetTransform) {
        this.targetTransform = targetTransform;
    }

    private void LateUpdate() {
        if (targetTransform == null) {
            return;
        }

        transform.position = targetTransform.position;
        transform.rotation = targetTransform.rotation;
    }
}

之后是生成后的跟随同步,教程给出的方法是调用普通函数,再用普通函数跑一遍 Client -> Server -> Client流程的

但实际上本身SetKitchenObjectParent这个函数就是在一个Client -> Server 的RPC函数中调用的,因此直接在中间调用一个ClientRPC函数即可,将kitchenObj.SetKitchenObjectParent(kitchenObjParent);替换为kitchenObj.SetKitchenObjectParentClientRPC(kitchenObjectParent);

以下是对应函数内容,分成两部分,是为了暂时不修改原函数造成参数的变动出现其他Bug

[ClientRpc]
public void SetKitchenObjectParentClientRPC(NetworkObjectReference parent)
{
    parent.TryGet(out NetworkObject parentNetObj);
    SetKitchenObjectParent(parentNetObj.GetComponent<IKitchenObjectParent>());
}

public void SetKitchenObjectParent(IKitchenObjectParent kitchenObjectParent) {
    if (this.kitchenObjectParent != null) {
        this.kitchenObjectParent.ClearKitchenObject();
    }

    this.kitchenObjectParent = kitchenObjectParent;

    if (kitchenObjectParent.HasKitchenObject()) {
        Debug.LogError("IKitchenObjectParent already has a KitchenObject!");
    }

    kitchenObjectParent.SetKitchenObject(this);

    followTransform.SetTargetTransform(kitchenObjectParent.GetKitchenObjectFollowTransform());
}
销毁同步

销毁和其他有些地方不同,比如销毁一个NetworkObject的操作只能在Server上进行,这意味着客户端需要删除一个网络物体,需要向服务器发送RPC来申请删除物体,并由服务器去通知所有客户端更新除了销毁以外的其他逻辑

逻辑如下:

KitchenObject相关函数:

public void DestroySelf() {
    Destroy(gameObject);
}

[ClientRpc]
public void ClearKitchenObjectParentClientRPC()
{
    kitchenObjectParent.ClearKitchenObject();
}

KichenGameMultiplayer相关函数

public void DestroyKitchenObject(KitchenObject kitchenObj)
{
    DestroyKitchenObjectServerRPC(kitchenObj.networkObject);
}

[ServerRpc(RequireOwnership = false)]
private void DestroyKitchenObjectServerRPC(NetworkObjectReference kitchenNetObjRef)
{
    kitchenNetObjRef.TryGet(out NetworkObject netObj);
    if (netObj == null)
        return;

    KitchenObject kitchenObj = netObj.GetComponent<KitchenObject>();

    kitchenObj.ClearKitchenObjectParentClientRPC();

    kitchenObj.DestroySelf();
}
放置拿取同步

拿取放置这部分实际上就是前面的跟随同步和生成销毁同步的聚合。从一种变成另一种就是销毁和生成,放置拿取就是切换不同的跟随目标。

先说下跟随,为了保证之前的逻辑能快速应用,因此还需要再次改造接受接口的SetParent函数,以下是其和相关函数

[ClientRpc]
public void SetKitchenObjectParentClientRPC(NetworkObjectReference parent)
{
    //单机时的逻辑放到这里
}

[ServerRpc(RequireOwnership = false)]
private void SetKitchenObjectParentServerRPC(NetworkObjectReference parent)
{
    SetKitchenObjectParentClientRPC(parent);
} 

public void SetKitchenObjectParent(IKitchenObjectParent kitchenObjectParent) {
    SetKitchenObjectParentServerRPC(kitchenObjectParent.GetNetworkObject());
}

这样即可保证所有使用了SetKitchenObjectParent的函数都变成了服务器的RPC函数

叠放同步

叠放同步是指物品被拿到盘子上后进行的同步,这里有两个同步,一个是餐盘拿取,一个是餐盘堆叠。

在调用的盘子的TryAddIngredient函数中抽出一个ServerRPC来调用ClientRPC即可实现盘子存放同步

public bool TryAddIngredient(KitchenObjectSO kitchenObjectSO) {
    if (!validKitchenObjectSOList.Contains(kitchenObjectSO)) {
        // Not a valid ingredient
        return false;
    }
    if (kitchenObjectSOList.Contains(kitchenObjectSO)) {
        // Already has this type
        return false;
    } else {
        AddIngredientServerRpc(
            KitchenGameMultiplayer.Instance.GetKitchenObjectSOIndex(kitchenObjectSO)
        );

        return true;
    }
}

[ServerRpc(RequireOwnership = false)]
private void AddIngredientServerRpc(int kitchenObjectSOIndex) {
    AddIngredientClientRpc(kitchenObjectSOIndex);
}

[ClientRpc]
private void AddIngredientClientRpc(int kitchenObjectSOIndex) {
    KitchenObjectSO kitchenObjectSO = KitchenGameMultiplayer.Instance.GetKitchenObjectSOFromIndex(kitchenObjectSOIndex);

    kitchenObjectSOList.Add(kitchenObjectSO);

    OnIngredientAdded?.Invoke(this, new OnIngredientAddedEventArgs {
        kitchenObjectSO = kitchenObjectSO
    });
}

除此之外就是在所有和盘子销毁相关的地方将原本的直接DestroySelf替换为新的服务器函数,因为客户端不能直接销毁物品

盘子还有一个同步是盘子的生成同步,如果不加同步的话,那么双方的盘子增减都是按各自的本地数量走的,增减的逻辑都应放在服务端进行。

盘子生成同步

下面是盘子的同步生成部分,我实现了一个通用的函数来可以按输入的数进行增加或删除

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

    spawnPlateTimer += Time.deltaTime;
    if (spawnPlateTimer > spawnPlateTimerMax) {
        spawnPlateTimer = 0f;

        if (KitchenGameManager.Instance.IsGamePlaying() && platesSpawnedAmount < platesSpawnedAmountMax) {
            ModifyPlateServerRPC(1);
        }
    }
}

public override void Interact(Player player) {
    if (!player.HasKitchenObject()) {
        // Player is empty handed
        if (platesSpawnedAmount > 0) {
            // There's at least one plate here
            KitchenObject.SpawnKitchenObject(plateKitchenObjectSO, player);
            ModifyPlateServerRPC(-1);
        }
    }
}

[ServerRpc(RequireOwnership = false)]
private void ModifyPlateServerRPC(int amount)
{
    ModifyPlateClientRPC(amount);
}

[ClientRpc]
private void ModifyPlateClientRPC(int amount)
{
    int res = platesSpawnedAmount + amount;
    if (res > platesSpawnedAmountMax || res < 0)
        return;

    platesSpawnedAmount = res;

    bool isAdd = amount > 0;
    amount = Math.Abs(amount);
    while(amount > 0)
    {
        if(isAdd)
            OnPlateSpawned?.Invoke(this, EventArgs.Empty);
        else
            OnPlateRemoved?.Invoke(this, EventArgs.Empty);
        amount--;
    }
}
切割同步

切割是游戏中的一个特殊操作,使用了ScriptableObject进行相应的配置,对其的同步主要分为以下几个地方

  • 放置,初始化状态

    放置可以复用切换父级的代码,但初始化状态需要由服务器同步给所有的客户端,确保每个客户端的初始值一致

    [ServerRpc(RequireOwnership = false)]
    private void PutInitServerRPC()
    {
        PutInitClientRPC();
    }
    
    [ClientRpc]
    private void PutInitClientRPC()
    {
        cuttingProgress = 0;
    
        OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
        {
            progressNormalized = 0f
        });
    }
    
  • 切割,同步状态

    public override void InteractAlternate(Player player) {
        if (HasKitchenObject() && HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO())) {
            // There is a KitchenObject here AND it can be cut
            CutServerRPC();
        }
    }
    
    [ServerRpc(RequireOwnership = false)]
    private void CutServerRPC()
    {
        CutClientRPC();
    
        //Check Cut Done
    }
    
    [ClientRpc]
    private void CutClientRPC()
    {
        cuttingProgress++;
    
        OnCut?.Invoke(this, EventArgs.Empty);
        OnAnyCut?.Invoke(this, EventArgs.Empty);
    
        CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
    
        OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
        {
            progressNormalized = (float)cuttingProgress / cuttingRecipeSO.cuttingProgressMax
        });
    }
    
  • 完成,同步生成

    [ServerRpc(RequireOwnership = false)]
    private void CutServerRPC()
    {
        CutClientRPC();
    
        //Check Cut Done
        CuttingRecipeSO cuttingRecipeSO = GetCuttingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());
        if (cuttingProgress >= cuttingRecipeSO.cuttingProgressMax)
        {
            KitchenObjectSO outputKitchenObjectSO = GetOutputForInput(GetKitchenObject().GetKitchenObjectSO());
    
            KitchenObject.DestroyKitchenObject(GetKitchenObject());
    
            KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this);
        }
    }
    
烹饪同步

烹饪是有状态的,因此主要集中在切换状态,放下,拿取时对应的状态的同步

我在下面按自己的思路将对应的单机代码进行RPC改造,可以进行状态,UI等的同步更新,拿取放下时状态的改变。

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

    if (HasKitchenObject()) {
        switch (state) {
            case State.Idle:
                break;
            case State.Frying:
                fryingTimer += Time.deltaTime;

                ProgressUpdateClientRPC(fryingTimer, fryingRecipeSO.fryingTimerMax);

                if (fryingTimer > fryingRecipeSO.fryingTimerMax) {
                    // Fried
                    KitchenObject.DestroyKitchenObject(GetKitchenObject());

                    KitchenObject.SpawnKitchenObject(fryingRecipeSO.output, this);

                    FiredCompleteClientRPC();
                }
                break;
            case State.Fried:
                burningTimer += Time.deltaTime;

                ProgressUpdateClientRPC(burningTimer, burningRecipeSO.burningTimerMax);

                if (burningTimer > burningRecipeSO.burningTimerMax) {
                    // Fried
                    KitchenObject.DestroyKitchenObject(GetKitchenObject());

                    KitchenObject.SpawnKitchenObject(burningRecipeSO.output, this);

                    BurnedCompleteClientRPC();
                }
                break;
            case State.Burned:
                break;
        }
    }
}

[ClientRpc]
private void ProgressUpdateClientRPC(float cur, float max)
{
    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
    {
        progressNormalized = cur / max
    });
}

[ClientRpc]
private void FiredCompleteClientRPC()
{
    state = State.Fried;
    burningTimer = 0f;
    burningRecipeSO = GetBurningRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());

    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
    {
        state = state
    });
}

[ClientRpc]
private void BurnedCompleteClientRPC()
{
    state = State.Burned;

    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
    {
        state = state
    });

    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
    {
        progressNormalized = 0f
    });
}

public override void Interact(Player player) {
    if (!HasKitchenObject()) {
        // There is no KitchenObject here
        if (player.HasKitchenObject()) {
            // Player is carrying something
            if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO())) {
                // Player carrying something that can be Fried
                player.GetKitchenObject().SetKitchenObjectParent(this);

                PutInitServerRPC();
            }
        } else {
            // Player not carrying anything
        }
    } else {
        // There is a KitchenObject here
        if (player.HasKitchenObject()) {
            // Player is carrying something
            if (player.GetKitchenObject().TryGetPlate(out PlateKitchenObject plateKitchenObject)) {
                // Player is holding a Plate
                if (plateKitchenObject.TryAddIngredient(GetKitchenObject().GetKitchenObjectSO())) {
                    KitchenObject.DestroyKitchenObject(GetKitchenObject());

                    TakeServerRPC();
                }
            }
        } else {
            // Player is not carrying anything
            GetKitchenObject().SetKitchenObjectParent(player);

            TakeServerRPC();
        }
    }
}

[ServerRpc(RequireOwnership = false)]
private void PutInitServerRPC()
{
    PutInitClientRPC();
}

[ClientRpc]
private void PutInitClientRPC()
{
    fryingRecipeSO = GetFryingRecipeSOWithInput(GetKitchenObject().GetKitchenObjectSO());

    state = State.Frying;
    fryingTimer = 0f;

    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
    {
        state = state
    });

    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
    {
        progressNormalized = fryingTimer / fryingRecipeSO.fryingTimerMax
    });
}

[ServerRpc(RequireOwnership = false)]
private void TakeServerRPC()
{
    TakeClientRPC();
}

[ClientRpc]
private void TakeClientRPC()
{
    state = State.Idle;

    OnStateChanged?.Invoke(this, new OnStateChangedEventArgs
    {
        state = state
    });

    OnProgressChanged?.Invoke(this, new IHasProgress.OnProgressChangedEventArgs
    {
        progressNormalized = 0f
    });
}

细节部分

柜台光标

在单机解决方案中,使用Instance来标定唯一玩家,柜台光标在Start时即将相关函数绑定到单例的委托上

在多人中,玩家并不在一开始就存在,且不止一个,需要分辨本地玩家,因此创建一个全局静态委托在网络对象创建时调用,由光标对象自己绑定并判断是否是本地玩家。如下

public class Player : NetworkBehaviour, IKitchenObjectParent {
    public static EventHandler OnAnyPlayerSpawned;
    public static Player LocalInstance { get; private set; }

    public override void OnNetworkSpawn()   //代替了Awake
    {
        base.OnNetworkSpawn();

        if (IsOwner)
            LocalInstance = this;

        OnAnyPlayerSpawned?.Invoke(this, EventArgs.Empty);
    }
}
public class SelectedCounterVisual : MonoBehaviour {
    private void Start() {
        Player.OnAnyPlayerSpawned += (object sender, EventArgs args) =>
        {
            if((sender as Player).IsOwner)
            {
                Player.LocalInstance.OnSelectedCounterChanged += Player_OnSelectedCounterChanged;
            }
        };
    }
}

注意由于OnAnyPlayerSpawned是一个Static的全局变量,因此当切换场景时,该变量仍然记录了过时对象的函数地址,因此需要专门创建类来在切换场景时清空变量内容,在本游戏中,使用ResetStaticDataManager类管理这个操作

public class ResetStaticDataManager : MonoBehaviour {
    private void Awake() {
        CuttingCounter.ResetStaticData();
        BaseCounter.ResetStaticData();
        TrashCounter.ResetStaticData();
        Player.ResetStaticData();
    }
}

其他部分

我把包括大厅,创建房间,选择颜色,聊天等功能放到其他部分进行阐述,因为这一部分属于Gameplay以外的部分,且随业务改变的程度较小

玩家准备

玩家准备是指当所有玩家都载入游戏场景,每个人都按下互动键后,游戏才会正式进入倒计时开始。

  • 17
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值