Use QFramework to refactor the Zuma game

material

Unity - Zuma Game
GitHub

illustrate

It is enough to use QF for one scene, and switch the prefab under UIRoot to achieve panel switching.
But of course there must be a test script that jumps directly to the test panel in the test, and the test scene is reserved (otherwise beginners don’t know how to restore the test scene), so the full text is divided by scene
insert image description here
insert image description here

----------------------------------------------------

01 Scene starts to enter (panel script, mainly dynamic (function) static (UI reference) separation)

01 First create a UIRoot (some in QF)

insert image description here

02 Make the UI well, and add Bind script to the UI that needs to be referenced (see the picture for the default setting)

insert image description here

03 Drag it out to make a prefab, and CreateUICode will generate two scripts (the automatic generation location is Scripts/UI in the upper directory), one for UI reference (xxx.Designer), one for function (xxx), and the one for function will be automatically added to the prefab

insert image description here

insert image description here

xxxPanelData, xxxPanel (not called Panel, called xxxWindow, xxxUI whatever)

using UnityEngine;
using UnityEngine.UI;
using QFramework;
using UnityEngine.SceneManagement;

namespace QFramework.Example
{
    
    
	public class StartGamePanelData : UIPanelData
	{
    
    
	}
	public partial class StartGamePanel : UIPanel
	{
    
    
		protected override void OnInit(IUIData uiData = null)
		{
    
    
			mData = uiData as StartGamePanelData ?? new StartGamePanelData();
			// please add init code here

			Screen.SetResolution(640, 1136, false);//宽,高,不可修改
			BtnStart.onClick.AddListener(() => {
    
    


				Debug.Log("StartGamePanel");
                SceneManager.LoadScene("01 SelectLevel");
            });
		}
		
		protected override void OnOpen(IUIData uiData = null)
		{
    
    
		}
		
		protected override void OnShow()
		{
    
    
		}
		
		protected override void OnHide()
		{
    
    
		}
		
		protected override void OnClose()
		{
    
    
		}
	}
}

xxxPanel.Designer

using System;
using UnityEngine;
using UnityEngine.UI;
using QFramework;

namespace QFramework.Example
{
    
    
	// Generate Id:29cfe4a1-ad1e-4350-a490-3bdf8cf34278
	public partial class StartGamePanel
	{
    
    
		public const string Name = "StartGamePanel";
		
		[SerializeField]
		public UnityEngine.UI.Button BtnStart;
		
		private StartGamePanelData mPrivateData = null;
		
		protected override void ClearUIComponents()
		{
    
    
			BtnStart = null;
			
			mData = null;
		}
		
		public StartGamePanelData Data
		{
    
    
			get
			{
    
    
				return mData;
			}
		}
		
		StartGamePanelData mData
		{
    
    
			get
			{
    
    
				return mPrivateData ?? (mPrivateData = new StartGamePanelData());
			}
			set
			{
    
    
				mUIData = value;
				mPrivateData = value;
			}
		}
	}
}

04 call, RrsKit.Init(); must be called

/****************************************************
    文件:GameStart.cs
	作者:lenovo
    邮箱: 
    日期:2023/7/2 22:59:41
	功能:
*****************************************************/

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;


namespace QFramework.Example
{
    
    
    public class GameStart : MonoBehaviour
    {
    
    

        #region 生命

        /// <summary>首次载入</summary>
        void Awake()
        {
    
    
            ResKit.Init();
            UIKit.OpenPanel<StartGamePanel>();
            GameObject.DontDestroyOnLoad(gameObject);
        }
        

        #endregion 


    }

}




06 Write the package name, mark it (ResKit only has it in the package list), and make AB package (QF has a related example that cannot be run without packaging; it is edited, but it is actually not so fast)

insert image description here
insert image description here
insert image description here

06 effect

insert image description here

---------------------------------------------------------------

02 Scene Selection

world choice

level selection

stars Plugins folder name

In unity, under the Plugins folder, it will be turned into a firstpass assembly

--------------------------------------------------------

03 Scene game interface

modify split Enum

Good point for beginners. But actually Unity's built-in scripts have enumerations written inside the class
insert image description here

GameUI is split into Pass panel and Fail panel

