[OGRE]基础教程来六发:来谈一谈帧的监听

这可能是OGRE中最为重要的一个内容,因为它直接和用户的交互相互联系。

我们先新建一个项目,新建cpp文件,源码如下:

#include "ExampleApplication.h"
class TutorialFrameListener : public ExampleFrameListener
{
public:
	TutorialFrameListener(RenderWindow* win, Camera* cam, SceneManager *sceneMgr)
		: ExampleFrameListener(win, cam, false, false)
	{
	}
	
	bool frameStarted(const FrameEvent &evt)
	{
		return ExampleFrameListener::frameStarted(evt);
	}

protected:
	bool mMouseDown;       // Whether or not the left mouse button was down last frame
	Real mToggle;          // The time left until next toggle
	Real mRotate;          // The rotate constant
	Real mMove;            // The movement constant
	SceneManager *mSceneMgr;   // The current SceneManager
	SceneNode *mCamNode;   // The SceneNode the camera is currently attached to
};



class TutorialApplication : public ExampleApplication
{
public:
	TutorialApplication()
	{
	}
	
	~TutorialApplication() 
	{
	}

protected:
	void createCamera(void)
	{
	}

	void createScene(void)
	{
	}
	
	void createFrameListener(void)
	{
	}
};




#define WIN32_LEAN_AND_MEAN
#include "windows.h"
INT WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR strCmdLine, INT)
{
	TutorialApplication app;
	try {
		app.go();
	} catch(Exception& e) {
		MessageBox(NULL, e.getFullDescription().c_str(), "An exception has occurred!", MB_OK | MB_ICONERROR | MB_TASKMODAL);
	}
	return 0;
}


Ogre中我们可以注册一个类去接收消息当一帧被渲染到屏幕之前和之后。

FrameListener接口定义两个函数:

bool frameStarted(const FrameEvent& evt)

bool frameEnded(const FrameEvent& evt)

简单的说起来,Ogre的主循环(Root::startRendering)是类似这样的一个循环:

1.Root object调用frameStarted方法在所有已经注册的FrameListeners中。

2.Root object渲染一帧。

3.Root object调用frameEnded方法在所有已经注册的FrameListeners中。

这是OGRE的基本运行机制。

这个循环直到有任意一个FrameListenerframeStarted或者frameEnded函数返回false时终止。

这些函数返回值的主要意思是是否继续保持渲染

如果你返回false,这个程序将退出。

FrameEvent实体包含两个参数,但是只有timeSinceLastFrame在帧监听中是有用的。

这个变量代表frameStarted或者frameEnded最近被调用的时间。

注意在frameStarted方法中,FrameEvent::timeSinceLastFrame将包含frameStarted时间最近被调用的时间,而不是最近被激活的frameEnded方法。


注意,你不能决定FrameListener被调用的次序,如果你需要确定FrameListeners被调用的准确顺序,你应该只注册一个FrameListener,然后用适当的顺序来调用所有的物体。

这句话现在理解起来可能有点吃力,等到了以后便会有所了解。


找到TutorialApplication::createCamera方法添加如下代码:

	void createCamera(void)
	{
		// 在默认位置创建一个摄像机
       mCamera = mSceneMgr->createCamera("PlayerCam"); 
       mCamera->setNearClipDistance(5);
	}

Root类是要每帧更新的。我们首先要创建TutorialFrameListener的实例,然后把他注册到Root对象中。代码如下:

	void createFrameListener(void)
	{
		// 创建帧监听
		mFrameListener =new TutorialFrameListener(mWindow, mCamera, mSceneMgr);
		mRoot->addFrameListener(mFrameListener);
	}


mRootmFrameListener变量是在ExampleApplication类中定义的。

addFrameListener方法添加一个帧监听者,removeFrameListener方法移除帧监听者,也就是说FrameListener就不用在更新了。

注意add|removeFrameListener方法仅仅是得到一个指向FrameListener的指针(也就是说FrameListener并没有一个名字你可以移除她)。

这就意味着你需要对每一个FrameListener持有一个指针,让你随后可以移除他们。

这个 ExampleFrameListener TutorialFrameListener 是从它继承来的),

还提供了一个showDebugOverlay(bool)方法,用来告诉ExampleApplication是否要在左下角显示帧率的提示框。

