Unity 2D游戏开发案例学习——SunnyLand(基本完结)

编辑素材&Tilemap

基本设置

1.修改素材中“back”的【import setting】(?)中的[Pixels Per Unit]属性从100到16(每个单位格中有多少个像素点)

场景构成

【Tile】
unity提供全套的Tile工具来编辑地图
1.在场景中创建Tilemap游戏对象,场景中将出现一个[Grid]对象并带有一个[TileMap],Grid就是Tile的画布,可以设置画布每个单元的大小(和[Pixels Per Unit]属性?)
2.通过调出【Tile Palette】窗口可以进行Tile的绘制。创建一个新的Palette(调色板)并保存在适当的位置,就可以向其中导入Sprite资源了
3.导入一张包含多个内容的Sprite资源时,应当将其Mode改为[Multiple],然后进入编辑界面进行Slice(切割),切割时注意切割设置,在此案例中选择[Grid By Cell Size]。切割完成后,原来的Sprite素材会包含分割完成的多个素材
4.将切割完成的Sprite素材拖拽导入【Tile Palette】,即可对每一个单元格进行绘制。点击所需要的Tile,选择brush(笔刷),即可在Grid中绘制场景

图层&角色建立

图层排序

1.图层排序[Sorting Layer],根据需要选择相应的图层排序(从上到下的图层为从后往前)
2.若想要调整处于同一图层排序的对象的层次,则使用[Order in Layer],此属性越大越在前方

角色建立

1.(注意[Pixels Per Unit]属性的大小)常创建一个Sprite对象,将Idel文件夹中的角色Sprite拖拽到【Sprite Renderer】中(或直接将一个角色Sprite拖拽入场景生成)
2.创建角色实体:添加碰撞体和刚体
3.添加地形碰撞体:Tilemap Collider

角色移动

void Movement()
	{
		float move = Input.GetAxis("Horizontal");
		if (move != 0)
		{
			playerRig.velocity = new Vector2(move * speed, 0);
		}
	}

角色方向&跳跃

代码修正

1.增加左右移动判断,通过Scale道正人物朝向

void Movement()
	{
		float move = Input.GetAxis("Horizontal");//不灵敏的变速运动,若使用Raw则会向一个方向一直运动

		float direction = Input.GetAxisRaw("Horizontal");

		if (move != 0 && direction != 0)
		{
			playerTransform.localScale = new Vector3(direction, 1, 1);//调整人物朝向 
			playerRig.velocity = new Vector2(move * speed, 0);
		}
	}

2.进一步修正,通过deltaTime使移动更加均匀,并将移动函数的调用调整到FixedUpdata中(避免机器产生的差异),但相应基础速度值需要增大很多

void Movement()
	{
		float move = Input.GetAxis("Horizontal");//不灵敏的变速运动,若使用Raw则会向一个方向一直运动

		float direction = Input.GetAxisRaw("Horizontal");

		if (move != 0)//移动判断
		{
			
			playerRig.velocity = new Vector2(move * speed * Time.deltaTime, playerRig.velocity.y);
			playerAnim.SetFloat("Running", Mathf.Abs(direction));
		}

		if (direction != 0)//朝向判断
		{
			playerTransform.localScale = new Vector3(direction, 1, 1);//调整人物朝向 
		}
	}

3.(?)通过FixedUpdata进行的移动函数调用会意外停顿

角色跳跃

1.添加角色跳跃代码(未修正手感等)

if (Input.GetButton("Jump"))
	{
		playerRig.velocity = new Vector2(playerRig.velocity.x, jumpForce);
	}

其他的跳跃和移动方式?
跳跃和移动手感的问题?
空中摇摆影响下落?移动和跳跃冲突?

其他

1.关于Tile的碰撞器
TileMap可以在现有基础上加一个CompositeCollider2D,并把原来的TileMapCollider2D的Used By Composite钩上,这样每一个瓦片的碰撞框会合并到一起,减少消耗,同时消除原碰撞体情况下可能导致的Bug(?),(注意这里地面刚体组件应当设置static)

动画效果

Run

