Beginning Game Development: Part IV - DirectInput

This is Part 4 of an introductory series on game programming using the Microsoft .NET Framework and managed DirectX 9.0. This article covers the input device portion of DirectX, called DirectInput.
3Leaf Development

Difficulty: Intermediate
Time Required: 3-6 hours
Cost: Free
Software: Visual Basic or Visual C# Express Editions, DirectX SDK
Hardware: None
Download:
Beginning Game Development Series
  1. Beginning Game Development Part 1 - Introduction
  2. Beginning Game Development Part II - Introduction to DirectX
  3. Beginning Game Development: Part III - DirectX II
  4. Beginning Game Development: Part IV - DirectInput
  5. Beginning Game Development: Part V - Adding Units
  6. Beginning Game Development: Part VI - Lights, Materials and Terrain
  7. Beginning Game Development: Part VII –Terrain and Collision Detection
  8. Beginning Game Development: Part VIII - DirectSound

 

Welcome to the fourth article on beginning game development. In this article we are going to cover the input device portion of DirectX, called DirectInput. Using DirectInput you can control joysticks, a mouse or the keyboard.

Before we start I need to cover a couple of items that were brought to my attention via feedback from the readers (thank you everyone for taking the time to do this) and changes not directly related to the items covered in this article.

Code Cleanup

To make adding the code for this section easier and to make the code more reusable I have moved some of the code from the GameEngine class into separate classes such as Camera. Encapsulating the camera functionality in a separate object makes the code easer to change and to read. The following changes have already been integrated into the code for this article.

  1. I moved the FrameworkTimer class out of the DirectX support code into a class called HiResTimer and removed the using statements for Microsoft.Samples.DirectX.UtilityToolkit. For the VB version I converted the FrameworkTimer class into VB and removed the reference to the DirectX Sample Framework library.
  2. I refactored the device creation code in the constructor of the GameEngine class into the ConfigureDevice method.
  3. I refactored all code that deals with the device in the OnPaint method of the GameEngine class into the Render method.
  4. I refactored the code that sets up the camera in the OnPaint method of the GameEngine class into the Camera class.
  5. I removed the CreateCrossHairVertexArrayTop, CreateCrossHairVertexArrayBottom, CreateTestTriangle, and CreateTestCube methods from the GameEngine Class.
  6. Removed the System.Collections.Generic, System.ComponentModel, System.Data and System.Text using statements from the GameEngine class.
  7. I removed all of the code used for experimenting with the camera settings.

In addition to these general housekeeping changes I added some code that I needed to make the DirectInput portion more interesting.

Skybox

The infinite 3D space we have created has one problem: it's infinite. We have no distinguishing terrain features to orient us and, as such, have no idea in which direction we are facing. We could create a number of complex 3D objects to draw a realistic terrain, but this is very slow and we really do not need 'real' terrain, but only to create the look of real terrain. To create this illusion of seeing a horizon and terrain in the distance, we make use of a technique called the skybox. A skybox creates the effect you get when placing your head into a cardboard box, the inside of which is painted with a landscape: Regardless where you are and where you look you see the inside of the box and nothing else.

In our game we create a cube and display textures (or pictures for us that prefer to use more normal definitions) on the inside walls of the cube. The top would be textured like the sky, the bottom like the ground and the remaining four sides have a texture applied to them that shows a horizon and terrain. The pictures for the four sides are designed in such a manner that they match perfectly at the edges and produce a seamless landscape. These four sides could then represent cardinal directions such as east, west, north and south (if your game is set on Earth).

To achieve the correct effect, we need to ensure that all objects are drawn on top of the skybox. This is done by disabling Z-buffering before drawing the skybox and enabling it after we are done.

Visual C#

_device.RenderState.ZBufferWriteEnable = false; 
// draw the skybox here
_device.RenderState.ZBufferWriteEnable = true;

Visual Basic

 
_device.RenderState.ZBufferWriteEnable = False
'draw the skybox here
_device.RenderState.ZBufferWriteEnable = True

The next thing we need to ensure is that the player can never actually get close to or beyond the skybox (otherwise they would notice the illusion). We accomplish this by ensuring that the viewpoint is always in the center of the skybox.

First we assign an Identity matrix to the world matrix. Since the vertices of the skybox are in model space (offset around zero), this ensures that they are not translated into world space. Then we set the use the View matrix of the camera to set the View matrix of the skybox.