Pass panel

using UnityEngine;
using UnityEngine.UI;
using QFramework;
using UnityEngine.SceneManagement;

namespace QFramework.Example
{
    
    
	public class SuccPanelData : UIPanelData
	{
    
    
	}
	public partial class SuccPanel : UIPanel
	{
    
    
		protected override void OnInit(IUIData uiData = null)
		{
    
    
			mData = uiData as SuccPanelData ?? new SuccPanelData();
			// please add init code here


			BtnNext.onClick.AddListener(() => {
    
     
                GameData.LevelIndex++;
                SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);			
			});
        }
		
		protected override void OnOpen(IUIData uiData = null)
		{
    
    
		}
		
		protected override void OnShow()
		{
    
    
		}
		
		protected override void OnHide()
		{
    
    
		}
		
		protected override void OnClose()
		{
    
    
		}
	}
}

Fail panel

using UnityEngine;
using UnityEngine.UI;
using QFramework;
using UnityEngine.SceneManagement;

namespace QFramework.Example
{
    
    
	public class GameOverPanelData : UIPanelData
	{
    
    
	}
	public partial class GameOverPanel : UIPanel
	{
    
    
		protected override void OnInit(IUIData uiData = null)
		{
    
    
			mData = uiData as GameOverPanelData ?? new GameOverPanelData();
			// please add init code here

			BtnReset.onClick.AddListener(()=>{
    
    
                GameManager.Instance.StartBack();
				CloseSelf();
            });

            BtnReplay.onClick.AddListener(() => {
    
    
                SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
            });

			BtnHome.onClick.AddListener(() => {
    
    
				UIKit.OpenPanel<StartGamePanel>();
                CloseSelf();
            });
        }
		
		protected override void OnOpen(IUIData uiData = null)
		{
    
    
		}
		
		protected override void OnShow()
		{
    
    
		}
		
		protected override void OnHide()
		{
    
    
		}
		
		protected override void OnClose()
		{
    
    
		}
	}
}

Get a resource in watch QF

new ResLoader() is an obsolete prefab of
the explosion effect ball

			//在扫雷案例中测试的,用到WhiteChess
			ResKit.Init();
            ResLoader loader = ResLoader.Allocate();
			GameObject prefab = loader.LoadSync<GameObject>("WhiteChess");
			Instantiate(prefab , transform);

Process GameManager into GamePanel

01 First refer all the scripts of the child nodes to the parent node GamePanel
02 Extract the nodes referenced in the script, rename them (same as the node name), and add the Bind script. Go to the top of the script for easy viewing.
03 Because UI is used, SpriteRenderer should be changed to Image

Features of watch Awake

Only the Awake method is used in the script, and there will be no check option in front of it
insert image description here

bug AB resource does not exist

Obviously yes, try again

bug 3D to UGUI