1.从Idel状态到Run状态的切换应当立即切换,因此要取消勾选[Has Exit Time],并且将[Setting]中的[Transition Duration](切换时间)设置为0
2.由于转换标签类型为float,因此使用Raw数据作为判据(直接使用GetAxis可能导致无法恢复Idel)

Jump

1.设置多条判断Tag
2.通过Collider和Layer进行地面碰撞判断

错误修正

关于碰撞体

1.人物原有的方形碰撞体和地形的交互会存在一些问题,再这里为角色再新增一个圆形碰撞体,和方形碰撞体共同构成人物碰撞体(注意人物与地面的碰撞判断使用了原来的方形碰撞体,这里需要修正)

镜头控制

基本跟踪方式

1.可变动x或y轴的镜头跟踪

public Transform player;
    void Update()
    {
		this.transform.position = new Vector3(player.position.x, player.position.y, transform.position.z);
    }

考虑到背景,可以采取锁定y轴的镜头跟踪(置位置向量中y坐标为基准值)

使用Cinemachine插件

设置基本属性

1.添加一个2D摄像头(会覆盖原有摄像头)
2.将要跟随的角色拖拽入[Fallow]
3.(待补充)

镜头边际锁定

1.为Cinemachine 2D摄像头添加Confiner组件
2.为游戏背景添加多边形碰撞体,并加入Confiner组件
3.调整碰撞体为触发器

设置可收集物品&Prefabs

设置可收集物品

1.在场景中初始化收集物,添加动画,添加碰撞体(触发器)
2.为收集物设置Tag,便于在脚本中判断

private void OnTriggerEnter2D(Collider2D collision)
	{
		if (collision.tag.Equals("Collections"))
		{
			Destroy(collision.gameObject);
		}
	}

注意,此处销毁时应销毁collision.gameObject
3设置计数变量记录收集物获得数

保存预制体

1.将制作完备的Player和收集物保存为预制体

其他

设置更丰富的图层,并丰富场景

物理材质&空中跳跃

物理材质

1.解决遗留问题(当人物跳跃撞击地形后保持摁下移动,人物将挂在地形上无法正常下落),这是由于物理摩擦问题(?),再这里创建2D物理材质设置相应属性,并配置给Player的碰撞体

跳跃问题

1.使角色可以在地面进行跳跃,但不能在空中进行跳跃,可以进行如下两种修改:
(1)角色控制脚本中添加布尔变量couldJump作为判据,当角色处于地面时(利用碰撞判断)时置为true,进行跳跃后在移动控制方法中置为false
(2)直接将地面碰撞检测加入跳跃判断

UI入门

1.创建画布
2.创建文本框
3.通过脚本控制文本框(相应设置分数等)

注意事项

1.利用锚点限定UI的相对位置
2.(?)是否应当将静态文本和分数分离为两个文本框?

3.(?)由于碰撞检测的频率问题可能导致收集物带来两次分数加成
解决方案:通过动画事件来触发计数

Enemy

设置敌人

1.设置敌人,增添刚体和碰撞体脚本,添加动画

消灭敌人

1.在脚本中添加消灭敌人方法
注意,再这里通过tag辨别碰撞信息时,触发器和碰撞体有如下差别:

(收集物相关代码)

if (collision.tag.Equals("Cherrys"))
		{
			Destroy(collision.gameObject);
			cherry++;
			cherryText.text = "Cherry:" + cherry;
			SetCount(10);
		}

(消灭敌人相关代码)

if (collision.gameObject.tag.Equals("Enemys"))
		{
			Destroy(collision.gameObject);
		}

碰撞体需要间接通过碰撞对象获得tag

2.进一步修改,碰撞到敌人时应当触发角色“受伤”事件,消灭敌人应当仅限于踩踏,这里借助动画状态机的逻辑进行实现,在人物下落状态时发生碰撞才会消灭

优化

消灭敌人后,可以为角色添加一个类似超级玛丽的弹起的跳跃效果。这里使用跳跃控制代码放入成功消灭敌人的代码块中

人物受伤