Visual C#

Matrix worldMatrix = Matrix.Identity;
Matrix viewMatrix = cam.View;

viewMatrix.M41 = 0.0f;
viewMatrix.M42 = 0.0f;
viewMatrix.M43 = 0.0f;

_device.Transform.View = viewMatrix;
_device.Transform.World = worldMatrix;

Visual Basic

Dim worldMatrix As Matrix = Matrix.Identity
Dim viewMatrix As Matrix = cam.View
viewMatrix.M41 = 0.0F
viewMatrix.M42 = 0.0F
viewMatrix.M43 = 0.0F
_device.Transform.View = viewMatrix
_device.Transform.World = worldMatrix

Drawing the actual skybox uses the techniques we have discussed in the previous articles and I am not going to cover them in detail. The basic steps are as follows.

  1. Define a PositionNormalTextured vertex array to hold the data for the four corners of each cube face and texture information.
  2. Load the texture for each cube face from a file.
  3. Setup the Vertex buffer for each face.
  4. On each Render loop readjust the skybox.
  5. On each Render loop disable Z-buffering.
  6. On each Render loop use the Camera object passed in to determine the direction the camera is facing and draw the appropriate face.
  7. On each Render loop turn Z-buffering back on.

If you look at the RenderFace method of the SkyBox class, you will notice that it is identical to the code we used to draw the triangle and cube in the last article.

Visual C#

_device.SetStreamSource ( 0, faceVertexBuffer, 0 );
_device.VertexFormat = CustomVertex.PositionNormalTextured.Format;
_device.SetTexture ( 0, faceTexture );
_device.DrawPrimitives ( PrimitiveType.TriangleStrip, 0, 2 );

Visual Basic

_device.SetStreamSource(0, faceVertexBuffer, 0)
_device.VertexFormat = CustomVertex.PositionNormalTextured.Format
_device.SetTexture(0, faceTexture)
_device.DrawPrimitives(PrimitiveType.TriangleStrip, 0, 2)
Camera Class

The other major refactoring I did for this article is to move all the camera-related code into the Camera class. The fixed values such as FoV and aspect ratio used in the Perspective matrix are set as internal properties with the appropriate default values. I have also added methods that allow the camera to be moved left or right and up or down, and a method that moves the position of the camera. Why I added these methods will become clear once we integrate user input later in the article.

Now that all of the graphics for the game except the enemy units are done, let's get the tank moving so we can explore our new world.

Controlling Input

Controlling input devices is not nearly as cool as graphics manipulation or artificial intelligence, but without it you couldn't have a game. Reacting to user input allows the user to move around in and manipulate the 3D world we have created.

The DirectInput API allows you to control the mouse, keyboard, joystick, game-pad, or force feedback device. Some games also provide the ability for voice input to control the game, but that is beyond the scope of our game.

Adding DirectInput

The first step when adding input support to an application is to reference the Microsoft.DirectX.DirectInput.dll assembly. The next step is to add the using statement to each class in which you plan to use the DirectInput classes (unless you really like typing).

Detecting Devices

A good API provides a common way to access similar devices. This is true in DirectX where physical devices such as video cards, sound cards, and input devices are abstracted into the Device class. This class shields us as developers from knowing any of the hardware-specific details. In the DirectInput namespace a Device represents any of the potential input devices.

Another familiar construct is the Manager class. We used the Manager class from the Direct3D namespace to get information about the adapters and to retrieve device capabilities for each adapter. In DirectInput the process is very similar.

To get a list of specific devices such as all keyboards connected to the computer, you use the GetDevices method of the Manager class and pass in the DeviceClass or DeviceType and one of the EnumDevicesFlags enumeration values to filter the list further. A computer can have many input devices but as a minimum it should have a keyboard and mouse.

Note: Most of the time you are going to use EnumDevicesFlags.AttachedOnly to get only the attached devices of a particular type.

To make things a little more obscure, a mouse is considered a Pointer type in the DeviceClass enumeration. You can use any combination of to retrieve a custom list of input devices.

Each call to GetDevices returns a DeviceList class. This DeviceList in turn contains a DeviceInstance structure for each device. The DeviceInstance structure contains the information about each device, the most important of which is the InstanceGuid. This is a unique identifier for each device on our system that we need to know so we can communicate with that device.

