现在的位置: 首页 > 综合 > 正文

用GAPI开发Windows Mobile手机游戏

2013年10月06日 ⁄ 综合 ⁄ 共 12675字 ⁄ 字号 评论关闭
文章目录

声明
  本文来自《msdn开发精选》杂志2005年第4期“特别策划”栏目,本文版权归杂志编辑部所有,未经许可,禁止转载!

作者:傅曦

  目前mobile phone 游戏API简称GAPI为手机上的游戏开发者提供了强有力的高效率的编程接口,当然GAPI不仅仅使用在游戏方面,需要高效率图形显示处理的地方都可以使用GAPI。

  GAPI是基于动态连接库方式,应用程序直接调用动态库里的函数,一般GAPI库的文件名为GX.dll,目前mobile phone里都提供了gx.dll文件。

  一个典型的游戏或者应用程序使用下列GAPI函数:

  • OpenDisplay (fullscreenflag)
    打开GAPI显示功能。
  • OpenInput
    打开直接响应硬件键盘输入消息功能
  • GetDisplayProperties
    获得VFB详细结构信息
  • GetDefaultKeys
    获得缺省的键值

操纵GAPI

  开始一个游戏编写,首先要打开GAPI显示功能,获得控制视频显示缓存的控制权限。可以调用

GXOpenDisplay(HWND hwnd, DWORD dwFlags)

  hwnd参数是游戏程序的窗口句柄,dwFlags定义了显示模式,宏GX_FULLSCREEN定义全屏模式,就能对设备的全屏区域进行控制。返回值1说明打开成功,0是失败。

  虽然都是使用mobile phone系统但是不同系列的产品可能使用不同的显示设备,那么对于不同的显示设备就可能有不同的显示性能参数,不同的分辨率率,不同的色深,不同的颜色显示能力。当在编写一个为mobile phone 系列运行的游戏程序时不得不考虑这些问题,以使程序能适应在不同的显示环境下达到程序所希望的显示效果。

  如何得到这些相关显示信息?可以调用下面函数:

GXDLL_API GXDisplayProperties GXGetDisplayProperties ();

  它能得到显示设备的所有相关细节信息,这些都是在开发基于GAPI游戏时需要的。所有信息被返回到GXDisplayProperties结构中,其结构如下:

struct GXDisplayProperties {
    DWORD cxWidth;
    DWORD cyHeight;
    long cbxPitch;
    long cbyPitch;
    long cBPP;
    DWORD ffFormat;
};

  这个结构提供了显示设备的信息,也就是当前视频缓存区域的参数指标。

  cxWidth和cyHeight是显示设备的宽和高的像素值,提供了显示设备横、纵能显示的像素个数;cBPP是每个像素点需要的位数,总是等于2的n次方。cbxPitch和cbyPitch提供了相邻两个像素间从内存数据上相差的字节数,cbxPitch表示的是左右两个像素间的差值,当cBPP大于等于8时,cbxPitch表示相差的字节个数,当cBPP小于8时,cbxPitch已经不能真实的反映出相差的字节数,事实上必须自己计算得到相邻的地址:

  比如:

cBPP = 4;
Leftpointaddr = pb + (((current_x+1) * cBPP) >> 3)
    + (current_y * cbyPitch)

  cbyPitch表示的上下方向间两个像素的差值,计算时通过加减cbyPitch来的到上下方向的像素点的地址:

downpointaddr = currentpointaddr + cbyPitch;

  ffFormat参数说明显示设备对色深的处理方式及显示的格式:

  当ffFormat 等于 KfLandscape 说明当前显示方式是横屏方式,即原点(0,0)变成了左下角。

  ffFormat 等于 KfPalette 说明色彩显示是基于调色板方式。

  ffFormat 等于 KfDirect 说明色彩显示是直接映射,不引用调色板。

  ffFormat 等于 KfDirectInverted 说明颜色显示是反转的。

  ffFormat 等于 kfDirect555 、kfDirect565、kfDirect888 说明映射颜色显示时,数字表示红绿蓝所占的位信息。

  计算每一个像素坐标地址方法如下(x,y):

unsigned char * pb;
if (cBPP < 8) {
address = pb + ((x * cBPP) >> 3) + (y * cbyPitch)
}
else
{
address = pb + (x * cbxPitch) + (y * cbyPitch);
}