1.人物受伤应被弹开,并且触发受伤动画效果
值得一提的是,项目中原本用来控制角色运动的是刚体速度属性,这似乎会产生一些问题,官方文档中并不建议使用改写速度的方式来控制运动,而提倡使用addForce等
这里暂时使用改写速度的方式,判断角色在左边还是右边,然后给角色一个相应方向的速度,在消灭敌人方法中加入:

else
			{
				playerRig.velocity = new Vector2(transform.position.x - collision.transform.position.x, jumpForce * Time.deltaTime);
				isHurt = true;
				//playerRig.velocity = new Vector2();
				//playerAnim.SetBool("Injured",true);//置于动画切换方法中
			}

由于Movement方法在FixedUpdata方法中一直在调用,运动时横向速度在被不断改写,因此加入判断条件,使受伤时Movement不被调用

if (!isHurt)
		{
			Movement();
		}

重置isHurt的代码置于动画切换方法中

 else if (isHurt)
		{
			if (Mathf.Abs(playerRig.velocity.x) < 0.1f)
			{
				isHurt = false;
			}
		}

问题

1.角色运动控制中,关于向量的使用(?)
2.游戏运行时,时常会有动画未能正常切换到Idel,人物仍在跑动的情况
这里是由于Running这条tag(float)未能正常降为0(?),可以在脚本中适当位置直接手动设置为0
3.跑动下落偶尔会使人物嵌入地形(?)

15:敌人移动(2019_11_23)

1.修改Frog的碰撞体为圆形碰撞体(避免与地形交互时发生的卡顿情况)
2.使Frog左右移动
(1)在脚本中获得Frog刚体组件控制位移,并且使之能够判断边界
方案1:通过两个空物体来限定Frog移动范围(为使空物体易于编辑,这里可以选择一个可视的彩色图标),两个空物体作为Frog的子物体并存储为预制体

public Transform LeftPoint,RightPoint;//获得左右临界点位置
void Movement()
	{
		if (isLeft)
		{
			FrogRig.velocity = new Vector2(-speed*Time.deltaTime, FrogRig.velocity.y);
			if (this.transform.position.x<=LeftPoint.position.x)//越过左侧点翻转
			{
				this.transform.localScale = new Vector3(-1, 1, 1);
				isLeft = false;
			}
		}
		else
		{
			FrogRig.velocity = new Vector2(-speed*Time.deltaTime, FrogRig.velocity.y);
			if (this.transform.position.x>=RightPoint.rotation.x)
			{
				this.transform.localScale = new Vector3(1, 1, 1);
				isLeft = true;
			}
		}
	}

这里注意,左右临界点设置为Frog的子物体后,会跟随Frog同步运动,因此需要对此进行调整:
方案1:获取到两个Transform后使两个临界点不再是Frog子物体

this.transform.DetachChildren();

方案2:仅获得左右临界点位置值,然后进行销毁

void Start()
    {
		leftx = LeftPoint.position.x;
		rightx = RightPoint.position.x;
		Destroy(LeftPoint.gameObject);
		Destroy(RightPoint.gameObject);
	}

16:Animation Events

1.修正下落动画的触发,使角色初始化时或从高台平移下落时也可以触发下落动画

playerAnim.SetBool("Idel", false);
		if (playerRig.velocity.y<0.1f && !playerCollider.IsTouchingLayers(ground))
		{
			playerAnim.SetBool("Falling", true);
		}

2.使用动画事件,为Frog增加跳起和下落动画

void SwitchAnim()
	{
		if (FrogAnim.GetBool("Jumping"))
		{
			if (FrogRig.velocity.y<0.1f)
			{
				FrogAnim.SetBool("Jumping", false);
				FrogAnim.SetBool("Falling", true);
			}
		}
		if (FrogCollider.IsTouchingLayers(ground) && FrogAnim.GetBool("Falling"))
		{
			FrogAnim.SetBool("Falling", false);
		}

		#region
		//	if (FrogAnim.GetBool("Jumping"))
		//	{
		//		if (FrogRig.velocity.y<0)
		//		{
		//			FrogAnim.SetBool("Jumping", false);
		//			FrogAnim.SetBool("Falling", true);
		//		}
		//	} else if (FrogCollider.IsTouchingLayers(ground))
		//	{
		//		FrogAnim.SetBool("Falling", false);
		//	}
		#endregion
	}
