WWDC 2017 の SceneKit サンプル Fox 2 を調べる その37
iOS 用のバーチャルパッドについて見てゆく。
今回は Swift ファイルの構成と振る舞いについて。
ファイルの構成
ファイル名 | 内容 |
---|---|
ButtonOverlay.swift | ControlOverlay.swift で使用するボタン用の ButtonOverlay クラス |
PadOverlay.swift | ControlOverlay.swift で使用するヴァーチャルパッド用の PadOverlay クラス |
ControlOverlay.swift | ボタンとヴァーチャルパッドで設定し Overlay.swift ボタンでこちらを設定する |
ButtonOverlay.swift
ButtonOverlay クラスは SpriteKit の SKNode で設定され A、B ボタンで使い回せる様につくられており、
ButtonOverlay.swift は最終的に GameController で操作できる様にするデリゲート ButtonOverlayDelegate と ButtonOverlay クラスで構成されている。
ButtonOverlayDelegate
ボタンが押されたかをデリゲートで渡す関数設定されており、最終的に GameController クラスでこちらが設定されている。
ここでは ButtonOverlay クラスでタッチイベントからアクセスしている。
func willPress(_ button: ButtonOverlay) func didPress(_ button: ButtonOverlay)
ButtonOverlay クラス
クラスの中身を見てゆく。
初期設定
- ボタンのサイズを決める
- デリゲートの設定
- UITouch からタッチの取得
- inner 変数で SKShapeNode を使いボタン自体を設定。タップ時の色変え用
- background 変数で SKShapeNode を使いボタンの背景を設定
- label 変数で SKShapeNode を使いボタン内のテキストを設定
var size = CGSize.zero { didSet { if size != oldValue { updateForSizeChange() } } } weak var delegate: ButtonOverlayDelegate? private var trackingTouch: UITouch? private var inner: SKShapeNode! private var background: SKShapeNode! private var label: SKLabelNode?
イニシャライズ
- サイズを 40 で設定
- 透明度を 0.7
- ユーザー操作を許可
- buildPad 関数でボタンを作成、引数の文字列を渡しボタンに文字を表示する
init(_ text: NSString) { super.init() size = CGSize(width: CGFloat(40), height: CGFloat(40)) alpha = 0.7 isUserInteractionEnabled = true buildPad(text) }
ボタンの作成
背景の background ノード > タップ時に色を変える inner ノート > 文字を表示する label ノードの重なり順で設定されている。
background と inner は SKShapeNode に CGPath の円が渡され描画しており、 label SKShapeNode で文字を表示している。
func buildPad(_ text: NSString) { let backgroundRect = CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(size.width), height: CGFloat(size.height)) background = SKShapeNode() background.path = CGPath( ellipseIn: backgroundRect, transform: nil) background.strokeColor = SKColor.black background.lineWidth = 3.0 addChild(background) inner = SKShapeNode() inner.path = CGPath( ellipseIn: CGRect( x: 0, y: 0, width: innerSize.width, height: innerSize.height ), transform: nil) inner.lineWidth = 1.0 inner.fillColor = SKColor.white inner.strokeColor = SKColor.gray addChild(inner) label = SKLabelNode() label!.fontName = UIFont.boldSystemFont(ofSize: 24).fontName label!.fontSize = 24 label!.fontColor = SKColor.black label?.verticalAlignmentMode = .center label?.horizontalAlignmentMode = .center label?.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0 + 1.0) label?.text = text as String addChild(label!) }
updateForSizeChange
ボタンの大きさがに変更がかかった場合こちらが呼ばれ backgroundRect、innerRect、label の大きさと配置が変更される。
処理の初めでこのボタンに背景の background ノードがなかった場合、この処理を抜ける様になっている。
func updateForSizeChange() { guard let background = background else { return } let backgroundRect = CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(size.width), height: CGFloat(size.height)) background.path = CGPath(ellipseIn: backgroundRect, transform: nil) let innerRect = CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(size.width / 3.0), height: CGFloat(size.height / 3.0)) inner.path = CGPath(ellipseIn: innerRect, transform: nil) label!.position = CGPoint(x: size.width / 2.0 - label!.frame.size.width / 2.0, y: size.height / 2.0 - label!.frame.size.height / 2.0) }
ボタンのサイズ取得
ボタンのサイズ取得様に設定したボタンの大きさを返す。
var innerSize: CGSize { return CGSize(width: CGFloat(size.width), height: CGFloat(size.height)) }
操作の初期化
最初にタッチされたイベントを保持する trackingTouch を空にして、inner の塗りつぶしを元に戻し、delegate の didPress に関数を渡す。
func resetInteraction() { trackingTouch = nil inner.fillColor = SKColor.white delegate!.didPress(self) }
タッチイベント
touchesBegan、touchesEnded、touchesCancelled が設定されている。
touchesBegan では、自身を初めにタッチされたものの保持、inner の fillColor を暗くして押されたh状態にして、willPress 関数に自身を渡す
touchesEnded と touchesCancelled は処理は同じで、resetInteraction を呼び出している。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { trackingTouch = touches.first inner.fillColor = SKColor.black delegate!.willPress(self) } override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { if touches.contains(trackingTouch!) { resetInteraction() } } override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { if touches.contains(trackingTouch!) { resetInteraction() } }
PadOverlay.swift
PadOverlay クラスは SpriteKit の SKNode で設定され A、B ボタンで使い回せる様につくられており、
ButtonOverlay.swift は最終的に GameController で操作できる様にするデリゲート ButtonOverlayDelegate と ButtonOverlay クラスで構成されている。
PadOverlayDelegate
ButtonOverlay 同様にヴァーチャルパッドの操作をデリゲートで渡す関数設定されており、最終的に GameController クラスでこちらが設定されている。
ここでは PadOverlay クラスでタッチイベントからアクセスしている。
func padOverlayVirtualStickInteractionDidStart(_ padNode: PadOverlay) func padOverlayVirtualStickInteractionDidChange(_ padNode: PadOverlay) func padOverlayVirtualStickInteractionDidEnd(_ padNode: PadOverlay)
PadOverlay クラス
クラスの中身を見てゆく。
初期設定
- 全体のサイズ
- ヴァーチャルパッドのスティック部分の初期値。-1 〜 1 の間の値
- デリゲートの設定
- UITouch からタッチの取得
- startLocation で初期位置
- stick 変数で SKShapeNode を使いスティックを設定
- background 変数で SKShapeNode を使いボタンの背景を設定
var size = CGSize.zero { didSet { if size != oldValue { updateForSizeChange() } } } var stickPosition = CGPoint.zero { didSet { if stickPosition != oldValue { updateStickPosition() } } } weak var delegate: PadOverlayDelegate? private var trackingTouch: UITouch? private var startLocation = CGPoint.zero private var stick: SKShapeNode! private var background: SKShapeNode!
イニシャライズ
- サイズを 150 で設定
- 透明度を 0.7
- ユーザー操作を許可
- buildPad 関数でパッドを作成し表示する
init(_ text: NSString) { super.init() size = CGSize(width: CGFloat(150), height: CGFloat(150)) alpha = 0.7 isUserInteractionEnabled = true buildPad() }
buildPad
background で背景として CGPath で円をかいた SKShapeNode が設定されており、 stick にも CGPath で円をかいた SKShapeNode が設定されている。
コメントアウトで #if os( OSX ) が設定されているが macOS ではヴァーチャルパッドが使われることはない。
func buildPad() { let backgroundRect = CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(size.width), height: CGFloat(size.height)) background = SKShapeNode() background.path = CGPath( ellipseIn: backgroundRect, transform: nil ) background.strokeColor = SKColor.black background.lineWidth = 3.0 addChild(background) var stickRect = CGRect.zero stickRect.size = stickSize stick = SKShapeNode() stick.path = CGPath( ellipseIn: stickRect, transform: nil) stick.lineWidth = 2.0 //#if os( OSX ) stick.fillColor = SKColor.white //#endif stick.strokeColor = SKColor.black addChild(stick) updateStickPosition() }
stickSize
スティックのサイズを全体の 1/3 として返し設定させる。
var stickSize: CGSize { return CGSize( width: size.width / 3.0, height: size.height / 3.0) }
update 関連
ボタンと同様にサイズが変更されると各パーツが変更される。
また updateStickPosition はスティックの移動と位置の変更値を stickPosition に渡す。
- updateForSizeChange は全体と背景となる円。
- updateStickPosition はスティックの SKNode の初期位置
- updateStickPosition はスティック stickPosition 変数が変更される
func updateForSizeChange() { guard let background = background else { return } let backgroundRect = CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(size.width), height: CGFloat(size.height)) background.path = CGPath( ellipseIn: backgroundRect, transform: nil) let stickRect = CGRect(x: CGFloat(0), y: CGFloat(0), width: CGFloat(size.width / 3.0), height: CGFloat(size.height / 3.0)) stick.path = CGPath( ellipseIn: stickRect, transform: nil) } func updateStickPosition() { let stickSize: CGSize = self.stickSize let stickX = size.width / 2.0 - stickSize.width / 2.0 + size.width / 2.0 * stickPosition.x let stickY = size.height / 2.0 - stickSize.height / 2.0 + size.width / 2.0 * stickPosition.y stick.position = CGPoint(x: stickX, y: stickY) } func updateStickPosition(forTouchLocation location: CGPoint) { var l_vec = vector_float2( x: Float( location.x - startLocation.x ), y: Float( location.y - startLocation.y ) ) l_vec.x = (l_vec.x / Float( size.width ) - 0.5) * 2.0 l_vec.y = (l_vec.y / Float( size.height ) - 0.5) * 2.0 if simd_length_squared(l_vec) > 1 { l_vec = simd_normalize(l_vec) } stickPosition = CGPoint( x: CGFloat( l_vec.x ), y: CGFloat( l_vec.y ) ) }
操作の初期化
スティックとスティックの初期位置
func resetInteraction() { stickPosition = CGPoint.zero trackingTouch = nil startLocation = CGPoint.zero delegate!.padOverlayVirtualStickInteractionDidEnd(self) }
タッチイベント: タッチ
ヴァーチャルパッドの触った際の処理
- trackingTouch に最初のタッチを保持する
- trackingTouch からタッチされた位置を取り、startLocation に渡す
- 中心が円の真ん中にあるため、タッチ位置を中心にせってするため width と height 割った数を startLocation の X、Y へ渡す。
- updateStickPosition 関数に位置を渡し stickPosition を更新する
- padOverlayVirtualStickInteractionDidStart に自身を設定する。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { trackingTouch = touches.first startLocation = trackingTouch!.location(in: self) startLocation.x -= size.width / 2.0 startLocation.y -= size.height / 2.0 updateStickPosition(forTouchLocation: trackingTouch!.location(in: self)) delegate!.padOverlayVirtualStickInteractionDidStart(self) }
タッチイベント: ムーブ
ヴァーチャルパッドのスティックを移動させた際の処理。
タッチイベントの touches に trackingTouch があれば、updateStickPosition を実行し trackingTouch の位置を渡している。
updateStickPosition で startLocation と比較し移動の値を渡すことになる。
padOverlayVirtualStickInteractionDidChange に自身を設定する。
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { if touches.contains(trackingTouch!) { updateStickPosition(forTouchLocation: trackingTouch!.location(in: self)) delegate!.padOverlayVirtualStickInteractionDidChange(self) } }
タッチイベント: エンド、キャンセル
エンド、キャンセル共に処理は同じ。
タッチイベントの touches に trackingTouch があれば、resetInteraction 関数を実行し、スティック等をリセットする。
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { if touches.contains(trackingTouch!) { resetInteraction() } } override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { if touches.contains(trackingTouch!) { resetInteraction() } }
ControlOverlay.swift
ControlOverlay クラスのみのファイル。
ボタンのマージンを 25 として、
キャラクターの移動を leftPad と カメラ視点変更を rightPad として PadOverlay クラスに設定。
ジャンプを buttonA、攻撃を buttonB として ButtonOverlay をボタンとして設定している。
イニシャライズは画面サイズを引数として取り、それを元に上記の leftPad、rightPad、buttonA、buttonB の位置を設定している。
class ControlOverlay: SKNode { let buttonMargin = CGFloat( 25 ) var leftPad = PadOverlay() var rightPad = PadOverlay() var buttonA = ButtonOverlay("A") var buttonB = ButtonOverlay("B") init(frame: CGRect) { super.init() leftPad.position = CGPoint(x: CGFloat(20), y: CGFloat(40)) addChild(leftPad) rightPad.position = CGPoint(x: CGFloat(frame.size.width - 20 - rightPad.size.width), y: CGFloat(40)) addChild(rightPad) let buttonDistance = rightPad.size.height / CGFloat( 2 ) + buttonMargin + buttonA.size.height / CGFloat( 2 ) let center = CGPoint( x: rightPad.position.x + rightPad.size.width / 2.0, y: rightPad.position.y + rightPad.size.height / 2.0 ) let buttonAx = center.x - buttonDistance * CGFloat(cosf(Float.pi / 4.0)) - (buttonB.size.width / 2) let buttonAy = center.y + buttonDistance * CGFloat(sinf(Float.pi / 4.0)) - (buttonB.size.height / 2) buttonA.position = CGPoint(x: buttonAx, y: buttonAy) addChild(buttonA) let buttonBx = center.x - buttonDistance * CGFloat(cosf(Float.pi / 2.0)) - (buttonB.size.width / 2) let buttonBy = center.y + buttonDistance * CGFloat(sinf(Float.pi / 2.0)) - (buttonB.size.height / 2) buttonB.position = CGPoint(x: buttonBx, y: buttonBy) addChild(buttonB) } override init() { super.init() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } }
これで一応、Fox2 のプロジェクトの設定はおしまい。
WWDC 2018 後、しばらくしてから、シーンファイルのノードに設定されている Shader Modifier について解説するかもしれない。
WWDC 2017 の SceneKit サンプル Fox 2 を調べる その36
2D の UI である Overlay クラスを見てゆく。
SceneKit の overlay の機能を使い SpriteKit を SKScene を表示している。
Overlay クラスの中身
表示される要素
- 右上のプレイヤーキャラクター Max のアイコン
- 宝石や鍵のアイコン
- クリア時の Congratulations の表示部分
- iOS 用のヴァーチャルパッド
- デモ用のメニュー
以下、コードを見てゆく。
import
SpriteKit が追加でインポートされている。
import Foundation import SceneKit import SpriteKit
クラスの宣言
class Overlay: SKScene { ... }
以下、クラスの中身を見てゆく。
メンバー定数 / 変数
定数 / 変数 | 説明 |
---|---|
overlayNode | このシーンのルートになるノード |
congratulationsGroupNode | クリア時に表示されるものをひとまとめにしたノード |
collectedKeySprite | 取得した鍵のノード |
collectedGemsSprites | 取得した宝石のノード |
demoMenu | Menu表示用の関数 |
controlOverlay | ヴァーチャルパッドを表示するためのノード |
private var overlayNode: SKNode private var congratulationsGroupNode: SKNode? private var collectedKeySprite: SKSpriteNode! private var collectedGemsSprites = [SKSpriteNode]() private var demoMenu: Menu? #if os( iOS ) public var controlOverlay: ControlOverlay? #endif
イニシャライズ
宣言
画面サイズと GameController 受け取って初期化される。
init(size: CGSize, controller: GameController) { ... }
初期設定
- 全体で使用する overlayNode に SKNode を設定
- w、h に引数 CGSize から weight、height を渡す。
- collectedGemsSprites に空の配列を設定
- シーンを scaleMode を resizeFill を使用し画面にフィットさせる。
- シーンに overlayNode を適応する
- overlayNode は X 軸 0、Y 軸 画面の高さにして左下を支点にする
overlayNode = SKNode() super.init(size: size) let w: CGFloat = size.width let h: CGFloat = size.height collectedGemsSprites = [] // Setup the game overlays using SpriteKit. scaleMode = .resizeFill addChild(overlayNode) overlayNode.position = CGPoint(x: 0.0, y: h)
右上のプレイヤーキャラクター Max のアイコン
MaxIcon.png を SKSpriteNode の characterNode に入れ、別で設定されている Button クラスの引数を menuButton に入れ、overlayNode に配置している。
setClickedTarget で toggleMenu 関数を呼びデモ用のメニューを開く
let characterNode = SKSpriteNode(imageNamed: "MaxIcon.png") let menuButton = Button(skNode: characterNode) menuButton.position = CGPoint(x: 50, y: -50) menuButton.xScale = 0.5 menuButton.yScale = 0.5 overlayNode.addChild(menuButton) menuButton.setClickedTarget(self, action: #selector(self.toggleMenu))
宝石
collectableBIG_empty.png を SKSpriteNode の gemNode に入れ、overlayNode に配置している。
collectedGemsSprites の配列に gemNode に入れている。 for 分を使っているが今回使用しているのは1つしかなく1回しかループがまわらない。
for i in 0..<1 { let gemNode = SKSpriteNode(imageNamed: "collectableBIG_empty.png") gemNode.position = CGPoint(x: 125 + i * 80, y: -50) gemNode.xScale = 0.25 gemNode.yScale = 0.25 overlayNode.addChild(gemNode) collectedGemsSprites.append(gemNode) }
鍵
key_empty.png を SKSpriteNode の collectedKeySprite に入れ、overlayNode に配置している。
collectedKeySprite = SKSpriteNode(imageNamed: "key_empty.png") collectedKeySprite.position = CGPoint(x: CGFloat(195), y: CGFloat(-50)) collectedKeySprite.xScale = 0.4 collectedKeySprite.yScale = 0.4 overlayNode.addChild(collectedKeySprite)
iOS の場合の処理
controlOverlay からヴァーチャルパッド ControlOverlay クラスを設定しデリゲートを 引数の controller を渡して、シーンに追加している。
#if os( iOS ) controlOverlay = ControlOverlay(frame: CGRect(x: CGFloat(0), y: CGFloat(0), width: w, height: h)) controlOverlay!.leftPad.delegate = controller controlOverlay!.rightPad.delegate = controller controlOverlay!.buttonA.delegate = controller controlOverlay!.buttonB.delegate = controller addChild(controlOverlay!) #endif
デモ用の UI
デモ用の UI を設定している。 isHidden 指定しているため表示されない。
右上の Max アイコンをタップすると toggleMenu 関数が呼ばれて、このデモ用の UI が表示されるのだが、この UI は画面外に位置しているため表示する際は Menu クラスの設定変更する必要がある。
demoMenu = Menu(size: size) demoMenu!.delegate = controller demoMenu!.isHidden = true overlayNode.addChild(demoMenu!)
デモ用の UI
SceneKit 上で操作するのでインタラクションを切る。
isUserInteractionEnabled = false
layout2DOverlay 関数
クリア時の Congratulations 表示設定を congratulationsGroupNode で設定する。
func layout2DOverlay() { overlayNode.position = CGPoint(x: 0.0, y: size.height) guard let congratulationsGroupNode = self.congratulationsGroupNode else { return } congratulationsGroupNode.position = CGPoint(x: CGFloat(size.width * 0.5), y: CGFloat(size.height * 0.5)) congratulationsGroupNode.xScale = 1.0 congratulationsGroupNode.yScale = 1.0 let currentBbox: CGRect = congratulationsGroupNode.calculateAccumulatedFrame() let margin: CGFloat = 25.0 let bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height) let maximumAllowedBbox: CGRect = bounds.insetBy(dx: margin, dy: margin) let top: CGFloat = currentBbox.maxY - congratulationsGroupNode.position.y let bottom: CGFloat = congratulationsGroupNode.position.y - currentBbox.minY let maxTopAllowed: CGFloat = maximumAllowedBbox.maxY - congratulationsGroupNode.position.y let maxBottomAllowed: CGFloat = congratulationsGroupNode.position.y - maximumAllowedBbox.minY let `left`: CGFloat = congratulationsGroupNode.position.x - currentBbox.minX let `right`: CGFloat = currentBbox.maxX - congratulationsGroupNode.position.x let maxLeftAllowed: CGFloat = congratulationsGroupNode.position.x - maximumAllowedBbox.minX let maxRightAllowed: CGFloat = maximumAllowedBbox.maxX - congratulationsGroupNode.position.x let topScale: CGFloat = top > maxTopAllowed ? maxTopAllowed / top: 1 let bottomScale: CGFloat = bottom > maxBottomAllowed ? maxBottomAllowed / bottom: 1 let leftScale: CGFloat = `left` > maxLeftAllowed ? maxLeftAllowed / `left`: 1 let rightScale: CGFloat = `right` > maxRightAllowed ? maxRightAllowed / `right`: 1 let scale: CGFloat = min(topScale, min(bottomScale, min(leftScale, rightScale))) congratulationsGroupNode.xScale = scale congratulationsGroupNode.yScale = scale }
宝石と鍵の取得処理
宝石の collectedGemsCount は Int の値を入れ、鍵は didCollectKey 関数を実行すると、各取得時の画像に変更され、アニメーションが行われる。
var collectedGemsCount: Int = 0 { didSet { collectedGemsSprites[collectedGemsCount - 1].texture = SKTexture(imageNamed:"collectableBIG_full.png") collectedGemsSprites[collectedGemsCount - 1].run(SKAction.sequence([ SKAction.wait(forDuration: 0.5), SKAction.scale(by: 1.5, duration: 0.2), SKAction.scale(by: 1 / 1.5, duration: 0.2) ])) } } func didCollectKey() { collectedKeySprite.texture = SKTexture(imageNamed:"key_full.png") collectedKeySprite.run(SKAction.sequence([ SKAction.wait(forDuration: 0.5), SKAction.scale(by: 1.5, duration: 0.2), SKAction.scale(by: 1 / 1.5, duration: 0.2) ])) }
iOS での分岐
ヴァーチャルパッドの表示 / 非表示を行う。
主に GameController クラスで呼び出される。
#if os( iOS ) func showVirtualPad() { controlOverlay!.isHidden = false } func hideVirtualPad() { controlOverlay!.isHidden = true } #endif
クリア時の表示
congratulationsNode を設定しつつ、アニメーション設定を行い再生する。
func showEndScreen() { let congratulationsNode = SKSpriteNode(imageNamed: "congratulations.png") let characterNode = SKSpriteNode(imageNamed: "congratulations_pandaMax.png") characterNode.position = CGPoint(x: CGFloat(0.0), y: CGFloat(-220.0)) characterNode.anchorPoint = CGPoint(x: CGFloat(0.5), y: CGFloat(0.0)) congratulationsGroupNode = SKNode() congratulationsGroupNode!.addChild(characterNode) congratulationsGroupNode!.addChild(congratulationsNode) addChild(congratulationsGroupNode!) layout2DOverlay() congratulationsNode.alpha = 0.0 congratulationsNode.xScale = 0.0 congratulationsNode.yScale = 0.0 congratulationsNode.run( SKAction.group([SKAction.fadeIn(withDuration: 0.25), SKAction.sequence([SKAction.scale(to: 1.22, duration: 0.25), SKAction.scale(to: 1.0, duration: 0.1)])])) characterNode.alpha = 0.0 characterNode.xScale = 0.0 characterNode.yScale = 0.0 characterNode.run(SKAction.sequence([SKAction.wait(forDuration: 0.5), SKAction.group([SKAction.fadeIn(withDuration: 0.5), SKAction.sequence([SKAction.scale(to: 1.22, duration: 0.25), SKAction.scale(to: 1.0, duration: 0.1)])])])) }
その他
トグルで toggleMenu 関数から demoMenu の isHidden 呼び非表示にする。
@objc func toggleMenu(_ sender: Button) { demoMenu!.isHidden = !demoMenu!.isHidden }
次回は iOS のヴァーチャルパッドについて見てゆく。
WWDC 2017 の SceneKit サンプル Fox 2 を調べる その35
引き続き GameController クラスの残りの関数を見てゆく。
今回はゲームコントローラーやゲーム操作部分の関数について。
その前にゲームコントローラーとは?
販売されている MFi のアクセサリのゲームコントローラーは 、上下左右の方向キー、ボタン4つ、アナログスティック2本とその押し込みの入力、LRボンタン各2つの計4つ、ポーズ(ホーム)ボタンの入力となっているが、
Apple はゲームコントローラーの種類は以下のプロファイルで定義されている。
- Extended Gamepad プロファイル
- Micro Gamepad プロファイル
- Motion プロファイル
Extended Gamepad
拡張されたゲームパッドで Bluetooth で接続されたもので iOS, tvOS, macOS で使用できる。
Lightning で接続するゲームパッドもこれにあたり iOS のみで使用可能。
無線のものはバッテリ残量を表示するインジケーターが必要。
Micro Gamepad
Apple TV の Siri Remote を指す。Extended Gamepad と操作できるものの数が違うため併用するが難しくなることがある。
ゲームパッドのドキュメントでは tvOS アプリで Extended Gamepad のみの対応でも可能としているが、できるだけ Siri Remote ので操作できるようにすべしと書かれている。
Motion
加速度センサーなどのコントロール。Siri Remote もこれを使用できる。
その他
現在 MFi のコントローラーで販売されておらず、非推奨の Gamepad プロファイルがある。 以前は Logicool(Logitech) で販売を行なっており、上下左右の方向キー、ボタン4つ、LRボンタン各1つの計2つ、ポーズ(ホーム)ボタンとなっている。
それではコードの方を見てゆく。
ゲームコントローラーからの通知
ゲームコントローラーは、無線、有線にかかわらず端末との接続、切断が行われるので通知の処理が行われる。
ゲームコントローラーが接続された場合、registerGameController 関数に GCController を渡す処理。 ヴーチャルパッドが無ければ消し、GCController が無ければ処理をしない。
@objc func handleControllerDidConnect(_ notification: Notification) { if gamePadCurrent != nil { return } guard let gameController = notification.object as? GCController else { return } registerGameController(gameController) }
ゲームコントローラーの接続が切れたら処理を止める。 他のコントローラーが接続されたら registerGameController で処理をし直す。
@objc func handleControllerDidDisconnect(_ notification: Notification) { guard let gameController = notification.object as? GCController else { return } if gameController != gamePadCurrent { return } unregisterGameController() for controller: GCController in GCController.controllers() where gameController != controller { registerGameController(controller) } }
ゲームコントローラーの登録処理
ゲームパッドの処理は以下のようになっている
- buttonA、buttonB を GCControllerButtonInput として設定。
- Extended Gamepad、Gamepad、Micro Gamepad のプロファイルで分岐
- Gamepad と Micro Gamepad のプロファイルではプレイヤーキャラクター移動の gamePadLeft 変数に dpad(Directional Pad/方向キー)、buttonA、buttonB 変数に A/B(X) ボタンが割り当てされる
- Extended Gamepad では gamePadLeft 変数に leftThumbstick、視点操作の gamePadRight 変数に rightThumbstick を割り当てられている
- GameController 自身である self を weak で宣言する weakController を設定
- gamePadLeft、buttonA、buttonB と gamePadRight があれば、各 valueChangedHandler で strongController に weakController を入れ、キャラクターの移動、ジャンプ、攻撃やカメラ視点変更を設定する。GCController のから変更があると strongController の各命令にその値が渡される。
- #if で iOS の場合で gamePadLeft が空でないの場合、ゲームコントローラーが接続されているので、Overlay からヴァーチャルパッドを非表示にする
func registerGameController(_ gameController: GCController) { var buttonA: GCControllerButtonInput? var buttonB: GCControllerButtonInput? if let gamepad = gameController.extendedGamepad { self.gamePadLeft = gamepad.leftThumbstick self.gamePadRight = gamepad.rightThumbstick buttonA = gamepad.buttonA buttonB = gamepad.buttonB } else if let gamepad = gameController.gamepad { self.gamePadLeft = gamepad.dpad buttonA = gamepad.buttonA buttonB = gamepad.buttonB } else if let gamepad = gameController.microGamepad { self.gamePadLeft = gamepad.dpad buttonA = gamepad.buttonA buttonB = gamepad.buttonX } weak var weakController = self gamePadLeft!.valueChangedHandler = {(_ dpad: GCControllerDirectionPad, _ xValue: Float, _ yValue: Float) -> Void in guard let strongController = weakController else { return } strongController.characterDirection = simd_make_float2(xValue, -yValue) } if let gamePadRight = self.gamePadRight { gamePadRight.valueChangedHandler = {(_ dpad: GCControllerDirectionPad, _ xValue: Float, _ yValue: Float) -> Void in guard let strongController = weakController else { return } strongController.cameraDirection = simd_make_float2(xValue, yValue) } } buttonA?.valueChangedHandler = {(_ button: GCControllerButtonInput, _ value: Float, _ pressed: Bool) -> Void in guard let strongController = weakController else { return } strongController.controllerJump(pressed) } buttonB?.valueChangedHandler = {(_ button: GCControllerButtonInput, _ value: Float, _ pressed: Bool) -> Void in guard let strongController = weakController else { return } strongController.controllerAttack() } #if os( iOS ) if gamePadLeft != nil { overlay!.hideVirtualPad() } #endif }
端末とゲームコントローラーの接続が切れたら、gamePadLeft、gamePadRight、gamePadCurrent を空にして、ヴァーチャルパッドを表示する。
func unregisterGameController() { gamePadLeft = nil gamePadRight = nil gamePadCurrent = nil #if os( iOS ) overlay!.showVirtualPad() #endif }
iOS の場合のバーチャルパッドの操作設定。 プロトコルから設定した PadOverlayDelegate のデリゲートで バーチャルパッド(スティック)の操作の始まりと変更、変更後の際の操作を設定する
ButtonOverlayDelegate のデリゲートでバーチャルパッドでのボタンを押した際と押し終えた処理を行う。 処理はジャンプと攻撃だが、押し終えた場合ジャンプの2度しないように止めている。
#if os( iOS ) func padOverlayVirtualStickInteractionDidStart(_ padNode: PadOverlay) { if padNode == overlay!.controlOverlay!.leftPad { characterDirection = float2(Float(padNode.stickPosition.x), -Float(padNode.stickPosition.y)) } if padNode == overlay!.controlOverlay!.rightPad { cameraDirection = float2( -Float(padNode.stickPosition.x), Float(padNode.stickPosition.y)) } } func padOverlayVirtualStickInteractionDidChange(_ padNode: PadOverlay) { if padNode == overlay!.controlOverlay!.leftPad { characterDirection = float2(Float(padNode.stickPosition.x), -Float(padNode.stickPosition.y)) } if padNode == overlay!.controlOverlay!.rightPad { cameraDirection = float2( -Float(padNode.stickPosition.x), Float(padNode.stickPosition.y)) } } func padOverlayVirtualStickInteractionDidEnd(_ padNode: PadOverlay) { if padNode == overlay!.controlOverlay!.leftPad { characterDirection = [0, 0] } if padNode == overlay!.controlOverlay!.rightPad { cameraDirection = [0, 0] } } func willPress(_ button: ButtonOverlay) { if button == overlay!.controlOverlay!.buttonA { controllerJump(true) } if button == overlay!.controlOverlay!.buttonB { controllerAttack() } } func didPress(_ button: ButtonOverlay) { if button == overlay!.controlOverlay!.buttonA { controllerJump(false) } } #endif
次回は Overlay.swift を見てゆく。
WWDC 2017 の SceneKit サンプル Fox 2 を調べる その34
引き続き GameController クラスの残りの関数を見てゆく。
今回はクリア時の演出、レンダリングのクオリティ、デバッグの関数について。
クリア時の演出
クリア時に Music_victory.mp3 の BGM を 鳴らし、 2D UI の overlay の showEndScreen() 呼び、ロゴと Congratulation の画像を出す。
func showEndScreen() { guard let victoryMusic = SCNAudioSource(named: "audio/Music_victory.mp3") else { return } victoryMusic.volume = 0.5 self.scene?.rootNode.addAudioPlayer(SCNAudioPlayer(source: victoryMusic)) self.overlay?.showEndScreen() }
レンダリングクオリティ設定
いくつかの関数を設定後、イニシャライズ時に呼ぶ configureRenderingQuality の関数でそれらを呼ぶ。
EXR などの tvOS で使用できなものや負荷が多いのを切っている。
以下、設定する関数。
SCNMaterialProperty で EXR の拡張子を png にする。
func turnOffEXRForMAterialProperty(property: SCNMaterialProperty) { if var propertyPath = property.contents as? NSString { if propertyPath.pathExtension == "exr" { propertyPath = ((propertyPath.deletingPathExtension as NSString).appendingPathExtension("png")! as NSString) property.contents = propertyPath } } }
バックグラウンドのテクスチャで turnOffEXRForMAterialProperty を使い EXR を止め、全てのマテリアルで自己発光を止める。
func turnOffEXR() { self.turnOffEXRForMAterialProperty(property: scene!.background) self.turnOffEXRForMAterialProperty(property: scene!.lightingEnvironment) scene?.rootNode.enumerateChildNodes { (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in if let materials = child.geometry?.materials { for material in materials { self.turnOffEXRForMAterialProperty(property: material.selfIllumination) } } } }
全てのノーマルマップを止める。
func turnOffNormalMaps() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in if let materials = child.geometry?.materials { for material in materials { material.normal.contents = SKColor.black } } }) }
全ての HDR の処理を止める。
func turnOffHDR() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in child.camera?.wantsHDR = false }) }
カメラの被写界深度の効果を止める。
func turnOffDepthOfField() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in child.camera?.wantsDepthOfField = false }) }
全てのライトの Shadow Sample を 上限 1 にする。
func turnOffSoftShadows() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in if let lightSampleCount = child.light?.shadowSampleCount { child.light?.shadowSampleCount = min(lightSampleCount, 1) } }) }
全てのポストプロセスを止める。
シャドウキャスケード(カメラ位置によるリアルタイムでのシャドウマップのクオリティの変更)、シャドウマップのサイズを 1024x1024 にする。
func turnOffPostProcess() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in if let light = child.light { light.shadowCascadeCount = 0 light.shadowMapSize = CGSize(width: 1024, height: 1024) } }) }
画面前面の 2D UI 表示を消す。
func turnOffOverlay() { sceneRenderer?.overlaySKScene = nil }
全ての Shader Modifiers を取得し、Geomety のエントリーポイントのシェーダー(Vertex Shader の処理)を消す。
func turnOffVertexShaderModifiers() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in if var shaderModifiers = child.geometry?.shaderModifiers { shaderModifiers[SCNShaderModifierEntryPoint.geometry] = nil child.geometry?.shaderModifiers = shaderModifiers } if let materials = child.geometry?.materials { for material in materials where material.shaderModifiers != nil { var shaderModifiers = material.shaderModifiers! shaderModifiers[SCNShaderModifierEntryPoint.geometry] = nil material.shaderModifiers = shaderModifiers } } }) }
植物の表示を止める。
func turnOffVegetation() { scene?.rootNode.enumerateChildNodes({ (child: SCNNode, _: UnsafeMutablePointer<ObjCBool>) in guard let materialName = child.geometry?.firstMaterial?.name as NSString? else { return } if materialName.hasPrefix("plante") { child.isHidden = true } }) }
tvOS 用に表示クオリティを変えるため、これまで作成した関数を実行する。
SCNView を渡しているが、内部で使用していない。
func configureRenderingQuality(_ view: SCNView) { #if os( tvOS ) self.turnOffEXR() self.turnOffNormalMaps() self.turnOffHDR() self.turnOffDepthOfField() self.turnOffSoftShadows() self.turnOffPostProcess() self.turnOffOverlay() self.turnOffVertexShaderModifiers() self.turnOffVegetation() #endif }
デバッグ処理。
WWDC 2017 のデモ用でそのままでは使用できない。
カメラの F 値、焦点距離、ボタンを押すとカメラの表示位置が変わる。
func fStopChanged(_ value: CGFloat) { sceneRenderer!.pointOfView!.camera!.fStop = value } func focusDistanceChanged(_ value: CGFloat) { sceneRenderer!.pointOfView!.camera!.focusDistance = value } func debugMenuSelectCameraAtIndex(_ index: Int) { if index == 0 { let key = self.scene?.rootNode .childNode(withName: "key", recursively: true) key?.opacity = 1.0 } self.setActiveCamera("CameraDof\(index)") }
次回は GameController の最終部分、ゲームコントローラーやゲーム操作部分の関数について見てゆく。
WWDC 2017 の SceneKit サンプル Fox 2 を調べる その33
引き続き GameController クラスの残りの関数を見てゆく。
今回はフレーム毎に更新される関数について。
Fox2 で使用しているフレーム毎に更新される関数
GameController のプロトコルで設定している様に SCNSceneRenderer と SCNPhysicsContact の2つのデリゲートが行なっている。
SCNSceneRendererDelegate
フレーム毎の描画更新で呼ばれる関数。
以下、行われる内容。
- deltaTime で現在の経過時間を保持し、lastUpdateTime で最終に取得した経過時間を保持する
- friendsAreFree が true なら updateFriends 呼び、仲間が解放され歩行するアニメーションを開始する。
- playingCinematic が true ならこれ以降の処理を飛ばす
- character クラスの update を呼び状態を更新する
- 全ての GKEntity の update 関数を更新する
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { if lastUpdateTime == 0 { lastUpdateTime = time } let deltaTime: TimeInterval = time - lastUpdateTime lastUpdateTime = time if friendsAreFree { updateFriends(deltaTime: deltaTime) } if playingCinematic == true { return } character!.update(atTime: time, with: renderer) for entity: GKEntity in gkScene!.entities { entity.update(deltaTime: deltaTime) } }
SCNPhysicsContactDelegate
フレーム毎の物理判定で呼ばれる関数
以下、行われる内容。
- nodeA, nodeB の categoryBitMask が Bitmask.trigger であった場合、trigger 関数に該 当するノードを渡し関数を呼び出す。
- nodeA, nodeB の categoryBitMask が Bitmask.collectable であった場合、collect 関数に該当するノードを渡し関数を呼び出す。
func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) { if contact.nodeA.physicsBody!.categoryBitMask == Bitmask.trigger.rawValue { trigger(contact.nodeA) } if contact.nodeB.physicsBody!.categoryBitMask == Bitmask.trigger.rawValue { trigger(contact.nodeB) } if contact.nodeA.physicsBody!.categoryBitMask == Bitmask.collectable.rawValue { collect(contact.nodeA) } if contact.nodeB.physicsBody!.categoryBitMask == Bitmask.collectable.rawValue { collect(contact.nodeB) } }
次回はクリア時の表示、レンダリングのクオリティ設定の関数について見てゆく。
WWDC 2017 の SceneKit サンプル Fox 2 を調べる その32
引き続き GameController クラスの残りの関数を見てゆく。
今回はプレイヤーキャラクター Max のコントロールについて。
コントロールについて
キャラクターのコントロールはバーチャルパッド、ゲームコントローラー、キーボードからの入力から以下の関数が呼ばれる。
ジャンプ
バーチャルパッドの A ボタンが押された時の処理で Max がジャンプする。
Bool 値が設定されているのは、ジャンプ中にもう一度ジャンプしないため。
func controllerJump(_ controllerJump: Bool) { character!.isJump = controllerJump }
攻撃
バーチャルパッドの B ボタンが押された時の処理で Max が攻撃する。
攻撃状態を調べ、アニメーションが終わるまで次の攻撃ができない。
func controllerAttack() { if !self.character!.isAttacking { self.character!.attack() } }
移動
バーチャルパッドやカーソルで移動を行う。
X、Y 軸の移動をを与える。
get で Max の現在の方向を取得可能。
var characterDirection: vector_float2 { get { return character!.direction } set { var direction = newValue let l = simd_length(direction) if l > 1.0 { direction *= 1 / l } character!.direction = direction } }
カメラ視点変更
バーチャルパッドやキーで視点変更を行う。 操作は 移動と同様。
ここでは設定されていないが特定のカメラでは視点変更ができない。
var cameraDirection = vector_float2.zero { didSet { let l = simd_length(cameraDirection) if l > 1.0 { cameraDirection *= 1 / l } cameraDirection.y = 0 } }
次回はフレーム毎に更新される関数について見てゆく。
WWDC 2017 の SceneKit サンプル Fox 2 を調べる その31
引き続き GameController クラスの残りの関数を見てゆく。
今回は鍵を開けるなどゲームでのアクションについて。
鍵を開ける
unlockDoor 関数も長いので中身を別で見てゆく。
func unlockDoor() { ... }
扉が開いているか調べる
friendsAreFree が true の場合はこの関数を実行しない。 開いても プレイヤーキャラクター Max が少し操作でき、この関数を呼ぶトリガーを弾いてしまうため。
if friendsAreFree { return }
シーンを止める
宝石のアイテム取得時の演出と同様に startCinematic() を使ってシーンを止める
startCinematic()
鍵を開ける音を鳴らす
playSound(AudioSourceKind.unlockDoor)
扉が開く演出
SCNTransaction に対して completionBlock が2つあるため、3段階でアニメーションされる。
以下、アニメーション内容。
1段階目
扉が開くトリガーが引かれると即時で setActiveCamera でカメラ移動のアニメーションが行われる
2段階目
- ドアのノード取得
- ドアのパーティクル配置用のノードを探しパーティクルを置く
- 扉が開いた際の音を鳴らす
- ドアを半透明にする
3段階目
- animateFriends() 呼び仲間を初期状態アニメーションさせる
- friendsAreFree を ture にして SceneRenderer のデリゲートで設定している歩行アニメーションの実行を許可にする
- DispatchQueue から showEndScreen() を呼び、クリア時の画面を表示する。
SCNTransaction.begin() SCNTransaction.animationDuration = 0.0 SCNTransaction.completionBlock = {() -> Void in let door: SCNNode? = self.scene!.rootNode.childNode(withName: "door", recursively: true) let particle_door: SCNNode? = self.scene!.rootNode.childNode(withName: "particles_door", recursively: true) self.addParticles(with: .unlockDoor, withTransform: particle_door!.worldTransform) self.playSound(.collectBig) //add friends SCNTransaction.begin() SCNTransaction.animationDuration = 0.0 self.addFriends(GameController.NumberOfFiends) SCNTransaction.commit() //open the door SCNTransaction.begin() SCNTransaction.animationDuration = 1.0 SCNTransaction.completionBlock = {() -> Void in self.animateFriends() self.friendsAreFree = true DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(1.0 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: {() -> Void in self.showEndScreen() }) } door!.opacity = 0.0 SCNTransaction.commit() } setActiveCamera("CameraCinematic02", animationDuration: 1.0) SCNTransaction.commit()
鍵のアイテムを表示する
鍵のアイテムを表示する処理をする関数。
- 鍵表示用のフラグを true にする
- 鍵のノードを探し、key に渡す
- 鍵を表示した時の音を鳴らす
- 鍵を表示する前のパーティクルを設定し再生
- 鍵のノードの透明度を 1 にして出現させる
- completionBlock から DispatchQueue で keyDidAppear() 呼び処理を終える。
func showKey() { keyIsVisible = true let key: SCNNode? = scene!.rootNode.childNode(withName: "key", recursively: true) playSound(AudioSourceKind.collectBig) addParticles(with: .keyApparition, withTransform: key!.worldTransform) SCNTransaction.begin() SCNTransaction.animationDuration = 1.0 SCNTransaction.completionBlock = {() -> Void in DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(2.5 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: {() -> Void in self.keyDidAppear() }) } key!.opacity = 1.0 SCNTransaction.commit() }
鍵のアイテムを表示後の処理
鍵のアイテムを表示後、execTrigger でカメラを元の位置に戻し、 stopCinematic() を一時停止しているシーンを再生する。
func keyDidAppear() { execTrigger(lastTrigger!, animationDuration: 0.75) stopCinematic() }
鍵のアイテムが出現する事前処理
先ほどの showKey 関数が呼ばれる前に keyShouldAppear が呼ばれる。
startCinematic() を呼び、画面を一時停止。
SCNTransaction のアニメーションから setActiveCamera を呼び CameraCinematic01 のカメラに移動。
completionBlock で showKey 関数が呼ぶ。
func keyShouldAppear() { startCinematic() SCNTransaction.begin() SCNTransaction.animationDuration = 0.0 SCNTransaction.completionBlock = {() -> Void in self.showKey() } setActiveCamera("CameraCinematic01", animationDuration: 3.0) SCNTransaction.commit() }
アイテムを取る処理
physicsWorld のデリゲートで Max がアイテムに接触した際呼ばれる関数。
このノードの physicsBody がある場合処理開始。
引数で渡されるノード collectable の名前が "key" なら鍵、"CollectableBig" なら宝石。
鍵の取得
- keyIsVisible が true でなく表示されていない場合は処理を抜ける。(透明度 0 にしているだけなので物理判定は通るため)
- アイテム取得のサウンドを再生
- UI である overlay の didCollectKey 関数を呼び画面左上に鍵を表示する。
- collectedKeys を1増やす
宝石の取得
- collectedGems を1増やす
- アイテム取得のサウンドを再生
- UI である overlay の collectedGemsCount 関数を呼び画面左上に宝石を表示する。
- collectedGems が 1 の場合 DispatchQueue から keyShouldAppear() を呼ぶ。
その後の処理
collectable の physicsBody を nil にして物理判定を削除し、keyApparition のパーディクルを表示して、collectable ノードをシーンから消す。
func collect(_ collectable: SCNNode) { if collectable.physicsBody != nil { if collectable.name == "key" { if !self.keyIsVisible { return } // play sound playSound(AudioSourceKind.collect) self.overlay?.didCollectKey() self.collectedKeys += 1 } //the gems else if collectable.name == "CollectableBig" { self.collectedGems += 1 // play sound playSound(AudioSourceKind.collect) // update the overlay self.overlay?.collectedGemsCount = self.collectedGems if self.collectedGems == 1 { //we collect a gem, show the key after 1 second DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(Int64(0.5 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC), execute: {() -> Void in self.keyShouldAppear() }) } } collectable.physicsBody = nil //not collectable anymore // particles addParticles(with: .keyApparition, withTransform: collectable.worldTransform) collectable.removeFromParentNode() } }
次回はプレイヤーキャラクターのコントロールをする関数を見てゆく。