Note: Just because a device is in the DeviceList does not mean that it is actually attached. You can use the InstanceGuid and the GetDeviceAttached method to determine if the device is attached.

The Manager class exposes a Devices property that contains a DeviceList.

Note that the DeviceInstance structure is not actually a device; there is nothing we can do with it in terms of connecting to it. The only use it has is to provide a GUID which we can then use to actually connect to or acquire that device.

Connecting to a Keyboard

The first device we are going to connect to is the keyboard. This is probably the most common device used to interact with games. Most modern games enable the user to control the game using the keyboard and the mouse, as there are too many actions to perform to use just one of these devices. Depending on your audience, you need to choose the primary input device and decide whether to offer configurable choices for secondary devices. You also need to remember that some computers, such as slate-mode Tablet PCs, have no keyboard, so offering alternate input means broadens your potential audience.

Regardless of what input options you offer, the steps to set up any input device are the same.

  1. Instantiate a Device class passing the type of device.
  2. Set the cooperation level of the device.
  3. Set the format of the data returned for the device.
  4. Acquire the device.
  5. Poll the device.
  6. Read the state data to determine what actions the user did.

Using good OO practices, we first create a keyboard class that will encapsulate all keyboard-specific functionality and provide a single access point for the rest of the game to any keyboard functionality.

The first step is to create a Device object for the keyboard. We do this using the Keyboard value of the SystemGuid enumeration. This lets us connect to the default keyboard. Using the default keyboard is safer than enumerating the devices and picking a specific one, because you don't know beforehand what devices all your users might have.

Visual C#

_device = new Device ( SystemGuid.Keyboard );

Visual Basic

_device = New Device(SystemGuid.Keyboard)

The next step is to set the Cooperative level of the device. The values of the CooperativeLevelFlags enumeration determine how much control we chose to take over the device and how much control we leave to other applications.

The CooperativeLevelFlags enumeration contains five values.

  • Exclusive
  • NonExclusive
  • Foreground
  • Background
  • NoWindowsKey

The Background and Foreground values are mutually exclusive, as are the Exclusive and NonExclusive values. The Foreground option states that we only want data from the device if the window we passed into the SetCooperativeLevel method has the focus. The Background option simply means that we always want data from the device. Exclusive means that we want priority for control of the device while NonExclusive means we don't. Even when using the Exclusive option it is still possible to lose the device, which is why we add the reacquire logic to the Poll method to ensure we can get the device back when we want to read its state. The NoWindowsKey option can be combined with any of the other settings and specifies that we want to ignore the Windows logo key on the keyboard. This setting is important when running in full screen mode, because the Windows key causes the application to loose focus.

For BattleTank 2005 we combine the Background and NonExclusive values to allow other applications maximum control over this device.

We also need to pass in a reference to the window we are using so DirectX knows which window's input we are interested in.

Visual C#

_device.SetCooperativeLevel ( form, CooperativeLevelFlags.Background | 
CooperativeLevelFlags.NonExclusive );

Visual Basic

 
 
_device.SetCooperativeLevel(form, CooperativeLevelFlags.Background Or _ 
CooperativeLevelFlags.NonExclusive)

The next step is to determine the data format we expect this device to return. We cover the contents of this data structure a little later in the article.

Visual C#

_device.SetDataFormat ( DeviceDataFormat.Keyboard ); 

Visual Basic

_device.SetDataFormat(DeviceDataFormat.Keyboard)

The last step is to actually acquire the device. You can think of this like opening a communications channel to the device. Since there are lots of things that could go wrong at this point, it is best to wrap the Acquire call into a try/catch block.

Visual C#

try
{
_device.Acquire ( );
}
catch ( DirectXException ex )
{
Console.WriteLine ( ex.Message );
}

Visual Basic

Try
_device.Acquire()
Catch ex As DirectXException
Console.WriteLine(ex.Message)
End Try

After acquiring the device we can read its state, which is a byte array. The first 256 values of this array hold the state of the keyboard; the next 8, the state of the mouse; and the last 32, the state of the joystick. Setting the device data format simply restricts the returned array to the set of values for the specified device. The KeyboardState structure, for example, only contains the first 256 bytes of the raw state.

Visual C#

private KeyboardState _state; 