判断是否是标准显示设备

  可以使用函数GXIsDisplayDRAMBuffer (),返回值为TRUE说明是非标准显示设备,返回值为FLASE说明是标准显示设备。当是非标准显示设备时,需要使用函数GXSetViewport来定义显示屏幕的区域,在标准显示设备上使用GXSetViewport是无效的。

GXDLL_API int GXSetViewport (
    DWORD dwTop,
    DWORD dwHeight,
    DWORD dwReserved1,
    DWORD dwReserved2)

  dwTop定义了屏幕显示区域的y坐标,dwHeight显示区域的高度,dwReserved1、dwReserved被保留,必须需设置为0。

开始绘制像素

  现在就可以准备对缓存区进行操作绘制图形,通过GXBeginDraw得到缓存区的首地址:

void * GXBeginDraw ();

  函数返回值就是需要的首地址,如果是NULL说明显示缓存区得不到。然后就可以进行一些列像素的操作,操作完毕后需要调用GXEndDraw 结束一次操作:

int GXEndDraw ();

  返回1说明调用成功,0说明错误。

  提交绘制的信息,已使变化的画面生效。当程序失去焦点时必须调用GXSuspend ()挂起所有GAPI的操作,把屏幕控制交给其他程序,当接收到获得焦点信息时,程序必须调用GXResume ()使得程序继续运行GAPI函数。

  当退出程序时,必须释放GAPI资源,可以调用:

int GXCloseDisplay ();

GAPI高效贴图

  在开发一些图像处理或游戏时我们可以使用GDI制作出满意的产品,但是开发复杂高速的图形显示或高效率的动态游戏时,往往对GDI的显示效率不高而感到沮丧,虽然可以使用双缓存等技术,但是GDI层接口毕竟效率低,无法满足要求。

  GAPI对显示缓存区的直接操作,使显示效率大大提高,所以在目前mobile phone上当需要处理高速贴图时GAPI就当之无愧了。

  虽然GAPI高效强大,提供了对显示缓存区直接的读写权限,但是基于如此低级的功能函数,在编写一个稍微复杂的程序时,就会花费大量的时间和精力在处理对显示缓存区的操作,因为此缓存区并不像GDI提供的绘制缓存区对图片显示一样操作容易,为了显示一幅bmp图就需要编写好几页的代码,这是非常令人厌倦的事。

  在这里主要介绍一下一个使用GAPI编写的第三方贴图类STGapiBuffer。STGapiBuffer提供了类似于GDI方式的接口操作简单,很容易就构造出了需要显示的缓存内容,最后只要简单的把它们拷贝进显示缓存区,就可以显示出来了。

  只要简单的把STGapiBuffer.h 和 STGapiBuffer.cpp加入到工程里面就可以方面的使用了。

  为了需要绘制一个jpg图片,首先需要把图片加载入此类里,并创建适合STGapiBuffer处理的数据,使用CreateNativeBitmap函数,需要如下操作:

HBITMAP hBitmap = SHLoadDIBitmap(_T("//image.bmp"));
g_pNativeBitmap = g_gapiBuffer.CreateNativeBitmap(hBitmap);
::DeleteObject(hBitmap);

  接下去需要为绘制到哪里设置目标对象,可以是另一个STGapiBuffer的缓存区,也可以是显示的显示设备缓存区,调用函数SetBuffer,代码如下:

g_gapiBuffer.SetBuffer(pDisplayBuffer);

  最好可以使用CSTGapiBuffer::BitBlt来把需要的数据绘制到缓存区里,类似于GDI函数BitBlt,代码如下:

g_gapiBuffer.BitBlt(0, 0, 100, 100, g_pNativeBitmap);

  这样就把一幅图片显示到了屏幕上。

  CSTGapiBuffer还提供了绘制透明图片的函数,在绘图时经常会遇到这样的情况,使用CSTGapiBuffer::MaskedBlt方便的绘制指定透明色的图案。当然我在用GDI绘图时经常使用CreateMemoryDC创建一个临时内存DC来绘制,CSTGapiBuffer也提供了类似的功能的函数CSTGapiBuffer:: CreateMemoryBuffer

  CSTGapiBuffer类使用示例(部分代码):

CNativeBitmap* pAsteroidBitmap = NULL;
CNativeBitmap* pAsteroidMask = NULL;