用这种方式把它开启:

		mFrameListener->showDebugOverlay(true);

下面我们来做个小Demo,具体需求如下:

1.把一个忍者放到屏幕中,并在场景中加上点光源。

2.当你点击鼠标左键,灯光会打开或关闭。按住鼠标右键,则开启鼠标观察模式(也就是你用摄像机四处观望)。

3.在场景里放置场景节点,来让作用于不同视口的摄像机附在上面。按下12键,以选择从哪一个视口来观看场景。


然后就开始吧!

首先,找到TutorialApplication::createScene方法,我们先来对环境灯光稍加调整:

	void createScene(void)
	{
		mSceneMgr->setAmbientLight(ColourValue(0.25, 0.25, 0.25));
	}

现在把一个忍者加到屏幕中去:

	void createScene(void)
	{
		mSceneMgr->setAmbientLight(ColourValue(0.25, 0.25, 0.25));
		Entity *ent = mSceneMgr->createEntity("Ninja", "ninja.mesh");
		SceneNode *node = mSceneMgr->getRootSceneNode()->createChildSceneNode("NinjaNode");
		node->attachObject(ent);
	}

创建一个白色灯让后放到场景中,和忍者有一个相对小距离:

		Light *light = mSceneMgr->createLight("Light1");
		light->setType(Light::LT_POINT);
		light->setPosition(Vector3(250, 150, 250));
		light->setDiffuseColour(ColourValue::White);
		light->setSpecularColour(ColourValue::White);


现在需要创建场景节点供摄像机绑定:

		//创建场景节点供摄像机绑定: 
       node = mSceneMgr->getRootSceneNode()->createChildSceneNode("CamNode1", Vector3(-400, 200, 400));
       node->yaw(Degree(-45));
       node->attachObject(mCamera);
       // 创建另一个节点绑定摄像机
       node = mSceneMgr->getRootSceneNode()->createChildSceneNode("CamNode2", Vector3(0, 200, 400));

接下来来看一下 TutorialFrameListener的变量:

protected:
	bool mMouseDown;       // Whether or not the left mouse button was down last frame
	Real mToggle;          // The time left until next toggle
	Real mRotate;          // The rotate constant
	Real mMove;            // The movement constant
	SceneManager *mSceneMgr;   // The current SceneManager
	SceneNode *mCamNode;   // The SceneNode the camera is currently attached to

mSceneMgr拥有一个目前SceneManager的指针,mCamNode拥有目前SceneNode并且绑定着摄像机。mRotatemMove是旋转和移动的变量。

如果你想移动或旋转的更快或更慢,改变这两个变量的大小就可以了。

另外两个变量(mTogglemMouseDown)控制我们的输入。