After SpriteRenderer transfers Image (I want to use QF's method of calling the panel), the ball moves very little.
The method uses the same UIRoot before generating the map file, that is, UGUI, instead of the original world coordinates. At this point, be careful to adjust the Scale of the parent node and the RectTranfrom of the prefabricated ball Ball to 1, otherwise there will be problems with the distance between the balls (the problem is that there is a problem with the distance between the balls, or the tracks do not coincide)

modify UGUI Image

3000 is the smoothness of the ball moving along the curve
. 0.3 is also equivalent to the diameter of the ball. This diameter is the diameter of the ball prefab you want to instantiate.
insert image description here

The method uses the same UIRoot before generating the map file, that is, UGUI, instead of the original world coordinates. At the same time, it needs to be multiplied by the magnification (try 230 is suitable)

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MapConfig : ScriptableObject
{
    
    
    public float EndPoint {
    
     get; private set; }

    public List<Vector3> pathPointList = new List<Vector3>();

    public void InitMapConfig()
    {
    
    
        EndPoint = pathPointList.Count - 2;
    }

    public Vector3 GetPosition(float progress)
    {
    
    
        Camera ui=Camera.main.gameObject.FindComponentWithTag<Camera>("UI");
        int index = Mathf.FloorToInt(progress);
         //return Vector3.Lerp(pathPointList[index], pathPointList[index + 1], progress - index);

        Vector3 v1 = Vector3.Lerp(pathPointList[index], pathPointList[index + 1], progress - index);

        return v1*230f;
    }

}

effect track ball

insert image description here

-------------------------------------

modify The launch position, speed and end point of the launch ball are out of bounds

where the ball is launched

insert image description here
insert image description here
insert image description here

speed

insert image description here

Judgment beyond bounds

Just drag the ball on the Canvas to the boundary of the Canvas, and look at the coordinates
insert image description here

Effect

insert image description here

-------------------------------------------

The size and position of the bug destruction effect

using QFramework;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using ResLoader = QFramework.ResLoader;

public class FXManager :MonoSingleton<FXManager>
{
    
    
       Transform FXs;
   //
    GameObject destroyFXPrefab;
    ObjectPool<GameObject> destroyFXPool;
 

                                                                
    public void Init(Transform FXs)
    {
    
    
        this.FXs = FXs;
        //
        ResKit.Init();
        QFramework.ResLoader loader =  QFramework.ResLoader.Allocate();
        destroyFXPrefab = loader.LoadSync<GameObject>("DestroyFX"); 
        destroyFXPool = new ObjectPool<GameObject>(InstantiateFX, 10);
    }


    private GameObject InstantiateFX()
    {
    
    
        GameObject go = Instantiate(destroyFXPrefab, FXs);
        go.Hide();
        return go;
    }


    public void ShowDestroyFX(Vector3 pos)
    {
    
    
        GameObject go = destroyFXPool.GetObject();
        go.Show();
        go.transform.localPosition = pos;

        //延时0.5f执行回收操作
        ScheduleOnce.Start(this, () =>
         {
    
    
             go.Hide();
             destroyFXPool.AddObject(go);
         }, 0.5f);
    }
}

The bug emitter rotates once and is fixed to the bottom left

It will be done automatically later, maybe the loop call to open the GamePanel has been changed, resulting in multiple GamePanels?
insert image description here

bug auto refresh

After clicking a few times, there is no response, because there was no automatic refresh
insert image description here

bug The three mapConfigs cannot be placed in Resources, and they are blank when generated

If you don't put Resources,
GameManager.Instance.mapConfig will be empty after running. This is because
some of the mapconfigArr in the public class GameSceneConfig: MonoSingleton are empty

The bug is not sensitive when lifting the launch

close this
insert image description here

watch does not fall back after eliminating the ball

It is necessary to retreat after the small ball.
After the small ball, there is still the situation of continuing to eliminate the ball.

The bug is too hard to fall back after resurrection

modify SoundManager

After reading the example, there is no need for initialization similar to UIkit's ResKit.Init()
AudioKit.PlaySound("resources://Sound/"+clipName);
AudioKit.PlayMusic("resources://Sound/"+name, volume:volume);

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using QFramework;

public class SoundManager : MonoSingleton<SoundManager>
{
    
    
    static AudioSource bgAudio;


    public void Init()
    {
    
    
        bgAudio = gameObject.GetOrAddComponent<AudioSource>();
    }


    private static void PlaySound(string clipName)
    {
    
    
      //  AudioSource.PlayClipAtPoint(GetAudioClip(clipName), Vector3.zero);
        AudioKit.PlaySound("resources://Sound/"+clipName );
    }


    public static AudioClip GetAudioClip(string clipName)
    {
    
    
        return Resources.Load("Sound/" + clipName, typeof(AudioClip)) as AudioClip;
    }


    public static void PlayDestroy() {
    
     PlaySound("Eliminate");  }
    public static void PlayShoot()   {
    
     PlaySound("Shoot"); }
    public static void PlayInsert()  {
    
     PlaySound("BallEnter"); }
    public static void PlayBomb()    {
    
     PlaySound("Bomb"); }
    public static void PlayFail()    {
    
     PlaySound("Fail"); }
    public static void PlayFastMove(){
    
     PlaySound("FastMove"); }
    public static void PlayMusic(string name,float volume=0.3f) 
    {
    
                                                       
        //bgAudio.clip = SoundManager.GetAudioClip(name);//*-
        //bgAudio.volume = volume;
        //bgAudio.loop = true;
        //bgAudio.Play();
        AudioKit.PlayMusic("resources://Sound/"+name,volume:volume);
    }



}

bug shooterSO data loss

It happened that the Vector3 inside was lost, so the following was used. I don't know if it works or not

        EditorUtility.SetDirty(fromAsset);

insert image description here

AB package naming of bug Object

Originally I named AB 0_mapconfig, and this ABbao1 was also included in the package,
but 0_asset automatically appeared on the right, and the file was automatically marked as 0)_asset, resulting in 0_asset appearing in the subsequent package
insert image description here