CSTGapiBuffer gapiBufferBackground; // 背景
CSTGapiBuffer gapiBufferMemory;
CSTGapiBuffer gapiBufferScreen;

HBITMAP hBackground = ::LoadBitmap(hInst, MAKEINTRESOURCE(IDB_BACKGROUND));
CNativeBitmap * pBackgroundBitmap =
    gapiBufferMemory.CreateNativeBitmap(hBackground);
::DeleteObject(hBackground);

gapiBufferBackground.CreateMemoryBuffer();
gapiBufferBackground.BitBlt(0, 20, dwDispWidth, dwDispHeight,
    pBackgroundBitmap);
delete pBackgroundBitmap;
pBackgroundBitmap = NULL;

////////////////////////////////////

HBITMAP hAsteroid = ::LoadBitmap(hInst, MAKEINTRESOURCE(IDB_ASTEROID));
pAsteroidBitmap = gapiBufferMemory.CreateNativeBitmap(hAsteroid);
::DeleteObject(hAsteroid);

HBITMAP hAsteroidMask = ::LoadBitmap(hInst, MAKEINTRESOURCE(IDB_ASTEROID_MASK));
pAsteroidMask = gapiBufferMemory.CreateNativeBitmap(hAsteroidMask);
::DeleteObject(hAsteroidMask);

/////////////////////////////////////////
//

dwTransparentColor = gapiBufferMemory.GetNativeColor(RGB(249, 57, 198));

// create an offscreen buffer
gapiBufferMemory.CreateMemoryBuffer();

gapiBufferMemory.BitBlt(&gapiBufferBackground);

///////////////////////////////////////////////////

//
gapiBufferMemory.TransparentBltEx(0, nOffset%dwDispHeight,
    50, 60, pAsteroidBitmap, dwTransparentColor);

gapiBufferMemory.TransparentBltEx(100, (nOffset*2)%dwDispHeight,
    50, 60, pAsteroidBitmap, dwTransparentColor);

gapiBufferMemory.TransparentBltEx(nOffset%dwDispWidth,
    50, 50, 60, pAsteroidBitmap, dwTransparentColor);

gapiBufferMemory.TransparentBltEx((nOffset*2)%dwDispWidth,
    150, 50, 60, pAsteroidBitmap, dwTransparentColor);

gapiBufferMemory.MaskedBlt(dwDispWidth-(nOffset*2)%dwDispWidth,
    (nOffset*8)%dwDispHeight, 50, 60, pAsteroidBitmap, pAsteroidMask);

gapiBufferMemory.MaskedBlt((nOffset%dwDispWidth*5), (nOffset*3)%
    dwDispHeight, 50, 60, pAsteroidBitmap, pAsteroidMask);

gapiBufferMemory.MaskedBlt((nOffset*3)%dwDispWidth, (nOffset*4)%
    dwDispHeight, 50, 60, pAsteroidBitmap, pAsteroidMask);

gapiBufferMemory.MaskedBlt((nOffset%dwDispWidth*2), dwDispHeight-
    (nOffset*5)%dwDispHeight, 50, 60, pAsteroidBitmap, pAsteroidMask);

RECT rc = { 0, 20, dwDispWidth/3, 10 };
FillRect(&rc, RGB(255, 0, 0));

rc.left = dwDispWidth/3;
rc.right = 2*dwDispWidth/3;
FillRect(&rc, RGB(0, 255, 0));

rc.left = 2*dwDispWidth/3;
rc.right = dwDispWidth;
FillRect(&rc, RGB(0, 0, 255));

void* pBuffer = GXBeginDraw();
gapiBufferScreen.SetBuffer(pBuffer);
gapiBufferScreen.BitBlt(&gapiBufferMemory);
GXEndDraw();

Gapi键盘消息

  使用GXOpenInput()函数获得键盘的控制权,调用GXGetDefaultKeys(GX_NORMALKEYS)函数来获得默认键盘的消息映射。然后在Windows消息处理函数中我们就能收到由GAPI发送过来的键盘消息,当你按下某一个键,程序会收到WM_KEYDOWN,wParam参数包含GAPI映射的这个键的消息,通过与得到的GXKeyList结构中的字段定义来判断当前收到的键是不是定义的功能键。

  GXKeyList结构如下:

