第三部分 GameManager 单例模式
注意如果脚本命名为GameManager它会变成齿轮图标,虽然没什么用
创建GameManager类(后面还会贴这只是个骨架)
1 2 3 4 5 6 7 8 9 10 public class GameManager : Singleton <GameManager >{ public CharacterStats playerStats; public void RegisterPlayer (CharacterStats player ) { playerStats = player; } }
创建工具Singleton泛型类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Singleton <T > : MonoBehaviour where T :Singleton <T >{ private static T instance; public static T Instance { get { return instance; } } protected virtual void Awake () { if (instance != null ) { Destroy(gameObject); } else { instance = (T)this ; } } public static bool isInitialized { get { return instance != null ; } } protected virtual void OnDestoy () { if (instance == this ) { instance = null ; } } }
之后把各种Manager类的父类从Monobehaviour改成Singleton即可
观察者模式进行广播
其实就是让所有敌人变成观察者,玩家被观察
增加接口IEndGameObserver
1 2 3 4 public interface IEndGameObserver { void EndNotify () ; }
让EnemyController实现这个接口,并实现接口方法如下
1 2 3 4 5 6 7 8 9 10 11 12 public void EndNotify (){ anim.SetBool("Win" , true ); PlayerDead = true ; isChase = false ; isWalk = false ; attackTarget = null ; }
在EnemyController中增加函数
1 2 3 4 5 6 7 8 9 10 11 private void OnDisable () { if (!GameManager.isInitialized) return ; GameManager.Instance.RemoveObserver(this ); }
更多敌人 多个史莱姆 如果仅仅是复制粘贴史莱姆那么会出现Data共用的问题,应该创建一个模板数据templateData,然后增加Awake方法如下
1 2 3 4 5 6 7 private void Awake (){ if (templateData != null ) { characterData = Instantiate(templateData); } }
乌龟 没什么好说的…代码和方法大致和史莱姆一样
如果想把一个动画器当做模板更方便地改变其中的动画,可以选择添加动画器覆盖控制器,更改原动画器则动画覆盖控制器也会随之修改
兽人 花了我很长时间…
创建代码Grunt类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.AI;public class Grunt : EnemyController { [Header("Skill" ) ] public float kickForce; public void KickOff () { if (attackTarget != null ) { transform.LookAt(attackTarget.transform); Vector3 direction = attackTarget.transform.position - transform.position; direction.Normalize(); attackTarget.GetComponent<NavMeshAgent>().isStopped = true ; attackTarget.GetComponent<NavMeshAgent>().velocity = direction * kickForce; attackTarget.GetComponent<Animator>().SetTrigger("Dizzy" ); } } }
创建代码状态机类(这里就不贴上了,创建这个状态机代码的目的是让玩家晕眩动画时不能进行移动 )
有几个bug困扰了我好久
明明代码里写了在攻击范围内会停止,但是史莱姆会还是会一直挤人
原因很简单,调整一下攻击范围大小
这个解决方法是先把玩家的代理的自动刹车关掉,再把停止距离调到大致为(0.5到1)
(如果开启自动刹车再开启停止距离可能会出现停止后滑动一会的情况,这个见仁见智)
NavMeshAgent的具体用法可参考文档(点Unity组件上的?即可查看)
如何让敌人打空而不是出刀必中 这里使用扩展方法类ExtensionMethod,设置敌人攻击范围为一个扇形
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 using System.Collections;using System.Collections.Generic;using UnityEngine;public static class ExtensionMethod { private const float dotThreshold = 0.5f ; public static bool IsFacingTarget (this Transform transform,Transform target ) { var vectorToTarget = target.position - transform.position; vectorToTarget.Normalize(); float dot = Vector3.Dot(transform.forward, vectorToTarget); return dot >= dotThreshold; } }
接下来在EnemyController里的Hit类里面直接调用 if (attackTarget != null && transform.IsFacingTarget(attackTarget.transform))
即可
this后的参数类似于通知这个方法将会被哪个类调用,并非实参,而后面那个才是实参
石头人 终于要设计Boss了
设置石头人Boss 其实抛开扔石头跟设置兽人差不多,近战击退远程扔石头,动画器也复刻的兽人的,创建了个Golem脚本,等下写完扔石头会贴代码
把之前的问题修复一下
暴击改为攻击者暴击
因为石头人体积较大,所以停止距离应该更大才能到达石头人处停止,故想攻击时使人物停止距离和攻击范围一样大,而走路时回归原始的停止距离
同样因为石头人体积大,石头人寻路的障碍躲避(相当于寻路的碰撞体)半径小一点就可以打到
挂状态机代码使人物受伤时不能攻击(代码之前那个StopAgent类就可以)
可扔的石头 贴个代码Golem类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.AI;public class Golem : EnemyController { [Header("Skill" ) ] public float kickForce; public GameObject rockPrefab; public Transform handPos; public void KickOff () { if (attackTarget != null && transform.IsFacingTarget(attackTarget.transform)) { var targetStats = attackTarget.GetComponent<CharacterStats>(); Vector3 direction = attackTarget.transform.position - transform.position; direction.Normalize(); targetStats.GetComponent<NavMeshAgent>().isStopped = true ; targetStats.GetComponent<NavMeshAgent>().velocity = direction * kickForce; targetStats.GetComponent<Animator>().SetTrigger("Dizzy" ); targetStats.TakeDamage(characterStats, targetStats); } } public void ThrowRock () { if (attackTarget != null ) { var rock = Instantiate(rockPrefab, handPos.position, Quaternion.identity); rock.GetComponent<Rock>().target = attackTarget; } } }
Rock类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 using System.Collections;using System.Collections.Generic;using UnityEngine;public class Rock : MonoBehaviour { private Rigidbody rb; [Header("Basic Setting" ) ] public float force; public GameObject target; private Vector3 direction; private void Start () { rb = GetComponent<Rigidbody>(); FlyToTarget(); } public void FlyToTarget () { if (target == null ) { target = FindObjectOfType<PlayerController>().gameObject; } direction = (target.transform.position - transform.position + Vector3.up).normalized; rb.AddForce(direction * force, ForceMode.Impulse); } }
反击石头回去 反击部分 给石头挂上Attackable的标签,并在Rock里面创建函数代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 private void OnCollisionEnter (Collision other ) { switch (rockStates) { case RockStates.HitPlayer: if (other.gameObject.CompareTag("Player" )) { other.gameObject.GetComponent<NavMeshAgent>().isStopped = true ; other.gameObject.GetComponent<NavMeshAgent>().velocity = direction * force; other.gameObject.GetComponent<Animator>().SetTrigger("Dizzy" ); other.gameObject.GetComponent<CharacterStats>().TakeDamage(damage,other.gameObject.GetComponent<CharacterStats>()); rockStates = RockStates.HitNothing; } break ; case RockStates.HitEnemy: if (other.gameObject.GetComponent<Golem>()) { var otherStats = other.gameObject.GetComponent<CharacterStats>(); otherStats.TakeDamage(damage, otherStats); Instantiate(breakEffect, transform.position, Quaternion.identity); Destroy(gameObject); } break ; } } }
为了让石头落地变成HitNothing状态在Rock的FixedUpdate中判断
1 2 3 4 5 6 7 private void FixedUpdate (){ if (rb.velocity.sqrMagnitude < 1f ) { rockStates = RockStates.HitNothing; } }
为了让人物反击石头回去,改变人物的PlayController中的Hit函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void Hit (){ if (attackTarget.CompareTag("Attackable" )) { if (attackTarget.GetComponent<Rock>()) { attackTarget.GetComponent<Rock>().rockStates = Rock.RockStates.HitEnemy; attackTarget.GetComponent<Rigidbody>().velocity = Vector3.one; attackTarget.GetComponent<Rigidbody>().AddForce(transform.forward * 20 , ForceMode.Impulse); } } else { var targetStats = attackTarget.GetComponent<CharacterStats>(); targetStats.TakeDamage(characterStats, targetStats); } }
Partical System部分 创建粒子特效,打回石头人的时候生成粒子特效(上文代码里已经有了这行)
创建粒子特效一般是先把基本效果搞定,之后打开粒子特效里的渲染器把图贴上
第四部分 最后一部分QAQ
敌人UI界面 界面 首先创建画布并把渲染模式调成世界空间
引入2DSprite包,创建image画红色血条
注意项目右上角有个小眼睛,那个可以隐藏或者显示引入的包
之后在红色血条上创建绿色血条,把图像类型改为填充 ,填充方法改为水平(其实冷却也是这么做的)
再为每个敌人都创建HealthBarPoint
代码 提醒一下代码文件最好不要有空格 ,否则Unity里虽然创建了文件但是无法编译
创建HealthBarUI代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 using System;using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.UI;public class HealthBarUI : MonoBehaviour { public GameObject healthUIPrefab; public Transform barPoint; public bool alwaysVisible; public float visibleTime; private float timeLeft; Image healthSlider; Transform UIbar; Transform cam; CharacterStats currentStats; private void Awake () { currentStats = GetComponent<CharacterStats>(); currentStats.updateHealthBarOnAttack += UpdateHealthBar; } private void OnEnable () { cam = Camera.main.transform; foreach (Canvas canvas in FindObjectsOfType <Canvas >()) { if (canvas.renderMode == RenderMode.WorldSpace) { UIbar = Instantiate(healthUIPrefab, canvas.transform).transform; healthSlider = UIbar.GetChild(0 ).GetComponent<Image>(); UIbar.gameObject.SetActive(alwaysVisible); } } } private void UpdateHealthBar (int currentHealth, int maxHealth ) { if (currentHealth <= 0 && gameObject) Destroy(UIbar.gameObject); UIbar.gameObject.SetActive(true ); timeLeft = visibleTime; float sliderPercent = (float )currentHealth / maxHealth; healthSlider.fillAmount = sliderPercent; } private void LateUpdate () { if (UIbar != null ) { UIbar.position = barPoint.position; UIbar.forward = -cam.forward; if (timeLeft <= 0 && !alwaysVisible) UIbar.gameObject.SetActive(false ); else timeLeft -= Time.deltaTime; } } }
在之前的CharacterStats类新建事件 public event Action<int, int> updateHealthBarOnAttack;
的TakeDamage函数里面注册事件 //TODO:Update UI updateHealthBarOnAttack?.Invoke(CurrentHealth, MaxHealth);
人物升级 类CharacterData_SO新增如下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 [Header("Kill" ) ] public int killPoint; [Header("Level" ) ] public int currentLevel; public int maxLevel; public int baseExp; public int currentExp; public float levelBuff; public float LevelMutiplier { get { return 1 + (currentLevel - 1 ) * levelBuff; } } public void UpdateExp (int point ) { currentExp += point; if (currentExp >= baseExp) { LevelUp(); } } private void LevelUp () { currentLevel =Mathf.Clamp(currentLevel + 1 ,0 ,maxLevel); baseExp += (int )(baseExp * LevelMutiplier); maxHealth = (int )(maxHealth * LevelMutiplier); currentHealth = maxHealth; Debug.Log("Level Up!" + currentLevel + "Max Health:" + maxHealth); }
然后在CharacterStats里面的TakeDamage()两个函数里面新增 if(CurrentHealth <= 0) attacker.characterData.UpdateExp(characterData.killPoint);
进行经验更新即可
玩家信息显示 设置UI
创建PlayerHealthUI类,挂在画布上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.UI;public class PlayerHealthUI : MonoBehaviour { Text levelText; Image healthSlider; Image expSlider; private void Awake () { levelText = transform.GetChild(2 ).GetComponent<Text>(); healthSlider = transform.GetChild(0 ).GetChild(0 ).GetComponent<Image>(); expSlider = transform.GetChild(1 ).GetChild(0 ).GetComponent<Image>(); } private void Update () { levelText.text = "Level " + GameManager.Instance.playerStats.characterData.currentLevel.ToString("00" ); UpdateExp(); UpdateHealth(); } void UpdateHealth () { float sliderPercent = (float )GameManager.Instance.playerStats.CurrentHealth / (float )GameManager.Instance.playerStats.MaxHealth; healthSlider.fillAmount = sliderPercent; } void UpdateExp () { float sliderPercent = (float )GameManager.Instance.playerStats.characterData.currentExp / (float )GameManager.Instance.playerStats.characterData.baseExp; expSlider.fillAmount = sliderPercent; }
传送 传送门 首先ShaderGraph制作传送门
注意ShaderGraph的保存摁钮在左上角,一定要记得保存
ShaderGraph有些Node的AB两个管道是不完全等价的,要多去尝试
创建代码TransitionPoint类的大致框架
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 using System.Collections;using System.Collections.Generic;using UnityEngine;public class TransitionPoint : MonoBehaviour { public enum TransitionType { SameScene,DifferentScene } [Header("Transition Info" ) ] public string sceneName; public TransitionType transitionType; public TransitionDestination.DestinationTag destinationTag; private bool canTrans; private void OnTriggerStay (Collider other ) { if (other.CompareTag("Player" )) canTrans = true ; } private void OnTriggerExit (Collider other ) { if (other.CompareTag("Player" )) canTrans = false ; } }
创建代码TransitionDestination的大致框架
1 2 3 4 5 6 7 8 9 10 11 12 13 using System.Collections;using System.Collections.Generic;using UnityEngine;public class TransitionDestination : MonoBehaviour { public enum DestinationTag { ENTER , A , B , C } public DestinationTag destinationTag; }
同场景传送 场景搭建
ProBuilder上面有齿轮的用Alt键加点击打开编辑界面
(ProBuilder的效果其实就相当于小型blender)
如果场景过暗或者过亮可以参考https://blog.csdn.net/weixin_42654944/article/details/125622914,天空盒也是在这个面板环境选项上调的
URP有特殊限制,一个物体最多有0~8个灯光照射,多了就不会电量,调整要去配置文件URPSetting里面去调
让摄像机快速对齐视图可以GameObject里面的对齐视图
代码 创建SceneController类(用协程是为了不同场景的异步做准备)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.SceneManagement;using UnityEngine.AI;public class SceneController : Singleton <SceneController >{ GameObject player; NavMeshAgent playerAgent; public void TransitionToDestination (TransitionPoint transitionPoint ) { switch (transitionPoint.transitionType) { case TransitionPoint.TransitionType.SameScene: StartCoroutine(Transition(SceneManager.GetActiveScene().name,transitionPoint.destinationTag)); break ; case TransitionPoint.TransitionType.DifferentScene: break ; } } IEnumerator Transition (string sceneName , TransitionDestination.DestinationTag destinationTag ) { player = GameManager.Instance.playerStats.gameObject; playerAgent = player.GetComponent<NavMeshAgent>(); playerAgent.enabled = false ; player.transform.SetPositionAndRotation(GetDestination(destinationTag).transform.position,GetDestination(destinationTag).transform.rotation); playerAgent.enabled = true ; yield return null ; } private TransitionDestination GetDestination (TransitionDestination.DestinationTag destinationTag ) { var entrances = FindObjectsOfType<TransitionDestination>(); for (int i = 0 ;i < entrances.Length; i++) { if (entrances[i].destinationTag == destinationTag) { return entrances[i]; } } return null ; } }
在TransitionPoint类里面增加
1 2 3 4 5 6 7 8 private void Update (){ if (Input.GetKeyDown(KeyCode.E) && canTrans) { SceneController.Instance.TransitionToDestination(this ); } }
注意Agent只要停用一次目标就丢失,再打开需要重新设立目标
不同场景传送 场景首先要放在生成设置内才可加载
使用异步设计,便于后台加载以及保存原游戏场景
在SceneController的Transition函数里面加上
1 2 3 4 5 6 if (SceneManager.GetActiveScene().name != sceneName){ yield return SceneManager.LoadSceneAsync(sceneName); yield return Instantiate (playerPrefab, GetDestination(destinationTag ).transform.position, GetDestination (destinationTag ).transform.rotation) ; yield break ; }
并且在各个Manager里面要加上DontDestroyOnLoad(this);
(这里发现一个事情,同名函数父子类如果没标注重写那会当成另一个函数不报错且会覆盖父函数,一定要注意,尽量不要这么用)
保存数据 使用PlayerPrefs配合Json保存
创建SceneManager类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 using System.Collections;using System.Collections.Generic;using UnityEngine;public class SaveManager : Singleton <SaveManager >{ protected override void Awake () { base .Awake(); DontDestroyOnLoad(this ); } private void Update () { if (Input.GetKeyDown(KeyCode.S)) { SavePlayerData(); } if (Input.GetKeyDown(KeyCode.L)) { LoadPlayerData(); } } public void SavePlayerData () { Save(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name); } public void LoadPlayerData () { Load(GameManager.Instance.playerStats.characterData, GameManager.Instance.playerStats.characterData.name); } public void Save (Object data,string key ) { var jsonData = JsonUtility.ToJson(data,true ); PlayerPrefs.SetString(key,jsonData); PlayerPrefs.Save(); } public void Load (Object data, string key ) { if (PlayerPrefs.HasKey(key)) { JsonUtility.FromJsonOverwrite(PlayerPrefs.GetString(key), data); } } }
具体PlayPres把这个数据存到哪并且有哪些方法可以看官方文档(还是那个小问号)
目前只保存了玩家的血量和经验值,保存其他得自己写
主菜单 创建主菜单UI
看着是没什么区别的,摄像机相当于沿着摄像机的视野范围给他镀了一层膜,而屏幕是固定在那个位置的。但是由于有的场景要做转场,所以会先设置成摄像机再设置成世界,方便之后摄像机穿过这层膜完成转场效果
代码 创建MainMenu类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 using System.Collections;using System.Collections.Generic;using UnityEngine;using UnityEngine.UI;public class MainMenu : MonoBehaviour { Button newGameBtn; Button continueBtn; Button quitBtn; private void Awake () { newGameBtn = transform.GetChild(1 ).GetComponent<Button>(); continueBtn = transform.GetChild(2 ).GetComponent<Button>(); quitBtn = transform.GetChild(3 ).GetComponent<Button>(); newGameBtn.onClick.AddListener(NewGame); continueBtn.onClick.AddListener(CotinueGame); quitBtn.onClick.AddListener(QuitGame); } void NewGame () { PlayerPrefs.DeleteAll(); SceneController.Instance.TransitionToFirstLevel(); } void CotinueGame () { if (SaveManager.Instance.SceneName != "" ) SceneController.Instance.TransitionToLoadGame(); } void QuitGame () { Application.Quit(); } }
SceneController增加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public void TransitionToLoadGame () { StartCoroutine(LoadLevel(SaveManager.Instance.SceneName)); } public void TransitionToFirstLevel () { StartCoroutine(LoadLevel("Game" )); } public void TransitionToMain () { StartCoroutine(LoadMain()); } IEnumerator LoadLevel (string scene ) { if (scene != "" ) { yield return SceneManager.LoadSceneAsync(scene); yield return player = Instantiate(playerPrefab,GameManager.Instance.GetEntrance().position, GameManager.Instance.GetEntrance().rotation); SaveManager.Instance.SavePlayerData(); yield break ; } } IEnumerator LoadMain () { yield return SceneManager.LoadSceneAsync("Main" ); yield break ; }
在SaveManager里面增加保存场景
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public void TransitionToLoadGame () { StartCoroutine(LoadLevel(SaveManager.Instance.SceneName)); } public void TransitionToFirstLevel () { StartCoroutine(LoadLevel("Game" )); } public void TransitionToMain () { StartCoroutine(LoadMain()); } IEnumerator LoadLevel (string scene ) { if (scene != "" ) { yield return SceneManager.LoadSceneAsync(scene); yield return player = Instantiate(playerPrefab,GameManager.Instance.GetEntrance().position, GameManager.Instance.GetEntrance().rotation); SaveManager.Instance.SavePlayerData(); yield break ; } } IEnumerator LoadMain () { yield return SceneManager.LoadSceneAsync("Main" ); yield break ; }
再加上ESC功能回到主菜单
1 2 3 4 if (Input.GetKeyDown(KeyCode.Escape)){ SceneController.Instance.TransitionToMain(); }
最后让人物刚生成就加载一下PlayerPrefs里面的数据,在Start里面 SaveManager.Instance.LoadPlayerData()
镜头穿过UI
在MainMenu的Awake里面添加
1 2 3 4 quitBtn.onClick.AddListener(QuitGame); director = FindObjectOfType<PlayableDirector>(); director.stopped += NewGame;
给MainMenu类添加新函数
1 2 3 4 void PlayTimeline (){ director.Play(); }
淡入淡出 创建SceneFader类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 using System.Collections;using System.Collections.Generic;using UnityEngine;public class SceneFader : MonoBehaviour { CanvasGroup canvasGroup; public float fadeInDuration; public float fadeOutDuration; private void Awake () { canvasGroup = GetComponent<CanvasGroup>(); DontDestroyOnLoad(gameObject); } public IEnumerator FadeOutIn () { yield return FadeOut (fadeOutDuration ) ; yield return FadeIn (fadeInDuration ) ; } public IEnumerator FadeOut (float time ) { while (canvasGroup.alpha < 1 ) { canvasGroup.alpha += Time.deltaTime / time; yield return null ; } } public IEnumerator FadeIn (float time ) { while (canvasGroup.alpha != 0 ) { canvasGroup.alpha -= Time.deltaTime / time; yield return null ; } Destroy(gameObject); } }
之后就各种用淡入淡出这两函数就可
协程其实还是单线程,只不过可以等待到下一帧,防止一帧内这个函数代码就执行完了
生成
在生成设置里面调场景和系统
在生成设置下玩家设置调更详细的参数,比如图标
总结 本来想最后贴一下代码的,但是……真的太麻烦了
最后这次日记给我的警示就是
写详细的日志太麻烦了,下次就算写也不会写这么长了