有阵子没玩这个了。
其实这玩意儿学了我工作也用不上,之前本来想学来玩玩ARKit,结果手机太旧了不支持(手动捂脸)。本着有始有终,花了两天时间留下最后一个相对完整的Demo,SceneKit的学习也暂告一段落。如果有什么不懂的,也可以留言讨论。
LittlerJumper:下载地址:https://github.com/anjohnlv/LittleJumper
我把它称为粗暴版跳一跳,Demo很粗暴也很简陋。
为了方便初学者学习SceneKit,整个Demo,SceneKit内容纯代码完成。且没有任何框架设计,所有的代码都在ViewController
中,加上注释一共接近400行。
注释还是很详细了。应该都能看懂并理解。
游戏截图:
简单总结一下:
-
创建工程。
和之前的Demo一样,我直接新建的Single View App。原因就不多重复。
初始化场景。
SceneKit中所有的物体、行为都要在SCNScene
中,而SCNScene
需要在SCNView
中。
在Demo中,包括SCNView
、SCNScene
、floor
、camera
、light
等,这些一开始就要准备好的元素。我为了体现游戏的操作过程,把这些初始化都放在了自己身上懒加载。
Demo里的注释很详细,我挑一些说
想要目标始终在视线范围内,我们得在“小人”跳走后让镜头跟随。可是如果一直让镜头跟随小人,会让整个游戏看起来特别晃。所以我让相机跟随站台,每成功跳一次,将相机移动观察新站台。
-(void)moveCameraToCurrentPlatform {
SCNVector3 position = self.platform.presentationNode.position;
position.x += 20;
position.y += 30;
position.z += 20;
SCNAction *move = [SCNAction moveTo:position duration:0.5];
[self.camera runAction:move];
[self createNextPlatform];
}
x
,y
,z
值是目测的,由于只是个Demo,所以细节方面没有太讲究。
如果这是这么写,你会发现,跳着跳着就看不见了。原因是你的光源始终照在远处,离光源远了,自然就看不到了。在这里,我是直接让光源始终跟随镜头。实现方法是在初始化相机之后,直接将光源设为相机的子节点。
-(SCNNode *)camera {
if (!_camera) {
_camera = ({
SCNNode *node = [SCNNode node];
node.camera = [SCNCamera camera];
node.camera.zFar = 200.f;
node.camera.zNear = .1f;
[self.scene.rootNode addChildNode:node];
node.eulerAngles = SCNVector3Make(-0.7, 0.6, 0);
node;
});
[_camera addChildNode:self.light];
}
return _camera;
}
- 事件
这个Demo其实很简单。整个游戏过程梳理下来:
初始化->点击屏幕蓄力->释放跳跃->判断成功->移动相机->生成下一个跳台->下一次跳跃->判断失败->游戏结束
蓄力的过程用到了长按手势,对,就和写App里的长按一样。SCNView
是基于UIView
的,可以直接将手势加在上面。设置longPressGesture.minimumPressDuration = 0;
保证短按也能监听到。这里有一个知识点是自定义SCNAction
的使用。
-(void)updateStrengthStatus {
SCNAction *action = [SCNAction customActionWithDuration:kMaxPressDuration actionBlock:^(SCNNode * node, CGFloat elapsedTime) {
CGFloat percentage = elapsedTime/kMaxPressDuration;
self.jumper.geometry.firstMaterial.diffuse.contents = [UIColor colorWithRed:1 green:1-percentage blue:1-percentage alpha:1];
}];
[self.jumper runAction:action];
}
很简单地实现了颜色的渐变动画。力量越大,颜色越红。在释放跳跃的瞬间,取消Action即可。
[self.jumper removeAllActions];
跳跃的过程得先提后面生成新跳台。Demo里新跳台的生成,是范围内随机大小,随机颜色、随机方向、随机距离。所以跳跃的时候,需要判断“小人”和目标跳台的方向。我们要保证方向向量上单位力量为恒定的,这样当通过时间来增加力量时才有意义。
这个是数学知识,已知三角形斜边长度和两边直角边比,求直角边长度。这个不多说。
移动相机上文已经提到就不再复述,接下来着重说明一下自己生成下一个站台的方法:
-(void)createNextPlatform {
self.nextPlatform = ({
SCNNode *node = [SCNNode node];
node.geometry = ({
//随机大小
int radius = (arc4random() % kMinPlatformRadius) + (kMaxPlatformRadius-kMinPlatformRadius);
SCNCylinder *cylinder = [SCNCylinder cylinderWithRadius:radius height:2];
//随机颜色
cylinder.firstMaterial.diffuse.contents = ({
CGFloat r = ((arc4random() % 255)+0.0)/255;
CGFloat g = ((arc4random() % 255)+0.0)/255;
CGFloat b = ((arc4random() % 255)+0.0)/255;
UIColor *color = [UIColor colorWithRed:r green:g blue:b alpha:1];
color;
});
cylinder;
});
node.physicsBody = ({
SCNPhysicsBody *body = [SCNPhysicsBody dynamicBody];
body.restitution = 1;
body.friction = 1;
body.damping = 0;
body.allowsResting = YES;
body.categoryBitMask = CollisionDetectionMaskPlatform;
body.collisionBitMask = CollisionDetectionMaskJumper|CollisionDetectionMaskFloor|CollisionDetectionMaskOldPlatform|CollisionDetectionMaskPlatform;
body.contactTestBitMask = CollisionDetectionMaskJumper;
body;
});
//随机位置
node.position = ({
SCNVector3 position = self.platform.presentationNode.position;
int xDistance = (arc4random() % (kMaxPlatformRadius*3-1))+1;
position.z -= ({
double lastRadius = ((SCNCylinder *)self.platform.geometry).radius;
double radius = ((SCNCylinder *)node.geometry).radius;
double maxDistance = sqrt(pow(kMaxPlatformRadius*3, 2)-pow(xDistance, 2));
double minDistance = (xDistance>lastRadius+radius)?xDistance:sqrt(pow(lastRadius+radius, 2)-pow(xDistance, 2));
double zDistance = (((double) rand() / RAND_MAX) * (maxDistance-minDistance)) + minDistance;
zDistance;
});
position.x -= xDistance;
position.y += 5;
position;
});
[self.scene.rootNode addChildNode:node];
node;
});
}
为了直观地看出每一步做了什么,Demo里我尽量采用语法糖来包裹所有节点。
如上文所说,新站台生成,是范围内的随即大小、随机颜色、随机方向、随机距离。随机大小和随机颜色很好理解。随机的方向和随机的距离的实现,是在x-z平面,首先在范围内,随机一个x坐标,然后根据最大距离和两元相切的最小距离,计算了一个z坐标的区间,再取z的随机坐标。以此达到随机方向和随机距离的效果。
Demo中得跳跃、碰撞等效果,均是使用的模拟的物理效果。使用很简单,说起来又是很多知识点。想了解更多可以点击这里。
在Demo我分别监听了小人与站台,以及小人和地板的碰撞。
- (void)physicsWorld:(SCNPhysicsWorld *)world didBeginContact:(SCNPhysicsContact *)contact{
SCNPhysicsBody *bodyA = contact.nodeA.physicsBody;
SCNPhysicsBody *bodyB = contact.nodeB.physicsBody;
if (bodyA.categoryBitMask==CollisionDetectionMaskJumper) {
if (bodyB.categoryBitMask==CollisionDetectionMaskFloor) {
bodyB.contactTestBitMask = CollisionDetectionMaskNone;
[self performSelectorOnMainThread:@selector(gameDidOver) withObject:nil waitUntilDone:NO];
}else if (bodyB.categoryBitMask==CollisionDetectionMaskPlatform) {
//这里有个小bug,我在第一次收到碰撞后进行如下配置,按理说不应该收到碰撞回调了。可实际上还是会来。于是我直接将跳过的台子的categoryBitMask改为CollisionDetectionMaskOldPlatform,保证每个台子只会收到一次。上面的掉落又没有这个bug。
//bodyB.contactTestBitMask = CollisionDetectionMaskNone;
bodyB.categoryBitMask = CollisionDetectionMaskOldPlatform;
[self jumpCompleted];
}
}
}
判断小人与地板碰撞,则游戏结束。
小人与新站台碰撞,则移动相机并生成下一个站台。
这里要注意的是,需要判断识别第一次碰撞。
最后,游戏结束。弹出的界面是UIView实现的。SceneKit就是一个framework,可以和其他UIKit之类的完全无缝衔接。
以上,加注释400行代码,粗暴版跳一跳完成。收工!
我觉得学习一门语言,主要是学习他的框架、他的流程,精益求精者会去关注他的实现原理。而学习Api,只是表面工作。其实在写这个Demo的时候,还遇到了一些未解的迷之bug。比如注释里提到的contactTestBitMask
取消了仍然会收到通知,比如加大重力后出现的无法平静的小人等等。
随意啦随意啦。反正SceneKit告一段落,撒花。
有什么bug的话欢迎斧正。有什么疑问的话也欢迎留言讨论。