Visual Basic

Private _state As KeyboardState

To get this state from the device we first call the Poll method. This method communicates with the actual hardware. Then we copy the state into a local data structure (the _state variable).

Since we are going to do this on every frame, I placed this particular sequence of calls into their own public method called Poll. In the game loop we then call _keyboard.Poll() and use the state data structure to see what keys were pressed. This is where we deal with a device which may have been acquired by another application by attempting to reacquire it.

Visual C#

try
{
_device.Poll ( );
_state = _device.GetCurrentKeyboardState ( );
}
catch ( NotAcquiredException )
{
// try to reqcquire the device
try
{
_device.Acquire ( );
}
catch ( InputException iex )
{
Console.WriteLine ( iex.Message );
// could not get the device
}
}
catch ( InputException ex2 )
{
Console.WriteLine ( ex2.Message );
}

Visual Basic

Try
_device.Poll()
_state = _device.GetCurrentKeyboardState
Catch generatedExceptionVariable0 As NotAcquiredException
Try
_device.Acquire()
Catch iex As InputException
Console.WriteLine(iex.Message)
End Try
Catch ex2 As InputException
Console.WriteLine(ex2.Message)
End Try

The only step remaining is to examine the state data returned to see what actions the user performed.

In addition to managing the device state each time we poll the device, we want to make sure to release it when we are finished using it. We accomplish this by calling the Unacquire method of the device in the Dispose method.

Visual C#

if ( _device != null )
_device.Unacquire ( );

Visual Basic

If Not (_device Is Nothing) Then
_device.Unacquire()
End If
Connecting to a Mouse

Connecting to a mouse is almost identical to connecting to a keyboard. The differences are the SystemGuid passed to the Device constructor, the DeviceDataFormat, the data structure used to store the state information, and the method call used to retrieve the device state.

Visual C#

_device = new Device ( SystemGuid.Mouse );
_device.SetDataFormat ( DeviceDataFormat.Mouse );
private MouseState _state;
public void Poll ( )
{
_device.Poll ( );
_state = _device.CurrentMouseState;
}

Visual Basic

_device = New Device(SystemGuid.Mouse)
_device.SetDataFormat(DeviceDataFormat.Mouse)
Private _state As MouseState
Public Sub Poll()
_device.Poll()
_state = _device.CurrentMouseState
End Sub