void Movement()
	{

		if (isLeft)
		{
			//FrogRig.velocity = new Vector2(-speed*Time.deltaTime, FrogRig.velocity.y);
			if (transform.position.x <=/*LeftPoint.position.x*/leftx-1)//越过左侧点翻转
			{
				transform.localScale = new Vector3(-1, 1, 1);
				isLeft = false;
			}
			if (FrogCollider.IsTouchingLayers(ground))
			{
				FrogAnim.SetBool("Jumping", true);
				FrogRig.velocity = new Vector2(-speed * Time.deltaTime, jumpForce * Time.deltaTime);
			}
		}
		else
		{
			//FrogRig.velocity = new Vector2(speed*Time.deltaTime, FrogRig.velocity.y);
			if (transform.position.x >=/*RightPoint.rotation.x*/rightx+1)
			{
				transform.localScale = new Vector3(1, 1, 1);
				isLeft = true;
			}
			if (FrogCollider.IsTouchingLayers(ground))
			{
				FrogAnim.SetBool("Jumping", true);
				FrogRig.velocity = new Vector2(speed * Time.deltaTime, jumpForce * Time.deltaTime);
			}
		}

增添如上代码后,在Updata中仅调用SwitchAnim即可

3.其他问题

1.Frog的左右跳跃过程中,存在朝向和速度方向不协调的问题。
在这里可以:在转向代码前将水平速度手动调零(未解决?)

17:Class调用

Eagle动画效果

1.注意锁定x轴的移动,避免玩家角色施加的作用力使Eagle脱离设定位置

单个Enemy被消灭的特效制作

1.添加Enemy死亡动画
2.代码逻辑实现
(1)使Frog内置Death方法和OnJunp方法,分别用于调用死亡特效动画和销毁物体,将原来的通过碰撞体踩踏“Enemy”销毁的逻辑修改,先常见创建一个Frog类的对象并通过GetComponent方法获得碰撞体的相应脚本引用,然后调用其OnJump方法销毁对象
(问题:未能对各种敌人进行统一管理)
(2)将Death和OnJump功能互换,通过在Death动画结束后插入事件实现死亡特效

通过创建Class主类,使所有Enemy都能调用动画效果

(解决上述遗留问题)
1.创建一个Enemy类用于统一管理敌人
(1)在Enemy中,定义Protected的Animator变量Anim,在Awake或Start中完成初始化。使Frog和Eagle成为Enemy子类。若想要子类可以继承其Awake或Start等方法并可重写(?),在Enemy的方法中声明virtual关键字,在子类相应方法中声明override关键字,并在子类对应方法中通过base.XX();继承
(2)将Death和JumpOn方法转移到父类中进行统一调用(声明为public),然后更新消灭敌人部分的代码
(问题:Enemy被消灭后播放死亡特效动画时,爆炸效果会根据原来的运动状态发生移动)

18:音效Audio

BGM

1.为Player添加Audio Source(这个举动只会配适给当前场景的Player,不会自动添加在Prefab上。想要配适给所有的Player可以通过Overrides按钮添加)

其他

1.添加怪物死亡音效(注意取消开始时播放),在代码中实现调用(置于OnJump中,若置于Death可能会由于对象销毁而无法正常播放)
2.添加跳跃音效
3.添加受伤音效
4.添加收集物收集音效
5.添加。。。

19:对话框Dialog

1.在Canvas中添加一个Panel,并将其放在合适的位置
2.在Panel中增加Text显示文本
3.通过在相应对象上设置触发器用于Panel的显示,将Panel控制脚本设置给相应的对象(Panel的引用需要使用GameObject
4.通过动画系统为Panel的显示增加渐变效果

20:下蹲效果Crouch

1.代码修正
若在FixedUpata中调用移动控制代码时,应使用fixedDealtTime,若使用dealtTime应置于Updata中进行调用、
2.按键设置
在项目设置里修改Input属性,为Jump增加“w”作为副键,并复制一个设置更名为Crouch
3.设置Crouch动画,添加相关代码

void Crouch()
	{
		if (Input.GetButtonDown("Crouch"))
		{
			playerAnim.SetBool("Crouching", true);
			boxCollider.enabled = false;
		} else if (Input.GetButtonUp("Crouch"))
		{
			playerAnim.SetBool("Crouching", false);
			boxCollider.enabled = true;
		}
	}

将下蹲方法置于Movement方法中引用
4.加入代码限制,使角色无法在卡在夹缝中时起立而被卡主
(1)在角色下添加一个空物体,作为头顶检查点
(2)使用Physics2D中的OverlapCircle方法进行检查
(问题:暂未添加由跑动到下蹲的动画状态转换逻辑)

if (!Physics2D.OverlapCircle(CellingCheck.position, 0.2f, ground))//
		{
			if (Input.GetButtonDown("Crouch"))
			{
				playerAnim.SetBool("Crouching", true);
				DisCollider.enabled = false;
			} else if (Input.GetButtonUp("Crouch"))
			{
				playerAnim.SetBool("Crouching", false);
				DisCollider.enabled = true;
			}
		}

这里0.2f的判定半径需要根据实际情况有所调整

21:场景控制

1.代码修正
在完成了前述代码后,控制角色移动时,保持下蹲姿势从夹缝中走出后,角色无法自动恢复Idel状态。这里可以对代码作出如下修改:

if (!Physics2D.OverlapCircle(CellingCheck.position, 0.5f, ground))
		{
			if (/*Input.GetButtonDown("Crouch")*/Input.GetButton("Crouch"))
			{
				playerAnim.SetBool("Crouching", true);
				DisCollider.enabled = false;
			} else /*if (Input.GetButtonUp("Crouch"))*/
			{
				playerAnim.SetBool("Crouching", false);
				DisCollider.enabled = true;
			}
		}

将GetButtonDown和GetButtonUp的使用调整为GetButton(下蹲需要长摁),此时脱离夹缝地形可以自动恢复站立
2.掉落死亡机制和场景重启
(1)在场景下端设置“死线”,用于判定角色下落死亡,并重置场景

if (collision.tag.Equals("DeadLine"))
		{
			SceneManager.LoadScene(SceneManager.GetActiveScene().name);
		}

将以上逻辑包装为一个方法,通过Invoke调用来实现延迟调用,然后禁用所有音频

//场景重置
		if (collision.tag.Equals("DeadLine"))
		{
			GetComponent<AudioSource>().enabled = false;//禁用所有音频
			Invoke("Restart", 2);
		}

3.关卡切换
在切换点设立触发器,挂载脚本,通关按下按键切换场景
(问题:关于文本框的动画触发)
这里可以使用两种方法:
(1)通过切换门的触发器,在触发器中时,按下E切换场景(无效,可能由于按键类操作需要在Updata中)
(2)将脚本加载到EnterDialog上,触发代码置于Updata中
这里使用(1)的思路,在Updata中检测碰撞

if (doorCollider.IsTouching(playMainCollider))
		{
			if (Input.GetKeyDown(KeyCode.E))
			{
				SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);//切换到下一个编号的场景
			}
		}

22:2D光效

1.将Tilemap的材质修改为Default-Diffuse(默认的漫反射类型),并新建同类型的材质给场景中包括Player的其他对象
2.在适当位置添加点光源,2D开发中点光源仍是一个3D物体,其在2D平面上的照射和其z轴位置有关,因此需要注意调整光源的z轴

23:代码优化

1.Animator条件瘦身
在动画切换中,可以将代码和状态机中“Idel”相关的进行删除(因为Idel是默认的动画效果)(未更正)
2.跳跃手感的优化
在官方文档中,有关物理计算如Rigbody相关的内容置于FixedUpdata中进行调用更为顺滑,而GetButton相关的方法则在Updata中调用更合适(都需要使用deltaTime或fixedUpdata平衡),因此需要调整代码内容
此中,跳跃可以修改为GetButton来进一步调整手感
3.Enemy死亡后Bug消除
Enemy死亡后仍会进行一定的物理运动,在Death中销毁Enemy前禁用其Collider来阻止这种运动
4.避免角色移动速度太快时Cherry计数器的重复触发(并添加收集动画)(未更正)
建立一个统御收集物的类用于管理所有收集物,将PlayerController中的相关逻辑迁移至其中
(问题:跳跃功能出现bug,会莫名其妙跳的很低,偶尔会跳的异常高,并时常陷入Tilemap)

24:视觉差Parallax

——使场景中不同层次的景观以不同的速度移动,来造成视觉上的偏差
1.整理场景中的各类对象
2.新建脚本Parallax控制视差

public Transform CameraTransform;

	public float MoveRate;
	private float startPointX,startPointY;

	public bool lockY = false;
    // Start is called before the first frame update
    void Start()
    {
		startPointX = transform.position.x;
		startPointY = transform.position.y;
    }

    // Update is called once per frame
    void Update()
    {
		if (lockY)
		{
			transform.position = new Vector2(startPointX + CameraTransform.position.x * MoveRate, transform.position.y);
		}
		else
		{
			transform.position = new Vector2(startPointX + CameraTransform.position.x * MoveRate, startPointY + CameraTransform.position.y * MoveRate);
		}
    }

(问题:部分场景的起始位置会发生很大改变)

25:主菜单MainMenu

1.新建场景Menu,通过UI制作主菜单
2.在Canvas中,新建两个Panel,其一作为主菜单背景,其二作为主菜单控制面板
3.完善主菜单页面显示
4.完善主菜单功能,编写场景载入脚本,并在按钮上添加相应点击事件(其中,退出事件在游戏测试中无法生效,需要完成导出后实现)

public void PlayGame()
	{
		SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex + 1);
	}

	public void QuitGame()
	{
		Application.Quit();
	}

	public void UIEnable()
	{
		GameObject.Find("Canvas/MainMenu/UI").SetActive(true);
	}

