Apple Engine

Apple, iPhone, iOS, その周辺のことについて

WWDC 2017 の SceneKit サンプル Fox 2 を調べる その16

Character.swift 過去の記事

 

最後に残りの関数やプロパティを見てゆく。
ここでは操作キャラクターである Max の状態やその処理、物理判定の処理が書かれている。

 

isAttacking

攻撃の状態。

var isAttacking: Bool {
    return attackCount > 0
}

isAttacking は attackCount が 1 以上なら true を返し攻撃状態。
attackCount の増減は、次の attack 関数で行なっている。

 

attack()

攻撃設定。

func attack() {
    attackCount += 1
    model.animationPlayer(forKey: "spin")?.play()
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        self.attackCount -= 1
    }
    spinParticleAttach.addParticleSystem(spinCircleParticle)
}

attack() が呼ばれると attackCount のカウントアップと Animation Player で設定されている "spin" のキーで呼び攻撃のアニメーションを行い、spinParticleAttach ノードに spinCircleParticle を追加する。

DispatchQueue で 現在から 0.5 秒後に attackCount を -1 を行う遅延実行をする。

 

isWalking

歩行判定。

var isWalking: Bool = false {
    didSet {
        if oldValue != isWalking {
            if isWalking {
                model.animationPlayer(forKey: "walk")?.play()
            } else {
                model.animationPlayer(forKey: "walk")?.stop(withBlendOutDuration: 0.2)
            }
        }
    }
}

isWalking のデフォルトは false で、このファイルの update 関数内で true/false の設定がされている。 値がセットされ、過去の値と異なっており、その値が true であれば Animation Player から "walk" のキーで呼び歩行のアニメーションを行う。 false になった場合(操作が行われなくなった場合)は歩行を停止する。

 

walkSpeed

歩行スピードの設定。
歩行スピードが変わることで歩行アニメーションの再生スピードが変わる。

var walkSpeed: CGFloat = 1.0 {
    didSet {
        let burningFactor: CGFloat = isBurning ? 2: 1
        model.animationPlayer(forKey: "walk")?.speed = Character.speedFactor * walkSpeed * burningFactor
    }
}

walkSpeed は CGFloat で、デフォルトは 1.0。

値を入れると、まず burningFactor に値を設定する。 溶岩に入っているか調べる isBurning が true の場合は 2、入っていない状態の false の場合は 1 を入れる。

そして、animationPlayer(forKey: "walk") で調べた歩行のアニメーションの speed に speedFactor、自身である walkSpeed、ここで設定している burningFactor をかけて他値を設定している。

Max を移動し続けると update 側で加速し、こちらでは歩行用のアニメーション再生速度を上げる。 溶岩に入った場合はさらにアニメーションのスピードがある。

 

characterDirection

キャラクタの方向を設定する。

func characterDirection(withPointOfView pointOfView: SCNNode?) -> float3 {
    let controllerDir = self.direction
    if controllerDir.allZero() {
        return float3.zero
    }
    
    var directionWorld = float3.zero
    if let pov = pointOfView {
        let p1 = pov.presentation.simdConvertPosition(float3(controllerDir.x, 0.0, controllerDir.y), to: nil)
        let p0 = pov.presentation.simdConvertPosition(float3.zero, to: nil)
        directionWorld = p1 - p0
        directionWorld.y = 0
        if simd_any(directionWorld != float3.zero) {
            let minControllerSpeedFactor = Float(0.2)
            let maxControllerSpeedFactor = Float(1.0)
            let speed = simd_length(controllerDir) * (maxControllerSpeedFactor - minControllerSpeedFactor) + minControllerSpeedFactor
            directionWorld = speed * simd_normalize(directionWorld)
        }
    }
    return directionWorld
}

controllerDir に現状の direction を入れ、controllerDir の float3 の値が全て 0 なら float3.zero を返して関数を抜ける。

返り値用に directionWorld 変数を設定し、float3 の値を全て 0。

SceneRenderer の pointOfView を pov に設定。
p1 に controllerDir の x、y を float3 の x、z へ設定し、pov の presentation の simdConvertPosition へ。カメラ位置から Max の方向の算出する。
p0 は float3.zero で pov の presentation の simdConvertPosition を渡し、カメラの場所を取得。

directionWorld に p1 - p0 に渡し、高さは必要ないので Y 軸を 0 する。

SimdExtensions.swift で設定したオペレーター "directionWorld != float3.zero" で float3 同士で比較を行う。 simd_any で値の違いを調べる。 directionWorld は float3.zero でなければ、minControllerSpeedFactor、maxControllerSpeedFactor で Max の速度を取り、 speed で controllerDir からベクトルの長さに、maxControllerSpeedFactor と minControllerSpeedFactor に引いた分を掛け、初期値の minControllerSpeedFactor を足すことで、移動のスピードを取る。