Using the mouse also provides the ability to define how the X, Y and Z (No, don't pick up your mouse, the Z value is the value for the mouse scroll wheel on certain mice) values are reported on each poll. Setting the AxisModeAbsolute value to true returns the coordinates in screen coordinates, while setting this value to false returns the change in pixels from the previous poll. We set this value as soon as we have acquired the device.

Visual C#

_device.Acquire ( );
_device.Properties.AxisModeAbsolute = false;

Visual Basic

_device.Acquire()
_device.Properties.AxisModeAbsolute = False

Using the false option is valuable if you want to determine the speed in which the user moved the mouse using the time elapsed between polls and the distance traveled in that time frame while the first option is more valuable if the user is selecting something from the screen.

Connecting to a Joystick

Connecting to a joystick follows the same general steps as for the keyboard and mouse, but, since there is no such thing as a default joystick, no corresponding SystemGuid value exists. Instead, we need to use the GetDevices method of the Manager class, passing in GameControl type for the DeviceClass and AttachedOnly for the EnumDevicesFlags to enumerate any attached joysticks. You would probably want to present some type of UI to the user to let them choose from a list of devices found or you could just use the first device as the code snippet does.

Visual C#

DeviceList gameControllerList = 
Manager.GetDevices(
DeviceClass.GameControl,
EnumDevicesFlags.AttachedOnly);

if (gameControllerList.Count > 0)
{
foreach (DeviceInstance deviceInstance in gameControllerList)
{
_device = new Device(deviceInstance.InstanceGuid);
_device.SetCooperativeLevel(form,
CooperativeLevelFlags.Background |
CooperativeLevelFlags.NonExclusive);
break;
}
}

Visual Basic

Dim gameControllerList As DeviceList
gameControllerList = Manager.GetDevices(DeviceClass.GameControl, _
EnumDevicesFlags.AttachedOnly)
If (gameControllerList.Count > 0) Then
Dim deviceInstance As DeviceInstance
For Each deviceInstance In gameControllerList
_device = New Device(deviceInstance.InstanceGuid)
_device.SetCooperativeLevel(Form, CooperativeLevelFlags.Background
Or CooperativeLevelFlags.NonExclusive)
Break()
Next
End If

The next step is to specify a new DeviceDataFormat and new type for the _state data structure. Finally you need to retrieve the joystick state from the device inside the Poll method.

Visual C#

_device.SetDataFormat ( DeviceDataFormat.Joystick );
private JoystickState _state;
public void Poll ( )
{
_device.Poll ( );
_state = _device.CurrentJoystickState;
}

Visual Basic

_device.SetDataFormat(DeviceDataFormat. Joystick)
Private _state As JoystickState
Public Sub Poll()
_device.Poll()
_state = _device.CurrentJoystickState
End Sub

In BattleTank 2005 we are not going to use a joystick, but feel free to add this capability on your own if you have a joystick. If you own a force-feedback device you might also want to experiment with using it in the game.

Now that we have access to the various forms of input devices, and know how to retrieve their state we need to know how to read the various state data structures to determine user actions.

Determining User Actions

Each of the data structures for the state are byte arrays. The KeyboardState is a byte array 256 bytes long. Each byte represents the state of a key on the keyboard and the position in the array matches the value of the Key enumeration. The indexer for the KeyboardState returns a Boolean value for each index checked. For example, to see if the user pressed the Escape key you simply test that the most significant bit is set at that index location (position 1 for Escape) in the array. If this expression returns true then the user pressed the Escape key, otherwise it returns false.

Each poll method may return values for multiple keys such as when the user pressed certain key combinations (Ctrl+Alt+Delete is a common one for me when I program). DirectInput supports a maximum of five key values, but you should be nice to your users and stick to single key actions if possible.

Visual C#

_state[Key.Escape] 

Visual Basic

 

 
_keyboard.State(Key.Escape)

The MouseState is a structure that provides three public properties for the X, Y and Z values, as well an eight byte long array for the state of the mouse buttons that is retrieved by calling the GetMouseButtons method.

Checking for the button information is the same for the mouse buttons as it was for the keyboard keys. You simply check the most significant bit at each position to determine if the corresponding button was pressed.

Visual C#

if ( 0 != mouseButtons[0])
Console.WriteLine ( "Primary Button pressed" );

Visual Basic

If Not (0 = _mouse.MouseButtons(0)) Then
Console.WriteLine("Fire!")
End If

The 0 position represents the primary button which could be the left or right mouse button depending on how the user configured the mouse.

Depending on the AxisModeAbsolute setting, the X, Y and Z values return the absolute or relative position of the mouse. Regardless of this setting the values for the X axis are always positive to the right and negative to the left, positive forward and negative backward for the Y value, and positive when spinning the scroll wheel forward and negative backward for the Z value.

Reacting to User Action

Now that we can detect what the user is doing, it is time to take that input and use it to manipulate our world. First we create a method called CheckForInput that will contain all of the input-related code. (In a later article I will show you why this is not the best way of tracking and reacting to user input and how a system called Action Mapping can be used to reduce the amount of code and avoid code duplication.) For right now we are going to set up BattleTank 2005 to use the cursor keys to change the heading and pitch of the camera (move it up/down and left/right). Note that this does not change the location of the camera. The first step is to poll the devices to retrieve the latest state information.

Visual C#

_keyboard.Poll ( );
_mouse.Poll ( );

Visual Basic

_keyboard.Poll()
_mouse.Poll()

Then we test the state for the keys we are interested in and react accordingly.

Visual C#

if ( _keyboard.State[Key.LeftArrow] )
_camera.MoveCameraLeftRight ( -0.5f );

if ( _keyboard.State[Key.RightArrow] )
_camera.MoveCameraLeftRight ( 0.5f );

if ( _keyboard.State[Key.UpArrow] )
_camera.MoveCameraUpDown ( -0.5f );

if ( _keyboard.State[Key.DownArrow] )
_camera.MoveCameraUpDown ( 0.5f );

Visual Basic

If _keyboard.State(Key.LeftArrow) Then
_camera.MoveCameraLeftRight(-0.5F)
End If
If _keyboard.State(Key.RightArrow) Then
_camera.MoveCameraLeftRight(0.5F)
End If
If _keyboard.State(Key.UpArrow) Then
_camera.MoveCameraUpDown(-0.5F)
End If
If _keyboard.State(Key.DownArrow) Then
_camera.MoveCameraUpDown(0.5F)
End If

We also want to use the mouse to move the camera, so we add another set of checks for the mouse input.

Visual C#

if ( 0 != ( _mouse.State.X | _mouse.State.Y ) )
{
_camera.MoveCameraLeftRight ( _mouse.State.X / 10 );
_camera.MoveCameraUpDown ( _mouse.State.Y / 10 );
}

Visual Basic

If Not (0 = (_mouse.State.X Or _mouse.State.Y)) Then
_camera.MoveCameraLeftRight(_mouse.State.X / 10)
_camera.MoveCameraUpDown(_mouse.State.Y / 10)
End If

You can adjust the increments used in the MoveCameraLeftRight and MoveCameraUpDown methods to make the movement slower or faster.

The final step is to add a key that enables us to exit the application. The Escape key is the natural choice:

Visual C#

if ( _keyboard.State[Key.Escape] )
{
Application.Exit ( );
}

Visual Basic

If _keyboard.State(Key.Escape) Then
Application.Exit()
End If

In addition to moving the camera around, we also want to move the location of the camera since that is how we are going to simulate driving around in our tank. The only problem is that, with no fixed items in the world, we can't really tell when we have moved, since the only reference point is the skybox, which never changes position. To overcome this limitation until we add units in the next article, I am simply writing out the new camera location to the Console whenever it changes.

Visual C#

if ( _keyboard.State[Key.W] )
_camera.MoveCameraPosition ( 10, 0, 0 );

if ( _keyboard.State[Key.S] )
_camera.MoveCameraPosition ( -10, 0, 0 );

if ( _keyboard.State[Key.A] )
_camera.MoveCameraPosition ( 0, 10, 0 );

if ( _keyboard.State[Key.D] )
_camera.MoveCameraPosition ( 0, -10, 0 );

if ( oldX != _camera.X )
{
Console.WriteLine ( _camera.X + ", " + _camera.Y );
oldX = _camera.X;
}

Visual Basic

If _keyboard.State(Key.W) Then
_camera.MoveCameraPosition(10, 0, 0)
End If
If _keyboard.State(Key.S) Then
_camera.MoveCameraPosition(-10, 0, 0)
End If
If _keyboard.State(Key.A) Then
_camera.MoveCameraPosition(0, 10, 0)
End If
If _keyboard.State(Key.D) Then
_camera.MoveCameraPosition(0, -10, 0)
End If
If Not (oldX = _camera.X) Then
Console.WriteLine(_camera.X & ", " & _camera.Y)
oldX = _camera.X
End If

The last piece of input-checking we need to add is checking the mouse buttons. For right now, let's assume that the default button on the mouse causes the tank to fire. We are going to add more exciting action later on, but all we can do now is to write "Fire!" to the Console.

Visual C#

if ( 0 != _mouse.MouseButtons[0] )
Console.WriteLine ( "Fire!" );

Visual Basic

If Not (0 = _mouse.MouseButtons(0)) Then
Console.WriteLine("Fire!")
End If

That's it for input. As I mentioned before, there are better ways of relating keyboard and mouse keys and movement to actions in the game. You may also notice that when moving the camera, sometimes a single button click causes two movements. This is due to the fact that the game loop is faster than we are and the key is pressed long enough to be picked up in two game loops. We are also going to fix that in the next article.

Summary

Once again I find myself running out of space before being able to cover all of the items I wanted, but I hope there is enough stuff here to let you experiment on your own. We are going to continuously refine the game in each iteration rather than trying to get it perfect the first time; that is the sprit of Agile development. The important things to remember are how to use the Device class to acquire and then poll an input device, and how to determine the input from the State objects.

Even though we are now able to maneuver through the 3D world, it is not very exciting, since other than the skybox there are no other items in our world. In the next article we are going to fix that by adding units, both stationary and mobile, and work on collision detection. We are also going to refine the camera to help us in reducing the number of items we need to render by tacking the frustum. Oh—yes, I do know that the targeting crosshairs are gone; they will be back in the next article in the form of a real Heads-Up-Display (HUD) class.

Until then: Happy coding.  

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值