struct GXKeyList {
    short vkUp;
    POINT ptUp;
    Short vkDown;
    POINT ptDown;
    Short vkLeft;
    POINT ptLeft;
    Short vkRight;
    POINT ptRight;
    Short vkA;
    POINT ptA;
    Short vkB;
    POINT ptB;
    Short vkC;
    POINT ptC;
    Short vkStart;
    POINT ptStart;
};

游戏的振动感

  在游戏中提供振动效果,是让玩家非常兴奋的事情,可以提升游戏的吸引程度。Mobile Phone SDK 提供了振动效果的API,允许你控制振动,类似与控制声音一样简单。

获得振动设备属性

  要获知手机是否支持振动,振动的性能,当前的设置情况,可以调用下面的函数:

int VibrateGetDeviceCaps(VIBRATEDEVICECAPS vdc);

  VIBRATEDEVICECAPS是个枚举类型,结构如下

typedef enum {
    VDC_AMPLITUDE,
    VDC_FREQUENCY,
    VDC_LAST
} VIBRATEDEVICECAPS

  • VDC_AMPLITUDE 查询振动设备所能支持的振幅大小
  • VDC_FREQUENCY 查询振动设备所能支持的振动频率大小
  • VDC_LAST 查询振动设备所能支持的振幅大小

  如果函数成功,它将返回数字0到7,数字0说明设备没有提供振动功能,1说明设备具有振动功能,并且可以使用,但是仅仅具有打开关闭震动,无法对振动进行调节,2到7说明了设备提供了不同等级的振动功能,数字越大提供的调节能力越强。当设备具有不同等级振动能力时,我就可以通过VIBRATENOTE结构做详细设置。

  怎么才能开始真正的使用振动功能呢?mobile phone SDK提供Vibrate函数:

HRESULT Vibrate(
    DWORD cvn,
    const VIBRATENOTE * rgvn,
    BOOL fRepeat,
    DWORD dwTimeout
);

  它能提供不同振幅,不同频率,并可以调节需要振动时间。cvn参数是第二个参数rgvn数组的维数,rgvn是指向一组VIBRATENOTE结构的指针。

  VIBRATENOTE结构如下:

typedef struct {
    WORD wDuration;
    BYTE bAmplitude;
    BYTE bFrequency;
} VIBRATENOTE

  wDuration说明了震动持续的时间,bAmplitude定义了振动的振幅大小,允许设置0-7级,如果等于0xff系统使用缺省值作为参数,bFrequency定义了振动的频率高低,允许设置0-7级,如果0xff系统使用缺省值作为参数。

  当你需要停止当然的振动时,可以调用VibrateStop()函数,返回S_OK说明成功调用,E_FAIL说明调用失败。

  下面是代码示例:

int caps = -1;
caps = VibrateGetDeviceCaps(VDC_AMPLITUDE);
if(caps<=0)
    return FALSE; //振幅返回失败,说明不支持振动功能

HRESULT hr = Vibrate(0, NULL, TRUE, INFINITE); //设定为无时间限制
if(hr == E_FAIL)
{
    MessageBox(NULL,L"E_FAIL",L"",MB_OK);
}
else if(hr == E_NOTIMPL)
{
    MessageBox(NULL,L"E_NOTIMPL",L"",MB_OK);
}

Sleep(1000); //振动所花时间

VibrateStop();

开始第一手机游戏历程

  在这里使用GAPI模拟一个贪食者游戏,它非常简单。主要注重怎么具体使用GAPI,在使用中怎么对视频缓存区操作演示,并不去美化外表。

  初始化GAPI库,在InitInstance函数里我们对GAPI的显示和输入进行了初始化。

if (GXOpenDisplay( hWnd, GX_FULLSCREEN) == 0)
    return FALSE;
gx_displayprop = GXGetDisplayProperties();
if (gx_displayprop.cBPP != 16)
{
    // Only dealing with 16 bit color in this code
    GXCloseDisplay();
    MessageBox(hWnd,L"Sorry, only supporting 16bit color",L"Sorry!",     MB_OK);
    return FALSE;
}
framebuf = (unsigned short*) malloc(sizeof(short)*gx_displayprop.cxWidth*gx_displayprop.cyHeight);
if(framebuf==NULL)
    return FALSE;
ClearScreen(framebuf,0xff,0xff,0xff);
GXOpenInput();