最後に directionWorld へ speed と 正規化した directionWorld 掛け、if 分を抜けて、 directionWorld を float3 として返す。

 

resetCharacterPosition

Max を initialPosition を使って初期位置に戻し、下方向へ加速度である downwardAcceleration を 0 にしてリセットする。
Max の位置をリセットする。

func resetCharacterPosition() {
    characterNode.simdPosition = Character.initialPosition
    downwardAcceleration = 0
}

 

didHitEnemy()

敵キャラの設定 その1。

didHitEnemy 関数が呼ばれると、Max の攻撃ヒット時の SE "hitEnemySound" を再生。 それと同時に、Action の sequence で、0. 5 秒待ち、敵爆発の SE "explodeEnemySound" を再生する。

func didHitEnemy() {
    model.runAction(SCNAction.group(
        [SCNAction.playAudio(hitEnemySound, waitForCompletion: false),
            SCNAction.sequence(
            [SCNAction.wait(duration: 0.5),
                SCNAction.playAudio(explodeEnemySound, waitForCompletion: false)
            ])
        ]))
}

 

wasTouchedByEnemy()

敵キャラの設定 その2。

現在の時間より time より lastHitTime + 1 秒より大きい場合、

現在の時間を lastHitTime 入れ、敵に触れた時の SE "hitSound" を再生し、 4回 Max の透明度を点滅させる。

func wasTouchedByEnemy() {
    let time = CFAbsoluteTimeGetCurrent()
    if time > lastHitTime + 1 {
        lastHitTime = time
        model.runAction(SCNAction.sequence([
            SCNAction.playAudio(hitSound, waitForCompletion: false),
            SCNAction.repeat(SCNAction.sequence([
                SCNAction.fadeOpacity(to: 0.01, duration: 0.1),
                SCNAction.fadeOpacity(to: 1.0, duration: 0.1)
                ]), count: 4)
            ]))
    }
}

 

loadAnimation()

アニメーションをロードする際のユーティリティ関数。

class func loadAnimation(fromSceneNamed sceneName: String) -> SCNAnimationPlayer {
    let scene = SCNScene( named: sceneName )!
    
    var animationPlayer: SCNAnimationPlayer! = nil
    scene.rootNode.enumerateChildNodes { (child, stop) in
        if !child.animationKeys.isEmpty {
            animationPlayer = child.animationPlayer(forKey: child.animationKeys[0])
            stop.pointee = true
        }
    }
    return animationPlayer
}

scene に引数である sceneName のシーン名を入れ、空の SCNAnimationPlayer を animationPlayer として設定。
scene.rootNode.enumerateChildNodes を使い調べていき animationKeys があるものを探し、 見つかると animationPlayer を返す。

 

slideInWorld()

Max の障害物との形状の物理判定処理。

func slideInWorld(fromPosition start: float3, velocity: float3) {
    let maxSlideIteration: Int = 4
    var iteration = 0
    var stop: Bool = false

    var replacementPoint = start

    var start = start
    var velocity = velocity
    let options: [SCNPhysicsWorld.TestOption: Any] = [
        SCNPhysicsWorld.TestOption.collisionBitMask: Bitmask.collision.rawValue,
        SCNPhysicsWorld.TestOption.searchMode: SCNPhysicsWorld.TestSearchMode.closest]
    while !stop {
        var from = matrix_identity_float4x4
        from.position = start

        var to: matrix_float4x4 = matrix_identity_float4x4
        to.position = start + velocity

        let contacts = physicsWorld!.convexSweepTest(
            with: characterCollisionShape!,
            from: SCNMatrix4(from),
            to: SCNMatrix4(to),
            options: options)
        if !contacts.isEmpty {
            (velocity, start) = handleSlidingAtContact(contacts.first!, position: start, velocity: velocity)
            iteration += 1

            if simd_length_squared(velocity) <= (10E-3 * 10E-3) || iteration >= maxSlideIteration {
                replacementPoint = start
                stop = true
            }
        } else {
            replacementPoint = start + velocity
            stop = true
        }
    }
    characterNode!.simdWorldPosition = replacementPoint - collisionShapeOffsetFromModel
}

maxSlideIteration で 4、iteration を初期化、止まっていフラグの stop を false にしている。

変更位置の replacementPoint に引数の start、start に start、 速度である velocity に引き数の velocity を入れている。

stop が true になるまでループをかけ、 from、to 共に 0 で float4x4 の 16 の数値を持つ 4x4 の行列を設定。 from.position に start、to.position に start と velocity 足したものを入れている。