5.加入主菜单的动画效果

26:暂停菜单和AudioMixer

暂停菜单的基本构建

1.根据25中步骤完善菜单的基本页面构建,游戏画面中设置Pause按钮用于调出菜单,菜单中设置Continue按钮用于返回游戏
2.在脚本中添加按钮需要的方法(通过Time.timeScale控制游戏暂停)

AudioMixer

1.创建一个AudoMixer用于音量控制
2.将Player中的BGM的输出设置为该混音器
3.将暂停菜单的Slider的端值设置为混音器的声音端值
4.在代码中引用混音器,创建方法用于控制声音输出的大小
5.将混音器中的Volume一项设置为可被代码修改的(?)
6.在Slider中添加拖动事件,选择代码中设置的方法“bgmVolume”(?这里将会有上下两个方法供选择,应选择上面的一个)

(2019版本后,需要获取Slider的组件引用,然后进行值传递,而不会在事件选择时提供两个方法)

27:手机控制/触控测试/真机测试(略)

28:二段跳/单向平台

1.二段跳和跳跃手感的优化
(1)定义一个管理跳跃的bool变量,通过物理检测的方式判断跳跃是否可以进行
(2)定义一个变量用于可跳跃次数的管理,以此实现二段跳
(3)重新编写跳跃方法
在代码中,可以通过Alt+上下来调整光标所在行的行位置

在FixedUpdata中

isGround = Physics2D.OverlapCircle(GroundCheck.position, 0.2f, ground);

新的Jump方法

void NewJump()
	{
		if (isGround)
		{
			extraJump = 1;
		}
		if (Input.GetButtonDown("Jump") && extraJump>0)
		{
			playerRig.velocity = Vector2.up * jumpForce;//Vector.up等效于new Vector(0,1)
			extraJump--;
			playerAnim.SetBool("Jumping", true);
		}
		if (Input.GetButtonDown("Jump") && extraJump==0 && isGround)//?
		{
			playerRig.velocity = Vector2.up * jumpForce;
			playerAnim.SetBool("Jumping", true);
		}
	}

2.单向平台的实现
(1)新建管理单向平台的Tilemap
(2)为该Tilemap添加PlatformEffector2D组件,取消Use Collider Mask(勾选后可从单向平台上跳下),勾选TilemapCollier的Used By Effector(注意最后修改Layer)

29:音效管理SoundManager(利用static)(略)

30:游戏生成Build(略)

  • 9
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值