3D 跨平台第三人称射击游戏 - 到 Intel x86 Android
将我们的跨平台 3D 第三人称射击游戏移植到 Intel x86 Android 环境的过程。
引言
本文将介绍如何将我们的跨平台 3D 第三人称射击游戏移植到 Intel x86 Android 环境。提供的源代码可免费使用(Apache 2.0 许可证),如果您用它做出了很棒的东西,请告诉我,我很乐意看到。最终目的是希望能激励潜在的游戏开发者将我们的代码重新包装成他们自己的游戏,并激励 Android 或 iOS 开发者转向跨平台开发,通过展示如何轻松地构建您的代码库以使其适用于 Android(包括 Intel 芯片组)和 iOS。
描述
我们的游戏 Phone Wars 基于 Android 手机与 iPhone 互相对战的概念。为了实现这一点,我们需要为手机创建概念艺术,我们将其称为 aBots 和 iBots,然后根据概念创建 3D 模型,当然还要附上大型火箭发射器(这是制作游戏时常见的做法)。因此,我们的玩家可以体验将他们的竞争平台玩家炸成碎片的快感,iOS 玩家对抗 aBot,Android 玩家对抗 iBot。好吧,这就是前提。
特点
- 游戏可在多个平台(iOS、Android)上运行
- 加载并渲染 3D obj 格式模型
- 用于动态纹理流的纹理管理器
- 正面精灵
- 碰撞检测
- 从火箭发射子弹
- 生命条
- 会移动和射击的 AI
- 寻路器(节点生成和门控)
- 拾取物品
- UI 屏幕
还有很多其他好东西,但我必须强调我最喜欢这款游戏的激动人心的部分(警告:我是一个书呆子),那就是它可以在多个平台上运行。跨平台是编程 C++ 游戏的一大动力,使用 NDK 和 JNI 调用,您可以共享用于 iOS 的相同代码。这太棒了!您不需要只为一个平台制作游戏,您可以针对所有平台(muwhhahahahahha)。
技术信息
在本节中,我将详细介绍将完整版游戏代码库移植到 Intel x86 模拟器上运行的精确过程。完整版还包括在线多人玩家对战,我已将其从演示代码库中剔除,因为它设置更复杂(需要服务器)。但为了激励各位漂亮的人儿继续阅读,这里有一段游戏在模拟器中运行的视频。
http://www.youtube.com/watch?v=Tfv3jvjxfS4
第 1 步 - 为 x86 编译
我们需要为 x86 芯片组编译 C++ 代码。为此,请进入 jni 文件夹中的 Application.mk 文件,并在 APP_ABI 中添加 x86。
APP_ABI += x86
现在我们需要编译 C++ 代码并修复所有编译错误。使用常用的 ndk-build 命令。
没有错误——成功!!!
(此时我给自己拍了几次手,并享受了一段应得的长假)。
第 2 步 - 下载 x86 模拟器
现在我们已经编译了代码,我们需要让它在 x86 模拟器上运行。如果您点击本文顶部的链接,页面会说明推荐的安装方法是通过 Android SDK Manager。我通常通过 Eclipse 启动 SDK 管理器,因为有时在某些平台上,由于 Java 绑定不正确,它可能无法正常启动。因此,在 Eclipse 中,点击“窗口”工具栏 > “Android SDK 管理器” > “安装 Intel x86 Atom 系统映像”。
下载完成后,启动 AVD Manager 并创建一个带有 Intel Atom 和 GPU 模拟的 AVD。
现在最好再休息一下,因为通常第一次启动时会崩溃,所以在进入第 3 步之前享受这种感觉。
第 3 步 - 首次运行应用程序
我第一次通过 Eclipse 启动游戏时,它启动了模拟器但没有启动游戏。我再次点击调试按钮,它启动了第二个模拟器并进入了游戏。可能是 Eclipse Android 开发环境中的某个错误(如果这种情况发生在您身上,只需关闭第一个模拟器,一切都会照常进行)。
崩溃 #1:Google 服务
游戏直接崩溃到调试器,并抱怨设备没有包 com.google.android.gsf。这很合理,因为我们的模拟器运行的是纯净的 Android 4.0.3,它不包含任何 Google 服务。您需要做的就是删除游戏中所有 Google 特定的服务。
第 4 步 - 安装 HAX
从代码库中删除所有对 Google 服务的引用后,游戏仍然崩溃,但现在是在 GLSurfaceView 中。检查日志后。
HAX is not working and emulator runs in emulation mode
这个关于 HAX 不工作的警告看起来很可疑,所以让我们安装它看看是否有帮助。您可以从 Android SDK 文件夹或在线此处获取 HAX。
安装过程非常直观,内存限制我选择了推荐设置。
现在重新运行模拟器,您应该会注意到 HAX 正在工作的消息。
然而,幸运的是,游戏仍然在同一位置崩溃。
第 5 步 - NDK 调试
是时候振作起来,用 ndk-gdb 解决您的问题了。如果您以前从未调试过 NDK 代码,那会有点让人望而生畏。如果您乐于使用命令行上的 gdb,那对您来说很好。如果您害怕任何听起来像 vi 或 cat 或 echo 的东西,那么您最好尝试使用 Sequoyah 设置您的 Eclipse 环境进行 NDK 调试。我之前制作了一个自己在 Windows 环境中设置 Eclipse 进行 NDK 调试的屏幕录像,如果您以前从未做过,可能值得一看。但是,总的来说,NDK 调试是一项出色的技能,值得提升。我将继续假设您已经使用 Seqyouah 的魔力设置了 Eclipse,以便在 Eclipse 中调试 C++ 和 Java 代码。调试代码的步骤遵循此模式:使用 ndk-build NDK_DEBUG=1 标志编译它。在 Eclipse 中以调试模式启动游戏。让它命中一个断点,通常在您加载 C++ 库之后。回到命令行并键入 ndk-gdb。
然后在 Eclipse 中启动 C++ 调试器。
结果:它还没有连接。
啊哈!当然,我们需要将 Eclipse C++ 调试器指向 x86 库文件夹并使用 x86 gdb 调试器。
注意 GDB 调试器和 GDB 命令文件的路径都指向 x86 文件夹内部。
结果:仍然不起作用。
好吧,那没有奏效,所以我变得更加积极地尝试,我关闭了所有东西,重新下载了 NDK 包(r8a),NDK-build clean,将模拟器皮肤更改为 WVGA800,并将断点从 loadLibrary
调用之后移到 GLView onSurfaceChanged
函数内部,然后胜利!!!
(..什么是线程?)
如果你能做到这一步,认真地现在就停下来,抱抱自己,洗个澡,刮个胡子,抬头看看天空,欣赏阳光,因为你可能已经几周没看到它了。
第 6 步 - 修复 JNI 崩溃
利用 NDK 调试工具,我们可以逐步调试代码库,找出崩溃发生的位置。对于这款游戏,它是在从 C++ 代码回调到 Java 的调用中崩溃的。
const int result = jEnv->CallIntMethod( jObj, mid, jFilename, jPackaged, jGenerateMipMap );
检查日志,我们可以看到情况确实如此。
08-14 10:43:17.515: W/dalvikvm(2825): JNI WARNING:
can't call Lcom/android2c/CCJNI;.textureLoad on instance of Ljava/lang/Class;
经过一些 Google 搜索后,发现问题的根源是静态调用无法回调到非静态函数中。我从 Java 以静态函数进入 C++,并试图从 C++ 返回到非静态 Java 函数。
为了解决这个问题,我将 Java 回调切换为静态。
以前:
static int jniLoad(const char *name, const bool packaged, const bool generateMipMap)
{
// JNI Java call
JNIEnv *jEnv = gView->jniEnv;
jobject jObj = gView->jniObj;
jclass jniClass = jEnv->FindClass( "com/android2c/CCJNI" );
ASSERT_MESSAGE( jniClass != 0, "Could not find Java class." );
static jmethodID mid = jEnv->GetMethodID( jniClass, "textureLoad", "(Ljava/lang/String;ZZ)I" );
ASSERT( mid != 0 );
// Call the function
jstring jFilename = jEnv->NewStringUTF( name );
const int result = jEnv->CallIntMethod( jObj, mid, jFilename, packaged, generateMipMap );
jEnv->DeleteLocalRef( jFilename );
return result;
}
private int textureLoad(final String filename, final boolean packaged, final boolean mipmap)
{
return CCGLTexture.load( filename, packaged, mipmap );
}
操作后:
static int jniLoad(const char *name, const bool packaged, const bool generateMipMap)
{
// JNI Java call
JNIEnv *jEnv = gView->jniEnv;
jclass jniClass = jEnv->FindClass( "com/android2c/CCJNI" );
ASSERT_MESSAGE( jniClass != 0, "Could not find Java class." );
static jmethodID mid = jEnv->GetStaticMethodID( jniClass, "TextureLoad", "(Ljava/lang/String;ZZ)I" );
ASSERT( mid != 0 );
// Call the function
jstring jFilename = jEnv->NewStringUTF( name );
const int result = jEnv->CallStaticIntMethod( jniClass, mid, jFilename, packaged, generateMipMap );
jEnv->DeleteLocalRef( jFilename );
return result;
}
private static int TextureLoad(final String filename, final boolean packaged, final boolean mipmap)
{
return CCGLTexture.Load( filename, packaged, mipmap );
}
它运行了!!!嗯,它到了第一个菜单。
第 7 步 - 修复最终崩溃
我遇到的下一个崩溃与来自我们的 URL 管理器类的空指针有关。
在这种情况下,所需要的只是在读取通过 Java 数组传入的变量时添加一个空检查。
jstring jHeaderName = (jstring)jEnv->GetObjectArrayElement( jHeaderNames, i );
if( jHeaderName != NULL )
{
const char *cHeaderName = jEnv->GetStringUTFChars( jHeaderName, &isCopy );
有趣的是,这个问题从未在 ARM 移植中出现过,但它的出现是件好事,因为我感觉这个过程使我的代码更加安全。
它运行了!!!
好吧,还有最后一件事要修复,我的 glClearColour
设置为不清空 alpha 通道,一旦我将 alpha 设置为 1,一切都好了。
使用代码
您可以在 iOS 文件夹(2c.xcproj)中找到 iOS 项目。您可以在 Android/Source 文件夹中找到 Android 项目。在 Eclipse 中,如果您选择将项目导入到您的工作区并指向根游戏文件夹,它将包含游戏的所有文件夹(Engine/External libs/App/Android)。
在这里我将列出代码库中一些最容易上手和开始定制的部分。
开始游戏
在 ScenePlayManager.cpp 中
void ScenePlayManager::start()
{
if( gameState == GameState_SplashScreen )
{
updaters.deleteObjects();
startOfflineGame();
}
}
当按下背景图片时,会调用 start
函数。如果您不想开始游戏而是想做其他事情,这里就是开始修改的地方。
更多对手?
在 SceneAndroidsManager.cpp 中
void SceneAndroidsManager::startGame()
{
CharacterPlayer *player1, *player2;
player1 = game->spawnCharacter( "player1", playerType.buffer );
game->assignPlayerCharacter( player1 );
if( CCText::Contains( playerType.buffer, "aBot" ) )
{
player2 = game->spawnCharacter( "player2", "iBot" );
game->addFriend( player2 );
}
else
{
player2 = game->spawnCharacter( "player2", "aBot" );
game->addFriend( player2 );
}
super::startGame();
}
startGame
在关卡加载后被调用,这里我们生成两个角色。Player1 被分配为 PlayerCharacter
,Player2 被添加为朋友。你可以在这里添加更多朋友(好吧,他们实际上是坏蛋)。注意,如果玩家角色是 iBot,则会生成一个 aBot,反之亦然。
自定义关卡?
在 SceneGameSyndicate.cpp 的 createEnvironment
函数中
这部分将关卡大小设置为 500,并将关卡纹理设置为“level_background.png”。
void SceneGameSyndicate::createEnvironment()
{
{
CCText levelsPath = "Resources/common/levels/level_";
// Ground
{
const float size = 500.0f;
mapBounds.width = size * 0.5f * 0.8f;
mapBounds.height = size * 0.5f * 0.8f;
ground = new CollideableFloor();
ground->setup( size, size );
ground->setScene( this );
ground->readDepth = false;
CCText texPath = "Resources/";
texPath += CLIENT_NAME;
texPath += "/levels/level_background.png";
ground->primitive->setTexture( texPath.buffer, Resource_Packaged );
}
这部分创建了沙袋位置列表,然后在关卡周围生成沙袋。
// Create sandbags around the level
{
CCList<CCPoint> sandbagLocations;
sandbagLocations.add( new CCPoint( 0.0f, 0.0f ) );
sandbagLocations.add( new CCPoint( -50.0f, -100.0f ) );
sandbagLocations.add( new CCPoint( 50.0f, -100.0f ) );
sandbagLocations.add( new CCPoint( -50.0f, 100.0f ) );
sandbagLocations.add( new CCPoint( 50.0f, 100.0f ) );
const float sandbagWidth = 30.0f;
for( int i=0; i<sandbagLocations.length; ++i )
{
CCText fxPath = "Resources/common/levels/";
CCText objFile = fxPath;
objFile += "sandbags.obj";
CCText texFile = fxPath;
texFile += "sandbags_diffuse.png";
CCObjectCollideable *object = new CCObjectCollideable();
object->setScene( this );
CCAddFlag( object->collideableType, collision_static );
CCModelBase *model = new CCModelBase();
CCModelObj *model3d = CCModelObj::CacheModel( objFile.buffer, texFile.buffer );
model3d->setColour( CCColour( 1.0f ) );
model->addModel( model3d );
object->model = model;
float modelWidth = model3d->getWidth();
float modelHeight = model3d->getHeight();
float modelDepth = model3d->getDepth();
// Adjust model height
const float scaleFactor = sandbagWidth / modelWidth;
CCVector3FillPtr( &model->scale, scaleFactor, scaleFactor, scaleFactor );
model->rotateY( 90.0f );
modelWidth *= scaleFactor;
modelDepth *= scaleFactor;
modelHeight *= scaleFactor;
object->setCollisionBounds( modelDepth, modelHeight, modelWidth );
object->translate( 0.0f, object->collisionBounds.y, 0.0f );
object->setPositionXZ( sandbagLocations.list[i]->x, sandbagLocations.list[i]->y );
pathFinderNetwork.addCollideable( object, ground->collisionBounds );
object->setTransparent();
object->disableCulling = true;
object->readDepth = true;
object->drawOrder = 99;
}
sandbagLocations.deleteObjects();
}
如何移动?
SceneGameSyndicate.cpp 中的 playerDestinationPending
变量处理玩家移动,当触摸释放时,该变量通过将 2D 触摸位置从屏幕空间投影到世界空间来设置。
// Callback for when a touch is released
bool SceneGameSyndicate::touchReleased(const CCScreenTouches &touch, const CCTouchAction touchAction)
{
...
if( hitObject == NULL )
{
playerDestinationPending = new CCVector3();
camera->project3DY( playerDestinationPending, touch.position.x, touch.position.y );
CCClampFloat( playerDestinationPending->x, -mapBounds.width, mapBounds.width );
CCClampFloat( playerDestinationPending->z, -mapBounds.height, mapBounds.height );
if( playerDestinationIndicator != NULL )
{
playerDestinationIndicator->setPositionXZ( playerDestinationPending->x, playerDestinationPending->z );
playerDestinationIndicator->model->setColourAlpha( 1.0f );
}
}
}
}
...
}
在 updateScene
函数中,一旦我们释放触摸的时间超过了阈值,我们就会告诉角色移动到那里(这使我们能够同时处理单次和双次触摸的不同命令)。
bool SceneGameSyndicate::updateScene(const CCTime &time)
{
...
// Move the player
if( playerDestinationPending != NULL )
{
const CCScreenTouches *touches = gEngine->controls->getScreenTouches();
if( touches[0].lastTimeReleased > CC_DOUBLE_TAP_THRESHOLD )
{
if( controlsMoving == false )
{
playerCharacter->controller->goToScan( *playerDestinationPending );
DELETE_POINTER( playerDestinationPending );
}
}
}
...
}
如何死亡?
当子弹和玩家发生碰撞时,会调用 SceneGameSyndicate.cpp 中的 registerAttack
函数。在这里,我们检查玩家的生命值,更新游戏中的生命条,如果生命值小于 0,我们告诉游戏管理器我们的游戏已经结束。如果您想添加爆炸或一些酷炫的死亡动画,这里就是进行修改的地方。
void SceneGameSyndicate::registerAttack(CCObject *from, CCObject *to, const float force)
{
CharacterPlayer *friendCharacter = getFriend( to );
if( friendCharacter != NULL )
{
const float healthRatio = friendCharacter->controller->getHealthRatio();
sceneGameUI->setHealthAlpha( 1, healthRatio );
if( healthRatio <= 0.0f )
{
if( ScenePlayManager::scene != NULL )
{
ScenePlayManager::scene->matchEnd();
}
}
}
}
总结
希望通过本指南,您现在能够或有动力将您的游戏移植到支持 Android Intel 芯片组。源代码是可用的,所以请随意尝试,上面一节应该为您提供一些关于最容易修改以扩展游戏玩法的好提示。我还有很多想添加到本文中的内容,但最好就此打住,以免吓跑潜在的优秀游戏英雄。这里讨论了一些可能令人恐惧的话题(NDK 调试),如果您有任何问题或疑问,请留言,我会随着时间的推移改进这篇文章。如果您希望解释游戏的其他系统(AI、寻路、模型加载、控制、架构),请留言,我将撰写另一篇文章重点介绍该主题。