Bug The ball is not destroyed after passing the next level, but progress==0

This is a section of the initial initialization of the small ball. It does not move because GmaeState==Succ at this time. To re-enter the game, GameState needs to be reset
insert image description here

modify Manager split

Split Manager from GamePanel
insert image description here

--------------------------------------------------------

Description of the two panels of the Panel

It is also automatically generated by QF's UI script, and the code is filled in
. . . .
There is too much rollback during resurrection (the reason for 3D to UGUI), and the value needs to be adjusted (there is a rollback time of 3 seconds)
. . . .
The effect of the two panels is at the end of the article

Panel success

using UnityEngine;
using UnityEngine.UI;
using QFramework;
using UnityEngine.SceneManagement;
using QFramework.PointGame;


namespace QFramework.Example
{
    
    
	public class SuccPanelData : UIPanelData
	{
    
    
	}
	public partial class SuccPanel : UIPanel
	{
    
    
		protected override void OnInit(IUIData uiData = null)
		{
    
    
			mData = uiData as SuccPanelData ?? new SuccPanelData();
			// please add init code here


			BtnNext.onClick.AddListener(() => {
    
    
				
				UIKit.OpenPanel<GamePanel>(
					new GamePanelData() {
    
     LevelCount=GameData.GetLevelIndex() }
				);
				CloseSelf();
			});

			BtnHome.onClick.AddListener(() => {
    
    
                UIKit.OpenPanel<StartGamePanel>();
                CloseSelf();
            });
        }
		
		protected override void OnOpen(IUIData uiData = null)
		{
    
    
		}
		
		protected override void OnShow()
		{
    
    
		}
		
		protected override void OnHide()
		{
    
    
		}
		
		protected override void OnClose()
		{
    
    
		}
	}
}

Panel failed

The main thing is resurrection, it can’t be Close, so after failure, Hide first, then
resurrect in 01, show
02 again, close, and
return to the homepage in Open 03

using UnityEngine;
using UnityEngine.UI;
using QFramework;
using UnityEngine.SceneManagement;

namespace QFramework.Example
{
    
    
	public class GameOverPanelData : UIPanelData
	{
    
    
	}


	public partial class GameOverPanel : UIPanel
	{
    
    
		protected override void OnInit(IUIData uiData = null)
		{
    
    
			mData = uiData as GameOverPanelData ?? new GameOverPanelData();
			// please add init code here

			BtnReset.onClick.AddListener(()=>{
    
     //复活
                GameManager.Instance.GameRevive();
                CloseSelf();

            });

            BtnReplay.onClick.AddListener(() => {
    
     //再来一次
                UIKit.ClosePanel<GamePanel>();
                UIKit.OpenPanel<GamePanel>( new GamePanelData() {
    
     LevelCount=GameData.GetLevelIndex() });
                CloseSelf();
            });

			BtnHome.onClick.AddListener(() => {
    
    	 //主页
                UIKit.ClosePanel<GamePanel>();
                UIKit.OpenPanel<StartGamePanel>();
                CloseSelf();
            });
        }
		
		protected override void OnOpen(IUIData uiData = null)
		{
    
    
		}
		
		protected override void OnShow()
		{
    
    
		}
		
		protected override void OnHide()
		{
    
    
		}
		
		protected override void OnClose()
		{
    
    
		}
	}
}

---------------------------------------------------------

Tool track production

modify BezierPathController

Make the Ball prefab (I used the sprite (UGUI) of the red ball (you can use others), the original one is MeshRender (in the 3D world)) the node "Map" is used in
BezierPathController.Awake(), it is used to demonstrate the coordinates of the blue ball, you can comment it out

