SwiftでiOS開発:スカッシュゲーム
スマホアプリといえばゲーム!
ゲームの古典スカッシュゲームを作ってみました。
ここからブロック崩しやインベーダーゲームに発展させるのはお決まりですね。
iOSの開発環境にはゲームのテンプレートが用意されていて、4つのGame Technologyが選択できます。
- SceneKit:2D/3Dのカジュアルゲーム
- SpriteKit:2Dの高度な物理演算
- OpenGL ES:従来の3D(Androidにもあるオープンな技術)
- Metal:iOS端末の性能を最大限引き出す新3D技術
今回は2Dで物体間の反発もあるので、SpriteKitを使います。
Xcodeを立ちあげて、iOS->Application->Gameを選択します。
LanguageはSwift、Game TechnologyはSpriteKitです。
Devicesは何でもいいです。
サンプルプログラムがありますので、そのまま実行できます。
サンプルを堪能したら不要な部分を消していきます。
Images.xcassetsのSpaceshipはサンプルプログラム用の画像なので削除します。
GameScene.swiftには3つの関数(didMoveToView、touchesBegan、update)がありますが、関数の中身を全部削除します。
最後にGameScene.sksのSKNode inspectorのColorを黒に、SizeをW:640、H:1136とiPhone6の大きさにしておきます。
これで準備完了です。
基本的にはGameScene.swiftを実装すればいいです。
まずはグローバルに変数を準備。
class GameScene: SKScene { var paddle: SKSpriteNode! // ラケットのNode var balls = [SKShapeNode]() // ボールのNodeの配列 let radius: CGFloat = 12.0 // ボールの半径 let numberOfBalls = 3 // ボールの数 let ballSpeed: Double = 600.0 // ボールの速度
SKNodeにSKPhysicsBodyを持たせると物体として当たり判定等、物理演算処理が使用できるようになります。
画面全体(frame)とラケットとボールにSKPhysicsBodyを登録します。
ラケットは当たっても動いたりしない(物理演算を使用しない)ので、dynamicにfalseを割り当てます。
override func didMoveToView(view: SKView) { self.physicsBody = SKPhysicsBody(edgeLoopFromRect: self.frame) self.paddle = SKSpriteNode(color: UIColor.brownColor(), size: CGSizeMake(100, 20)) self.paddle.position = CGPointMake(CGRectGetMidX(self.frame), 40.0); self.paddle.physicsBody = SKPhysicsBody(rectangleOfSize: self.paddle.size) self.paddle.physicsBody!.dynamic = false self.addChild(paddle) addBall()
ボールは動くのですが、重力の影響は今回は無視するので、affectedByGravityをfalseにします。
また反発時にエネルギーを失わないように、restitutionを1.0にします。
空気抵抗(linearDamping)と摩擦(friction)も無視するようにします。
private func addBall() { var directionX: Double = 1; for i in 0..<self.numberOfBalls { let ball = SKShapeNode(circleOfRadius: radius) ball.position = CGPointMake(CGRectGetMidX(self.paddle.frame), CGRectGetMaxY(self.paddle.frame) + radius) ball.fillColor = UIColor.yellowColor() ball.strokeColor = UIColor.clearColor() ball.physicsBody = SKPhysicsBody(circleOfRadius: radius) // 中略 ball.physicsBody!.affectedByGravity = false ball.physicsBody!.restitution = 1.0 ball.physicsBody!.linearDamping = 0 ball.physicsBody!.friction = 0 ball.physicsBody!.allowsRotation = false ball.physicsBody!.usesPreciseCollisionDetection = true self.addChild(ball) self.balls.append(ball) } }
ボールには重力の影響を受けない代わりに、初速を与えます。
最初にランダムに飛ぶようにしています。
private func addBall() { var directionX: Double = 1; for i in 0..<self.numberOfBalls { // 中略 let randX = arc4random_uniform(10) + 10 let randY = arc4random_uniform(10) + 10 let randV = sqrt(Double(randX * randX + randY * randY)) let speedX = Double(randX) * self.ballSpeed / randV let speedY = Double(randY) * self.ballSpeed / randV ball.physicsBody!.velocity = CGVectorMake(CGFloat(speedX * directionX), CGFloat(speedY)) directionX *= -1
ここまで来たらボールが壁にぶつかったり、ラケットにぶつかったりしながら、ぴょんぴょん飛び跳ねます。
簡単ですね。
ラケットを動かせるようにしましょう。
iOSデバイスなので傾けて動かしたいところですが、今回はシミュレータを使いながらやりたいので、タッチで動かすことにします。
タッチした位置に向かって、一定のスピードで動くようになります。
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { if self.balls.count == 0 { // 中略 } else { super.touchesBegan(touches, withEvent: event) let touch = touches.anyObject() as UITouch let location = touch.locationInNode(self) let speed: CGFloat = 0.001 let duration = NSTimeInterval(abs(location.x - self.paddle.position.x) * speed) let move = SKAction.moveToX(location.x, duration: duration) self.paddle.runAction(move) } }
これでボールが飛び跳ねながらラケットで返せるようになります。
ラケットの後ろにボールが飛んだ場合は、跳ね返らずボールをロストさせないといけません。
当たり判定のときに必ず呼ばれる didSimulatePhysics 関数を使用します。
この関数は物理演算等で画面に変化を加えた後に呼ばれます。
ボールがラケットの後ろに飛んだときに破裂する効果も入れてみましょう。
プログラムを書く前に破裂する効果画像を用意します。
File->New->File..でiOS ResourceのSpriteKitParticle Fileを選択します。
Particle templateはSparkを選んで、sparkという名前で保存します。
spark.sksとspark.pngが追加されます。
ボールがラケットの後ろに行ったら、このsparkのNodeをボールの位置に追加して、fadeoutして削除する処理をSKActionを使って実行すればOKです。
ボールもremoveFromParent関数を使って削除しましょう。
override func didSimulatePhysics() { var removed = [Int]() for i in 0..<balls.count { let ball = balls[i] if ball.position.y < self.radius * 3 { let sparkNode = NSKeyedUnarchiver.unarchiveObjectWithFile(NSBundle.mainBundle().pathForResource("spark", ofType: "sks")!) as SKEmitterNode sparkNode.position = ball.position sparkNode.xScale = 0.3 sparkNode.yScale = 0.3 self.addChild(sparkNode) let fadeOut = SKAction.fadeOutWithDuration(0.3) let remove = SKAction.removeFromParent() sparkNode.runAction(SKAction.sequence([fadeOut, remove])) removed.append(i) ball.removeFromParent() } else { // 中略 } } for i in removed { balls.removeAtIndex(i) } }
これだけだと寂しいので、点数をつけます。
点数は継続時間にします。0.1秒毎に1点です。
SKLabelNodeを使って、点数表示の準備をします。
let time = SKLabelNode() var startTime = NSDate() override func didMoveToView(view: SKView) { // 中略 self.time.position = CGPointMake(CGRectGetMaxX(self.frame) - 30.0, CGRectGetMaxY(self.frame) - 30.0) self.time.fontColor = UIColor.whiteColor() self.time.text = "0" self.time.fontSize = 100 self.time.verticalAlignmentMode = SKLabelVerticalAlignmentMode.Top self.time.horizontalAlignmentMode = SKLabelHorizontalAlignmentMode.Right self.addChild(self.time) }
update関数を使用して、定期的に点数を更新します。
override func update(currentTime: NSTimeInterval) { if balls.count > 0 { self.time.text = String(Int(NSDate().timeIntervalSinceDate(self.startTime)*10)) } }
ボールがすべてロストしたら、タッチで再開させるようにしましょう。
override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { if self.balls.count == 0 { startTime = NSDate() addBall() } else {
これでほぼ完成ですが、やってみると、ボール同士の衝突等でボールの速度が極端に遅くなったり、Y軸方向の速度が0になり延々とX軸方向に動き続けたりします。
この補正をdidSimulatePhysics関数の中でやっておきましょう。
X方向が遅くなったら、Y方向を少し遅くして、速度がballSpeedになるように、X方向の速度を再計算しています。
let threashold = CGFloat(ballSpeed * 0.1) if abs(ball.physicsBody!.velocity.dx) < threashold { let vY = Double(ball.physicsBody!.velocity.dy) * 0.8 ball.physicsBody!.velocity.dx = CGFloat(sqrt(ballSpeed * ballSpeed - vY * vY)) ball.physicsBody!.velocity.dy = CGFloat(vY) } if (abs(ball.physicsBody!.velocity.dy) < threashold) { let vX = Double(ball.physicsBody!.velocity.dx) * 0.8 ball.physicsBody!.velocity.dx = CGFloat(vX) ball.physicsBody!.velocity.dy = CGFloat(sqrt(ballSpeed * ballSpeed - vX * vX)) } }
これで完成です。
SpriteKitを使うと、速度や重力、衝突が簡単に処理できます。
Nodeを増やして、衝突判定をしてけば、どんどん複雑なゲームが書けるようになりますね。
GitHub
https://github.com/mizumotok/Squash-Swift
Code
import SpriteKit class GameScene: SKScene { var paddle: SKSpriteNode! var balls = [SKShapeNode]() let radius: CGFloat = 12.0 let numberOfBalls = 3 let ballSpeed: Double = 600.0 let time = SKLabelNode() var startTime = NSDate() override func didMoveToView(view: SKView) { self.time.position = CGPointMake(CGRectGetMaxX(self.frame) - 30.0, CGRectGetMaxY(self.frame) - 30.0) self.time.fontColor = UIColor.whiteColor() self.time.text = "0" self.time.fontSize = 100 self.time.verticalAlignmentMode = SKLabelVerticalAlignmentMode.Top self.time.horizontalAlignmentMode = SKLabelHorizontalAlignmentMode.Right self.addChild(self.time) self.physicsBody = SKPhysicsBody(edgeLoopFromRect: self.frame) self.paddle = SKSpriteNode(color: UIColor.brownColor(), size: CGSizeMake(100, 20)) self.paddle.position = CGPointMake(CGRectGetMidX(self.frame), 40.0); self.paddle.physicsBody = SKPhysicsBody(rectangleOfSize: self.paddle.size) self.paddle.physicsBody!.dynamic = false self.addChild(paddle) addBall() } private func addBall() { var directionX: Double = 1; for i in 0..<self.numberOfBalls { let ball = SKShapeNode(circleOfRadius: radius) ball.position = CGPointMake(CGRectGetMidX(self.paddle.frame), CGRectGetMaxY(self.paddle.frame) + radius) ball.fillColor = UIColor.yellowColor() ball.strokeColor = UIColor.clearColor() ball.physicsBody = SKPhysicsBody(circleOfRadius: radius) let randX = arc4random_uniform(10) + 10 let randY = arc4random_uniform(10) + 10 let randV = sqrt(Double(randX * randX + randY * randY)) let speedX = Double(randX) * self.ballSpeed / randV let speedY = Double(randY) * self.ballSpeed / randV ball.physicsBody!.velocity = CGVectorMake(CGFloat(speedX * directionX), CGFloat(speedY)) directionX *= -1 ball.physicsBody!.affectedByGravity = false ball.physicsBody!.restitution = 1.0 ball.physicsBody!.linearDamping = 0 ball.physicsBody!.friction = 0 ball.physicsBody!.allowsRotation = false ball.physicsBody!.usesPreciseCollisionDetection = true self.addChild(ball) self.balls.append(ball) } } override func update(currentTime: NSTimeInterval) { if balls.count > 0 { self.time.text = String(Int(NSDate().timeIntervalSinceDate(self.startTime)*10)) } } override func touchesBegan(touches: NSSet, withEvent event: UIEvent) { if self.balls.count == 0 { startTime = NSDate() addBall() } else { super.touchesBegan(touches, withEvent: event) let touch = touches.anyObject() as UITouch let location = touch.locationInNode(self) let speed: CGFloat = 0.001 let duration = NSTimeInterval(abs(location.x - self.paddle.position.x) * speed) let move = SKAction.moveToX(location.x, duration: duration) self.paddle.runAction(move) } } override func didSimulatePhysics() { var removed = [Int]() for i in 0..<balls.count { let ball = balls[i] if ball.position.y < self.radius * 3 { let sparkNode = NSKeyedUnarchiver.unarchiveObjectWithFile(NSBundle.mainBundle().pathForResource("spark", ofType: "sks")!) as SKEmitterNode sparkNode.position = ball.position sparkNode.xScale = 0.3 sparkNode.yScale = 0.3 self.addChild(sparkNode) let fadeOut = SKAction.fadeOutWithDuration(0.3) let remove = SKAction.removeFromParent() sparkNode.runAction(SKAction.sequence([fadeOut, remove])) removed.append(i) ball.removeFromParent() } else { let threashold = CGFloat(ballSpeed * 0.1) if abs(ball.physicsBody!.velocity.dx) < threashold { let vY = Double(ball.physicsBody!.velocity.dy) * 0.8 ball.physicsBody!.velocity.dx = CGFloat(sqrt(ballSpeed * ballSpeed - vY * vY)) ball.physicsBody!.velocity.dy = CGFloat(vY) } if (abs(ball.physicsBody!.velocity.dy) < threashold) { let vX = Double(ball.physicsBody!.velocity.dx) * 0.8 ball.physicsBody!.velocity.dx = CGFloat(vX) ball.physicsBody!.velocity.dy = CGFloat(sqrt(ballSpeed * ballSpeed - vX * vX)) } } } for i in removed { balls.removeAtIndex(i) } } }