contacts に入れる convexSweepTest は凸形状のものが動いた際の物理シミュレーション結果を導く。 characterCollisionShape を元に from は現在の場所、to は動いた先を設定する。
物理判定のオプションとして options のコリジョンのビットマスクへ GameController.swift で設定されている BitMask.collision を設定し、 searchMode は直近を選択する closest を設定している。

contacts が宮中に浮いているなどで空でなければ、次の関数 handleSlidingAtContact から velocity と start のタプルを渡し、iteration を増加。
(10E-3 * 10E-3) より simd_length_squared(velocity) が小さい、または iteration が maxSlideIteration より大きい場合は replacementPoint を start にして、stop を true にしループを抜ける。

contacts が空の場合は start と velocity 足したものを replacementPoint に入れ直進状態。stop を true にしてループを抜ける。

characterNode の simdWorldPosition に replacementPoint に Max に対してのコリジョンのオフセットである collisionShapeOffsetFromModel を引いて Max 位置を変更する。

 

handleSlidingAtContact()

SCNPhysicsContact と 現在位置の start、速度の velocity 渡して、障害物と衝突しない目的の場所とコライダーの接触位置を返す。

private func handleSlidingAtContact(_ closestContact: SCNPhysicsContact, position start: float3, velocity: float3)
    -> (computedVelocity: simd_float3, colliderPositionAtContact: simd_float3) {
    let originalDistance: Float = simd_length(velocity)

    let colliderPositionAtContact = start + Float(closestContact.sweepTestFraction) * velocity

    let slidePlaneNormal = float3(closestContact.contactNormal)
    let slidePlaneOrigin = float3(closestContact.contactPoint)
    let centerOffset = slidePlaneOrigin - colliderPositionAtContact

    let destinationPoint = slidePlaneOrigin + velocity

    let distPlane = simd_dot(slidePlaneOrigin, slidePlaneNormal)

    var t = planeIntersect(planeNormal: slidePlaneNormal, planeDist: distPlane,
                            rayOrigin: destinationPoint, rayDirection: slidePlaneNormal)

    let normalizedVelocity = velocity * (1.0 / originalDistance)
    let angle = simd_dot(slidePlaneNormal, normalizedVelocity)

    var frictionCoeff: Float = 0.3
    if fabs(angle) < 0.9 {
        t += 10E-3
        frictionCoeff = 1.0
    }
    let newDestinationPoint = (destinationPoint + t * slidePlaneNormal) - centerOffset

    let computedVelocity = frictionCoeff * Float(1.0 - closestContact.sweepTestFraction)
        * originalDistance * simd_normalize(newDestinationPoint - start)

    return (computedVelocity, colliderPositionAtContact)
}

originalDistance で初期位置の長さを simd_length(velocity) で取得す流。 velocity は update 関数の simd_length_squared(characterVelocity) で取得。 characterVelocity は同じく update関数の float3(groundMove.x, 0, groundMove.z) からで groundMove は地面の移動距離から算出されている。

colliderPositionAtContact はコライダーの接触位置として引数の start、物理判定から得る sweepTestFraction に引数の velocity 掛けたのち足している。

地面に対して滑走している状態を計算するため、slidePlaneNormal に物理判定の法線、slidePlaneOrigin で物理判定から得た障害物の接触点を入れ、centerOffset では slidePlaneOrigin から colliderPositionAtContact を引いてオフセット値を計算している。

destinationPoint は slidePlaneOrigin と velocity 足して、障害物の接触点を基準にして目的地を計算を行う。

distPlane では SIMD の内積の計算を使用し simd_dot(slidePlaneOrigin, slidePlaneNormal) となっている。これを使うことで移動目的地点を障害物を滑走する面の設定している。

t に、この Swift ファイルの最初にあった planeIntersect 関数に slidePlaneNormal, distPlane, destinationPoint, slidePlaneNormal を渡し地面とキャラクターの接点を求める。

normalizedVelocity では originalDistance を元に velocity の正規化を行ない、 angle では slidePlaneNormal, normalizedVelocity の内積から角度を出している。

frictionCoeff では摩擦係数を 0.3 に設 angle の絶対値が 0.9 以下なら t に 10E-定。3 を足し、frictionCoeff を 1.0 にする。

新しい目的地の位置として newDestinationPoint に (destinationPoint + t * slidePlaneNormal) - centerOffset を渡している。

そして、近くのポイントへの開始位置を computedVelocity へ計算結果を入れている。

computedVelocity, colliderPositionAtContact をタプルで返し、slideInWorld 関数で処理を行う。

 

次回は PlayerComponent、ChaserComponent、ScaredComponent の元となっている GKComponent の実装と GKAgent2D の extension が書かれている BaseComponent.swift を見てゆく。