// Get default buttons for up/down etc.
gx_keylist = GXGetDefaultKeys(GX_NORMALKEYS);

  初始化工作完成后,我们就需要对游戏的内容进行必要准备工作。我们首先初始化贪食者对象,我们为它建立蛇头和它的身体。为了使贪食者不停的游动,必须有一个事件触发。在这个我们使用定时器,以100ms的间隔发送消息,这样将得到一秒10帧的效果,这足以满足普通游戏的效果。

  为了对定时器发送过来的消息进行处理,我们调用Run函数

void Run(HWND hwnd)
{
    if(1 == JudgeDeath(framebuf))
    {
        KillTimer(hwnd,1);
        RunVibrate(1000);
        MessageBox(hwnd,_T("Snake has died!"),_T("died"),
            MB_OK | MB_ICONINFORMATION);
        SendMessage(hwnd,WM_PAINT,0,0);
        InvalidateRect(hwnd,NULL,TRUE);
    }
    ChangeDirection();
    SortAll();
    RedrawSnake();
}

  JudgeDeath每次都会判断是否已经死亡(蛇的任何部位有重叠),一旦满足死亡的条件,就取消定时器,以便中止蛇的游动。ChangeDirection判断方向是否发生了改变。在这里我对蛇部位的重叠利用了两个象素是否相同的颜色,如果颜色一直说明重叠发生。

void Get16Pixel(unsigned short *buffer,int x, int y,int *r, int *g, int *b)
{
    unsigned short *pixeladd;
    int address = (x * gx_displayprop.cbxPitch>>1)
        + (y * gx_displayprop.cbyPitch>>1);
    pixeladd = (buffer+address);
    if (gx_displayprop.ffFormat & kfDirect565)
    {
        unsigned short PixelCol;
        PixelCol = (*pixeladd);
        *r = (PixelCol & 0xf800) >> 11;
        *g = (PixelCol & 0x07e0) >> 5;
        *b = (PixelCol & 0x001f);
    }
    else//555
    {
        unsigned short PixelCol;
        PixelCol = (*pixeladd);
        *r = (PixelCol & 0x7c00) >> 11;
        *g = (PixelCol & 0x03e0) >> 5;
        *b = (PixelCol & 0x001f);
    }
}

第三方开发库介绍

  GapiDraw的设计与DirectDraw非常相似,而且将更加容易使用,极大限度的为掌上设备进行了优化。下面是一些DriectDraw中的一般功能,以及如何在GapiDraw中实现这些功能。

打开显示设备

  计算机的显示内存是一块包含了图像数据的内存区域。为了直接向这块区域进行写操作,DirectDraw和GapiDraw都需要你创建一个指定的称之为主界面的界面。直接绘制到这个主界面来影响屏幕的可见内容。

  创建主界面的第一步是打开显示器,设置一个显示模式。下面的步骤是使用DirectDraw创建主界面的最少步骤。

DirectDraw

LPDIRECTDRAW lpDD;

HRESULT ddrval;

//创建主Direct Draw对象
ddrval = DirectDrawCreate(NULL, &lpDD, NULL);
if(ddrval != DD_OK)
{
    return(false);
}

//设置合作级别以允许Direct Draw全屏运行
ddrval = lpDD->SetCooperativeLevel(hwnd, DDSCL_EXCLUSIVE | DDSCL_FULLSCREEN);
if(ddrval != DD_OK)
{
    lpDD->Release();
    return(false);
}

//设置显示模式为320x240x16
ddrval = lpDD->SetDisplayMode(320, 240, 16);
if(ddrval != DD_OK)
{
    lpDD->Release();
    return(false);
}

  使用GapiDraw是比较容易的。因为界面是对象而不是COM接口,所以根本就不用手工进行释放。下面的GapiDraw例子只使用了一条命令打开显示设备,设置默认显示模式。

GapiDraw

CGapiDisplay display;

HRESULT gdrval;

//使用标准Pocket PC240x320x16模式打开显示
gdrval = display.OpenDisplay(hwnd, GDOPENDISPLAY_FULLSCREEN);
if(gdrval != GD_OK)
{
    return(false);
}

取回主界面和后背缓冲

  使用Direct Draw,你必须手工请求创建一个指定界面的主Direcr Draw对象,主界面主要用于直接在显示器上绘制。在Direct Draw中只有一个界面接口用于双内存界面和显示。这可以简单地解释为过去使用的COM模式中的子类化缺陷。下面的例子创建一个主界面,使用Direct Draw取回它的后背缓冲。