01 Drag one to the "1" node (with BezierPathController script)
01 Constantly copy the prefab under the node with Ctrl+D (every 4 (basically 2 control the curvature, 2 on the track) red ball prefab will generate a piece of blue ball) 02 Control Point List contains the coordinate data of the red ball (world coordinates). ", "Path Point List" is the position of the blue ball (world coordinates) A ​​coordinate data is also saved in the
05 Map folder


insert image description here

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using UnityEditor;
using System;
using UnityEngine.UI;

public class BezierPathController : MonoBehaviour
{
    
    

    #region 字属



    public int segmentsPerCurve = 3000;
    /// <summary>连线上求和球之间的举例,也就是比直径大一点</summary>
    public float BallAndBallDis = 0.3f;
    public bool Debug = true;
    public GameObject ballPrefab;

    /// <summary>贝塞尔曲线的节点。控制弯曲度的白球</summary>
    public List<GameObject> ControlPointList = new List<GameObject>();
    /// <summary>贝塞尔曲线的的线段。连线的蓝球的坐标</summary>
    public List<Vector3> pathPointList = new List<Vector3>();
    #endregion


    //private void Awake()
    //{
    
    
    //    Debug = true;
    //    foreach (var item in pathPointList)
    //    {
    
    
    //        GameObject ball = Instantiate(ballPrefab, GameObject.Find("Map").transform);
    //        ball.transform.position = item;
    //    }
    //}


    private void OnDrawGizmos()
    {
    
    
        //节点
        ControlPointList.Clear();
        foreach (Transform item in transform)//没错,就是遍历子节点
        {
    
    
            ControlPointList.Add(item.gameObject);
        }


        //线段
        List<Vector3> controlPointPos 
            = ControlPointList.Select(point => point.transform.position).ToList();
        var points = GetDrawingPoints(controlPointPos, segmentsPerCurve);

        Vector3 startPos = points[0];
        pathPointList.Clear();
        pathPointList.Add(startPos);
        for (int i = 1; i < points.Count; i++)
        {
    
    
            if (Vector3.Distance(startPos, points[i]) >= BallAndBallDis)
            {
    
    
                startPos = points[i];
                pathPointList.Add(startPos);
            }
        }

        foreach (var item in ControlPointList)
        {
    
    
            item.GetComponent<Image>().enabled = Debug;//相当于将物体隐身,并不会影响物体的脚本运行,物体的碰撞体也依然存在。
        }

        if (Debug == false)
        {
    
     
            return;
        } 


        //01 画连线球的球
        Gizmos.color = Color.blue;
        foreach (var pos in pathPointList)
        {
    
    
            Gizmos.DrawSphere(pos, BallAndBallDis / 2);
        }

        //02 画连线球的线
        Gizmos.color = Color.yellow;
        for (int i = 0; i < points.Count - 1; i++)
        {
    
    
            Gizmos.DrawLine(points[i], points[i + 1]);
        }

        //03 画连线球的的弯曲度控制线
        //绘制贝塞尔曲线控制点连线,红,色
        Gizmos.color = Color.red;
        for (int i = 0; i < controlPointPos.Count - 1; i++)
        {
    
    
            Gizmos.DrawLine(controlPointPos[i], controlPointPos[i + 1]);
        }

    }



    #region 辅助


    /// <summary>贝塞尔线段</summary>
    List<Vector3> GetDrawingPoints(List<Vector3> controlPoints, int segmentsPerCurve)
    {
    
    
        List<Vector3> points = new List<Vector3>();
        for (int i = 0; i < controlPoints.Count - 3; i += 3)
        {
    
    
            var p0 = controlPoints[i];
            var p1 = controlPoints[i + 1];
            var p2 = controlPoints[i + 2];
            var p3 = controlPoints[i + 3];

            for (int j = 0; j <= segmentsPerCurve; j++)
            {
    
    
                var t = j / (float)segmentsPerCurve;
                points.Add(CalculateBezierPoint(t, p0, p1, p2, p3));
            }
        }
        return points;
    }