我们将在本章中使用非缓冲(unbuffered)鼠标和键盘输入(缓冲(buffered)输入将是下一章的议题。

这意味着我们将在帧监听器查询鼠标和键盘状态时调用方法。


接下来考虑一个问题。

如果知道一个键正被按下,我们会去处理它,但到了下一帧怎么办?

我们还会看见它被按下,然后再重复地做操作吗?在一些情况(比如用方向键来移动),我们是这样做的。

然而,我们想通过“T”键来控制灯的开关时,在第一帧里按下T键,拨动了灯的开关,灯关了,但在下一帧T键仍然被按着,所以灯又打开了如此反复着,直到按键被释放。

这显然不是我们想要的结果。为了解决这个问题,我们可以采用两种方案。


mMouseDown用来追踪鼠标在上一帧里是否也被按下(所以如果mMouseDown为true,我们可以设置在鼠标释放之前不重复执行)。

mToggle指定了直到我们可以执行下一个操作的时间。即,当一个按键被按下去了,在mToggle指明的这段时间里不允许有其它动作发生。

我们回头看一下我们的类,要调用ExampleFrameListener的构造器,首先我们要继承ExampleFrameListener的constructor:

TutorialFrameListener(RenderWindow* win, Camera* cam, SceneManager *sceneMgr)
		: ExampleFrameListener(win, cam, false, false)
	{
	}


一个需要注意的是,第三和第四个参数要置为false。第三个参数指明是否使用带缓冲的键盘输入,第四个参数指明是否使用带缓冲的鼠标输入,本次我们都不使用。

TutorialFrameListener的构造器里,我们把所有的变量设置为默认值:

	TutorialFrameListener(RenderWindow* win, Camera* cam, SceneManager *sceneMgr)
		: ExampleFrameListener(win, cam, false, false)
	{
		// 键盘和鼠标状态追踪
		mMouseDown = false;
		mToggle = 0.0;
		mCamNode = cam->getParentSceneNode();
		mSceneMgr = sceneMgr;
		// 设置旋转和移动速度
		mRotate = 0.13;
		mMove = 250;
	}

现在mCamNode是摄像机的父节点。


接下来是frameStarted方法,原有代码如下:

    bool frameStarted(const FrameEvent &evt)
    {
        return ExampleFrameListener::frameStarted(evt);
    }


这块代码非常重要。ExampleFrameListener::frameStarted方法定义了许多行为(像所有的按键绑定、所有的摄像机移动等)。

清空TutorialFrameListener::frameStarted方法,我们要在里面写入自己的内容:

	bool frameStarted(const FrameEvent &evt)
	{

	}

我们使用无缓冲输入时,要做的第一件事情就是获取当前鼠标键盘的状态。

为了达到目的,我们调用MouseKeyboard对象的capture方法。

示例框架已经帮我们创建了这些对象,分别在mMousemKeyboard变量里。

将以下代码添加到目前还是空的TutorialFrameListener::frameStarted成员方法里:

	bool frameStarted(const FrameEvent &evt)
	{
		mMouse->capture();
		mKeyboard->capture();
	}

接下来,我们要保证当Escape键被按下时程序退出。

我们通过调用InputReaderisKeyDown方法并指定一个KeyCode,来检查一个按钮是否被按下。

Escape键被按下时,我们只要返回false便可跳出当前程序:

	bool frameStarted(const FrameEvent &evt)
	{
		mMouse->capture();
		mKeyboard->capture();

		if(mKeyboard->isKeyDown(OIS::KC_ESCAPE))
			return false;

	}

但是前面我们讲过,为了能继续渲染,frameStarted方法必须返回一个正的布尔值。我们将在方法最后加上这么一行:


	bool frameStarted(const FrameEvent &evt)
	{
		mMouse->capture();
		mKeyboard->capture();

		if(mKeyboard->isKeyDown(OIS::KC_ESCAPE))
			return false;

		return true;
	}

我们使用FrameListener来做的第一个事情就是,让鼠标左键来控制灯的开关。

通过调用InputReadergetMouseButton方法,并传入我们要查询的按钮,我们能够检查这个按钮是否被按下。

通常0是鼠标左键,1是右键,2是中键。如果按键不能很好的工作,可尝试这样设置:

		bool currMouse = mMouse->getMouseState().buttonDown(OIS::MB_Left);
	

如果鼠标被按下,currMouse变量设为true

现在我们拨动灯开关,依赖于currMouse是否为true,同时在上一帧中鼠标没有被按下。因为我们只想每次按下鼠标时,只让灯开关被拨动一次

同时注意Light类里的setVisible方法决定了这个对象实际上是否发光:

		//如果左键按下并且上一帧没有按下键
		if (currMouse && ! mMouseDown)
		{
			Light *light = mSceneMgr->getLight("Light1");
			light->setVisible(! light->isVisible());
		} 


现在,我们把mMouseDown的值设置成与currMouse变量相同。下一帧里,它会告诉我们上次是否按下了鼠标按键:

       mMouseDown = currMouse;


编译运行程序,现在可以左击鼠标控制灯的开关啦~

开灯:


关灯:


总体感觉还是不错的啦。



这样做的缺点就是你要为每一个被绑定动作的按键设置一个布尔变量。

一种能避免这种情况的方法是,跟踪上一次点击任何按键的时刻,然后只允许经过了一段时间之后才触发事件。

我们用mToggle变量来跟踪。如果mToggle大于0,我们就不执行任何动作,如果mToggle小于0,我们才执行动作。

我们将用这个方法来进行下面两个按键的绑定。

首先我们要做的是拿mToggle变量减去从上一帧到现在所经历的时间。

		//完成接下来两个按键的绑定
		mToggle -= evt.timeSinceLastFrame;


现在我们更新了mToggle,我们能操作它了。

接下来我们将“1”键绑定为摄像机附在第一个场景节点上。在这之前,我们检查mToggle变量以保证它是小于0的:

		if ((mToggle < 0.0f ) && mKeyboard->isKeyDown(OIS::KC_1))
		{
			//需要设置mToggle变量,使得在下一个动作发生时至少相隔一秒钟: 
			mToggle = 0.5f;
			//将摄像机从当前附属的节点取下来,然后让mCamNode变量含有"CamNode1"节点,再将摄像机放上去。 
			mCamera->getParentSceneNode()->detachObject(mCamera);
			mCamNode = mSceneMgr->getSceneNode("CamNode1");
			mCamNode->attachObject(mCamera);
		}


当按下2键时,对于CamNode2我们也是这么干的。除了把1换成2其它代码都是相同的:

		if ((mToggle < 0.0f) && mKeyboard->isKeyDown(OIS::KC_2))
		{
			mToggle = 0.5f;
			mCamera->getParentSceneNode()->detachObject(mCamera);
			mCamNode = mSceneMgr->getSceneNode("CamNode2");
			mCamNode->attachObject(mCamera);
		}

编译并运行。我们通过按12键,能够转换摄像机的视口。



我们的下一个目标就是,当用户按下方向键或WASD键时,进行mCamNode的平移。

与上面的代码不同,我们不必追踪上一次移动摄像机的时刻,因为在每一帧按键被按下时我们都要进行平移。

这样一来我们的代码会相对简单,首先我们来创建一个Vector3用于保存平移的方位:

Vector3 transVector = Vector3::ZERO;

好的,当按下W键或者上箭头时,我们希望前后移动(也就是Z轴,记住Z轴负的方向是指向屏幕里面的):

		if (mKeyboard->isKeyDown(OIS::KC_UP) || mKeyboard->isKeyDown(OIS::KC_W))
			transVector.z -= mMove;

对于其他按钮也是这样

		if (mKeyboard->isKeyDown(OIS::KC_UP) || mKeyboard->isKeyDown(OIS::KC_W))
			transVector.z -= mMove;
		if (mKeyboard->isKeyDown(OIS::KC_DOWN) || mKeyboard->isKeyDown(OIS::KC_S))
			transVector.z += mMove;
		if (mKeyboard->isKeyDown(OIS::KC_LEFT) || mKeyboard->isKeyDown(OIS::KC_A))
			transVector.x -= mMove;
		if (mKeyboard->isKeyDown(OIS::KC_RIGHT) || mKeyboard->isKeyDown(OIS::KC_D))
			transVector.x += mMove;
		if (mKeyboard->isKeyDown(OIS::KC_PGUP) || mKeyboard->isKeyDown(OIS::KC_Q))
			transVector.y += mMove;
		if (mKeyboard->isKeyDown(OIS::KC_PGDOWN) || mKeyboard->isKeyDown(OIS::KC_E))
			transVector.y -= mMove;

好了,我们的transVector变量保存了作用于摄像机场景节点的平移。

这样做的第一个缺点是如果你旋转了场景节点,再作平移的时候我们的xyz坐标就不对了。

为了修正它,我们必须把作用在场景节点的旋转,也作用于我们的平移。这实际上比听起来更简单。


为了表示旋转,Ogre不是像一些图形引擎那样使用变换矩阵,而是使用四元组。

四元组的数学意义是一个较难理解的四维线性代数。你不必去了解它的数字知识,了解如何使用就行了。

非常简单,用四元组旋转一个向量只要将它们两个相乘即可。现在,我们希望将所有作用于场景节点的旋转,也作用在平移向量上。

通过调用SceneNode::getOrientation(),我们能得到这些旋转的四元组表示,然后用乘法让它作用在平移节点。


还有一个缺陷要引起注意的是,我们必须根据从上一帧到现在的时间,来对平移的大小进行缩放。

否则,你的移动速度取决于应用程序的帧率。这确实不是我们想要的。

这里有一个函数供我们来调用,可以避免平移摄像机时带来的问题

		mCamNode->translate(transVector*evt.timeSinceLastFrame,Node::TS_LOCAL);


当你对一个节点进行平移,或者是绕某个轴进行旋转,你都能指定使用哪一个“变换空间”来移动它。

一般你移动一个对象时,不需要指定这个参数。它默认是TS_PARENT,意思是这个对象使用的是父节点所在的变换空间。

在这里,父节点是场景的根节点。当我们按下W键时(向前移动),我们走向Z轴负的方向。

如果我们不在代码前面指明TS_LOCAL,摄像机都会向全局负Z轴移动。然而,既然我们希望按下W键时摄像机往前走,我们就需要它往节点所面朝的方向走。所以,我们使用“本地”变换空间。

好了,我们有了键盘控制的移动。


我们还想实现一个鼠标效果,来控制我们观察的方向,但这只有在鼠标右键被按住的情况下。为了达到目的,我们首先要检查右键是否被按下去:

		if (mMouse->getMouseState().buttonDown(OIS::MB_Right))
		{
			//从上一帧开始鼠标移动的距离来控制摄像机的俯仰偏斜
			//为了达到目的,我们取得X、Y的相对改变,传到pitch和yaw函数里去: 
			mCamNode->yaw(Degree(-mRotate * mMouse->getMouseState().X.rel), Node::TS_WORLD);
			mCamNode->pitch(Degree(-mRotate * mMouse->getMouseState().Y.rel), Node::TS_LOCAL);
		}



注意,我们使用TS_WORLD向量坐标来进行偏斜(如果不指明的话,旋转函数总是用TS_LOCAL作为默认值)。我们要保证物体随意的俯仰不影响它的偏斜,我们希望总是绕着固定的轴进行偏斜转动。


完整的项目源码:

#include "ExampleApplication.h"

class TutorialFrameListener : public ExampleFrameListener
{
public:
    TutorialFrameListener( RenderWindow* win, Camera* cam, SceneManager *sceneMgr )
        : ExampleFrameListener(win, cam, false, false)
    {
        // key and mouse state tracking
        mMouseDown = false;
        mToggle = 0.0;

        // Populate the camera and scene manager containers
        mCamNode = cam->getParentSceneNode();
        mSceneMgr = sceneMgr;

        // set the rotation and move speed
        mRotate = 0.13;
        mMove = 250;
    }

    // Overriding the default processUnbufferedKeyInput so the key updates we define
    // later on work as intended.
    bool processUnbufferedKeyInput(const FrameEvent& evt)
    {
        return true;
    }

    // Overriding the default processUnbufferedMouseInput so the Mouse updates we define
    // later on work as intended. 
    bool processUnbufferedMouseInput(const FrameEvent& evt)
    {
        return true;
    }

    bool frameStarted(const FrameEvent &evt)
    {
        mMouse->capture();
        mKeyboard->capture();

        if(mKeyboard->isKeyDown(OIS::KC_ESCAPE))
            return false;

        bool currMouse = mMouse->getMouseState().buttonDown(OIS::MB_Left);

        if (currMouse && ! mMouseDown)
        {
            Light *light = mSceneMgr->getLight("Light1");
            light->setVisible(! light->isVisible());
        } // if

        mMouseDown = currMouse;
        mToggle -= evt.timeSinceLastFrame;

        if ((mToggle < 0.0f ) && mKeyboard->isKeyDown(OIS::KC_1))
        {
            mToggle = 0.5f;
            mCamera->getParentSceneNode()->detachObject(mCamera);
            mCamNode = mSceneMgr->getSceneNode("CamNode1");
            mCamNode->attachObject(mCamera);
        }

        else if ((mToggle < 0.0f) && mKeyboard->isKeyDown(OIS::KC_2))
        {
            mToggle = 0.5f;
            mCamera->getParentSceneNode()->detachObject(mCamera);
            mCamNode = mSceneMgr->getSceneNode("CamNode2");
            mCamNode->attachObject(mCamera);
        }

        Vector3 transVector = Vector3::ZERO;

        if (mKeyboard->isKeyDown(OIS::KC_UP) || mKeyboard->isKeyDown(OIS::KC_W))
            transVector.z -= mMove;
        if (mKeyboard->isKeyDown(OIS::KC_DOWN) || mKeyboard->isKeyDown(OIS::KC_S))
            transVector.z += mMove;

        if (mKeyboard->isKeyDown(OIS::KC_LEFT) || mKeyboard->isKeyDown(OIS::KC_A))
            transVector.x -= mMove;
        if (mKeyboard->isKeyDown(OIS::KC_RIGHT) || mKeyboard->isKeyDown(OIS::KC_D))
            transVector.x += mMove;

        if (mKeyboard->isKeyDown(OIS::KC_PGUP) || mKeyboard->isKeyDown(OIS::KC_Q))
            transVector.y += mMove;
        if (mKeyboard->isKeyDown(OIS::KC_PGDOWN) || mKeyboard->isKeyDown(OIS::KC_E))
            transVector.y -= mMove;

        mCamNode->translate(transVector * evt.timeSinceLastFrame, Node::TS_LOCAL);

        // Do not add this to the program
        mCamNode->translate(mCamNode->getOrientation() * transVector * evt.timeSinceLastFrame);

        if (mMouse->getMouseState().buttonDown(OIS::MB_Right))
        {
            mCamNode->yaw(Degree(-mRotate * mMouse->getMouseState().X.rel), Node::TS_WORLD);
            mCamNode->pitch(Degree(-mRotate * mMouse->getMouseState().Y.rel), Node::TS_LOCAL);
        }

        return true;
    }
protected:
    bool mMouseDown;       // Whether or not the left mouse button was down last frame
    Real mToggle;          // The time left until next toggle
    Real mRotate;          // The rotate constant
    Real mMove;            // The movement constant
    SceneManager *mSceneMgr;   // The current SceneManager
    SceneNode *mCamNode;   // The SceneNode the camera is currently attached to
};

class TutorialApplication : public ExampleApplication
{
public:
    TutorialApplication()
    {
    }

    ~TutorialApplication() 
    {
    }
protected:
    void createCamera(void)
    {
        // create camera, but leave at default position
        mCamera = mSceneMgr->createCamera("PlayerCam"); 
        mCamera->setNearClipDistance(5);
    }

    void createScene(void)
    {
        mSceneMgr->setAmbientLight(ColourValue(0.25, 0.25, 0.25));

        Entity *ent = mSceneMgr->createEntity("Ninja", "ninja.mesh");
        SceneNode *node = mSceneMgr->getRootSceneNode()->createChildSceneNode("NinjaNode");
        node->attachObject(ent);

        Light *light = mSceneMgr->createLight("Light1");
        light->setType(Light::LT_POINT);
        light->setPosition(Vector3(250, 150, 250));
        light->setDiffuseColour(ColourValue::White);
        light->setSpecularColour(ColourValue::White);

        // Create the scene node
        node = mSceneMgr->getRootSceneNode()->createChildSceneNode("CamNode1", Vector3(-400, 200, 400));
        node->yaw(Degree(-45));
        node->attachObject(mCamera);

        // create the second camera node
        node = mSceneMgr->getRootSceneNode()->createChildSceneNode("CamNode2", Vector3(0, 200, 400));
    }

    void createFrameListener(void)
    {
        // Create the FrameListener
        mFrameListener = new TutorialFrameListener(mWindow, mCamera, mSceneMgr);
        mRoot->addFrameListener(mFrameListener);

        // Show the frame stats overlay
        mFrameListener->showDebugOverlay(true);
    }
};

#if OGRE_PLATFORM == PLATFORM_WIN32 || OGRE_PLATFORM == OGRE_PLATFORM_WIN32
#define WIN32_LEAN_AND_MEAN
#include "windows.h"

INT WINAPI WinMain( HINSTANCE hInst, HINSTANCE, LPSTR strCmdLine, INT )
#else
int main(int argc, char **argv)
#endif
{
    // Create application object
    TutorialApplication app;

    try {
        app.go();
    } catch( Exception& e ) {
#if OGRE_PLATFORM == PLATFORM_WIN32 || OGRE_PLATFORM == OGRE_PLATFORM_WIN32
        MessageBox( NULL, e.getFullDescription().c_str(), "An exception has occurred!", MB_OK | MB_ICONERROR | MB_TASKMODAL);
#else
        fprintf(stderr, "An exception has occurred: %s\n",
            e.getFullDescription().c_str());
#endif
    }

    return 0;
}


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值