DirectDraw

LPDIRECTDRAWSURFACE lpDDSPrimary; // DirectDraw 主界面
LPDIRECTDRAWSURFACE lpDDSBack; // DirectDraw 后背缓冲

DDSURFACEDESC ddsd;
DDSCAPS ddscaps;
HRESULT ddrval;

memset(&ddsd, 0, sizeof(ddsd));
ddsd.dwSize = sizeof(ddsd);
ddsd.dwFlags = DDSD_CAPS | DDSD_BACKBUFFERCOUNT;
ddsd.ddsCaps.dwCaps = DDSCAPS_PRIMARYSURFACE | DDSCAPS_FLIP | DDSCAPS_COMPLEX;
ddsd.dwBackBufferCount = 1;

//创建主界面
ddrval = lpDD->CreateSurface(&ddsd, &lpDDSPrimary, NULL);
if(ddrval != DD_OK)
{
    lpDD->Release();
    return(false);
}

// Get the back buffer获得背后缓冲
ddscaps.dwCaps = DDSCAPS_BACKBUFFER;
ddrval = lpDDSPrimary->GetAttachedSurface(&ddscaps, &lpDDSBack);
if(ddrval != DD_OK)
{
    lpDDSPrimary->Release();
    lpDD->Release();
    return(false);
}

  相反,使用GapiDraw 事情会更容易。一旦调用了CGapiSurface::OpenDisplay,CGapiDisplay 对象自动地变成主界面。因为CGapiDisplay 是CGapiSurface的子类,所有的位图传输(blit)和绘制操作都已经是可用的了。为了从CGapiDisplay得到后背缓冲,使用如下代码:

GapiDraw

CGapiSurface backbuffer; // GapiDraw 后背缓冲

HRESULT gdrval;
//得到后背缓冲
gdrval = display.GetBackBuffer(&backbuffer);
if(gdrval != GD_OK)
{
    return(false);
}

失败的界面

  DirectDraw的界面通常储存在图像存储器中,可以在任何时候被覆盖(在用户切换程序或者启动另外一个使用GDI的程序的情况下)。这是因为对界面的每个操作在任何时候都有可能失败,简单地说是因为界面数据被覆盖。因此所有使用Direct Draw的操作必须每次检查界面是否失败,然后从失败处手工恢复和重新创建界面。下面的例子说明了这一点。

DirectDraw

ddrval = lpDDSBack->Blt(&rcRectDest, lpDDSMySurf, &rcRectSrc, DDBLT_WAIT, NULL);
if(ddrval == DDERR_SURFACELOST)
{
    //界面被覆盖,现在你必须手工恢复和重新创建所有的界面
}

ddrval = lpDDSPrimary->Flip(NULL, DDFLIP_WAIT);
if(ddrval == DDERR_SURFACELOST)
{
    //界面被覆盖,现在你必须手工恢复和重新创建所有的界面
}

  Pocket PC不使用图像存储器,所有界面数据被储存在RAM物理内存,只有调用CGapiDisplay::Flip时才被复制到显示区域。如果Pocket PC设备访问了显示区域的缓冲,那么Pocket PC可能会移动它的后背缓冲的位置。到目前为止,还没有设备被告之可以做到这点,但是它始终是最好的设计。可以使用下面的代码捕获在GapiDraw中丢失的后背缓冲。

GapiDraw

//普通操作中的界面不能丢失
gdrval = backbuffer.Blt(&rcRectDest, &mysurf, &rcRectSrc, 0, NULL);

gdrval = display.Flip();
if(gdrval == GDERR_BACKBUFFERLOST)
{
    //显示缓冲被移动,刚好获得一个更新的后背缓冲
    display.GetBackBuffer(&backbuffer);
}

结论

  上面提及的是GapiDraw和Direct Draw之间的主要不同。其它的特性,象blit、颜色值、矩形坐标等都是有差异的。GapDraw也包含一个巨大的扩展特性,这些特性在Direct Draw不可用,象高级的blit影响、快速旋转、从文件或内存装载位图图像、绘制工具、冲突掩码、界面交叉、线程定时器、位图字体支持以及更多。

抱歉!评论已关闭.