    /// <summary>
    /// <summary>贝塞尔曲线的三次方公式</summary>
    /// </summary>
    /// <param name="t"></param>
    /// <param name="p0">起点</param>
    /// <param name="p1">一侧的平滑度调节点</param>
    /// <param name="p2">另一侧的平滑度调节点</param>
    /// <param name="p3">终点</param>
    /// <returns></returns>
    Vector3 CalculateBezierPoint(float t
        , Vector3 p0
        , Vector3 p1, Vector3 p2
        , Vector3 p3)
    {
    
    
        var x   = 1 - t;
        var xx  = x * x;
        var xxx = x * x * x;
        var tt  = t * t;
        var ttt = t * t * t;
        return p0 * xxx 
            +   3 * p1 * t * xx 
            +   3 * p2 * tt * x 
            +  p3 * ttt;
    }


#if UNITY_EDITOR
    /// <summary>
    /// pathPointList写入"Assets/Map/map.asset"
    /// 但没有覆盖功能,删掉再创建就看得见效果了
    /// </summary> 
    public void CreateMapAsset()
    {
    
    
        string assetPath =String.Format(  "Assets/Map/{0}.asset",gameObject.name);  //写这Vector3数据的
        MapConfig mapConfig = new MapConfig();
        foreach (Vector3 item in pathPointList)
        {
    
    
            mapConfig.pathPointList.Add(item);
        }
        AssetDatabase.CreateAsset(mapConfig, assetPath);
        AssetDatabase.SaveAssets();
    }
#endif


    #endregion


}


#if UNITY_EDITOR
[CustomEditor(typeof(BezierPathController))]
public class BezierEditor : Editor
{
    
    
    public override void OnInspectorGUI()
    {
    
    
        base.OnInspectorGUI();
        if (GUILayout.Button("生成地图文件"))//详情面板下的按钮
        {
    
    
            (target as BezierPathController).CreateMapAsset();
        }
        AssetDatabase.Refresh();
    }
}
#endif



modify save location, file name

If the save location is placed in Resources, an error will be reported.
The file name is changed to gameObject.name, not hard-coded

string assetPath =String.Format( “Assets/Map/{0}.asset”, gameObject.name); //write this Vector3 data

watch traverses child nodes

Transform implements iterators internally, so it can be written like this

        //节点
        ControlPointList.Clear();
        foreach (Transform item in transform)//没错,就是遍历子节点
        {
    
    
            ControlPointList.Add(item.gameObject);
        }

Tool Frog Shooter position

GameMapConfig to control, there is a Vector3 array inside

modify Modify the name of the picture (-1) for easy correspondence

insert image description here

modify A tool for making Shooter positions

/****************************************************
    文件:MakeShooterPos.cs
	作者:lenovo
    邮箱: 
    日期:2023/7/19 15:37:17
	功能:
*****************************************************/

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using Random = UnityEngine.Random;
 

public class MakeShooterPos : MonoBehaviour
{
    
    
#if UNITY_EDITOR
    /// <summary>
    /// pathPointList写入"Assets/Map/map.asset"
    /// 但没有覆盖功能,删掉再创建就看得见效果了
    /// </summary> 
    public void RecordeShooterPos()
    {
    
    
        MapConfig fromAsset= AssetDatabase.LoadAssetAtPath<MapConfig>("Assets/Map/shooter.asset");
        int idx = int.Parse( gameObject.name);
        Transform shooter = GameObject.Find("ShooterTrans").transform;
        fromAsset.pathPointList[idx] = shooter.localPosition;
        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();
    }
#endif

}

#if UNITY_EDITOR
[CustomEditor(typeof(MakeShooterPos))]
public class MakeShooterPosEditor : Editor
{
    
    
    public override void OnInspectorGUI()
    {
    
    
        base.OnInspectorGUI();
        if (GUILayout.Button("生成Shooter位置"))//详情面板下的按钮
        {
    
    
            (target as MakeShooterPos).RecordeShooterPos();
        }
        AssetDatabase.Refresh();
    }
}
#endif





Effect

insert image description here

---------------------------------------------------------

overall effect

01 Mainly press the S (Success) key to quickly pass the level and test.
02 Mainly press the F (Fail) key to fail quickly and test.
The map data has only been tested for three levels, so an error is reported at the end.
insert image description here
insert image description here

Guess you like

Origin blog.csdn.net/weixin_39538253/article/details/131519015