実牌麻雀専用Apple Watchアプリを作った話【にんにんLT】
はじめに
株式会社ハックツ 代表のどりーです。
今回は年末に開催予定のネタLTイベント「にんにんLT」のためだけに開発した
『実牌麻雀専用Apple Watchアプリ』を紹介します🀄️
前提
開発物について
実用性や操作性、著作権などを全く考えずに作ったジョークアプリです。
現状リリースする予定もありません。
開発環境
環境 | バージョン |
---|---|
Mac OS | Sequoia 15.2 |
Xcode | 16.2 |
Swift | 6.0.3 |
iOS | 18.2 |
WatchOS | 10.6.1 |
*執筆時の最新ver
機能
今回は2機能実装してみました。
1.ざわ・・・ざわ・・・心拍数計測機能
心拍数を計測し、一定数値以上の心拍数になったとき上記画像のようにざわ・・・ざわ・・・します。
また、画像出現時にざわ・・・ざわ・・・音声も再生しています。
2.超煽り!ツモ演出機能
ツモ演出動画をAppleWatch上で再生することができます。
また、リーチボタンを押した後、勢いよくツモる or 手を素早く振る とツモ音声のみ再生されます。
*Watchを装着している手に合わせます。
実装
それぞれどのように実装しているのか紹介していきます。
心拍数計測
Appleが提供している公式フレームワークHealthKit
を使用しています。
セットアップ(導入)
HealthKitを使用する際には実装以外にXcode上でセットアップを行う必要があります。
プロジェクト → TARGETS → {プロジェクト名} にアクセスして下記3工程を行ってください。
-
Info → “Privacy – Health Update(/Share/Record) Usage Description”を追加
Privacy – Healthから始まる3つ全部入れておけば安心。(実際は使用するものだけでいいです。)
Valueにはユーザーへ表示されるメッセージになりますので、いい感じに入れてください。
-
General → Frameworks, Libraries, and Embedded Content タブ内の "+"から”HealthKit.framework”を追加
-
Signing & Capabilities → ”+ Capability”をクリックして”HealthKit”を追加
実装は amorosoluciano さんのコードをコピペ参考にさせていただきました。
部分ごとに紹介
WatchにHealthKitを承認してもらうための通知を飛ばす関数
.heartRate
→心拍数
なのでこれをHealthKitのtypeにセットします。
そしてrequestAuthorization(toShare:read:)
を呼び出してHealthKitStore
にアクセス権限を要求します。
{}の中にエラーハンドリングを書いておくとより安全です。
また、SwiftUIではhealthDataAccessRequest(store:shareTypes:readTypes:trigger:completion:)
を用いてViewの中にmodifierとして書くこともできます。
(自分はView部分が長くなりすぎてあんまり好きじゃないので参考のように関数上で実装する派です)
func autorizeHealthKit() {
let healthKitTypes: Set = [
HKObjectType.quantityType(forIdentifier:HKQuantityTypeIdentifier.heartRate)!]
healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { _, _ in }
}
心拍数を受信するクエリ
startHeartRateQuery(quantityTypeIdentifier: .heartRate)
で心拍数の受信を行います。
例えばここを.stepCount
にすると歩数計測ができます。
startHeartRateQuery
ではクエリを設定していきます。
- クエリに使用するPredicateを設定(クエリのフィルターみたいなもの)
今回は自分のデバイス(Watch)に限定 - クエリに使用するUpdateを設定(変更を監視)
- process関数に関する設定
- クエリを生成
- healthStoreを上記クエリで実行
func start() {
autorizeHealthKit()
startHeartRateQuery(quantityTypeIdentifier: .heartRate)
}
private func startHeartRateQuery(quantityTypeIdentifier: HKQuantityTypeIdentifier) {
// 1
let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()])
// 2
let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = {
query, samples, deletedObjects, queryAnchor, error in
// 3
guard let samples = samples as? [HKQuantitySample] else {
return
}
self.process(samples, type: quantityTypeIdentifier)
}
// 4
let query = HKAnchoredObjectQuery(type: HKObjectType.quantityType(forIdentifier: quantityTypeIdentifier)!, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler)
query.updateHandler = updateHandler
// 5
healthStore.execute(query)
}
心拍数をprocess関数で受信
process関数でwatchから検出された最後のbpm値を含む心拍数を検出します。
このself.value
の値をSwiftUI上で表示させたりしている。
private func process(_ samples: [HKQuantitySample], type: HKQuantityTypeIdentifier) {
var lastHeartRate = 0.0
for sample in samples {
if type == .heartRate {
lastHeartRate = sample.quantity.doubleValue(for: heartRateQuantity)
}
self.value = Int(lastHeartRate)
}
}
音声再生
公式ライブラリであるAVFAudio
とAVFoundation
を使用して実装しています。
こちらはimportするだけでOK!
SwiftUIで音声再生起動
先ほどの心拍数情報value
を用いて画像の表示/非表示をコントールし、
画像表示時に音声再生関数を実行します。
.onAppear
はオブジェクトが表示される時に処理実行します。
今回の実装ではif
を用いて心拍数value
が一定数を超えた時にImageを表示させ、
その度に.onAppear
によってsetAudioSession()
とsoundPlayer.musicPlay()
が呼ばれるようにしました。
if value > 100 {
Image("zawazawa")
.renderingMode(.original)
.resizable()
.frame(width: 128, height: 72)
.onAppear {
setAudioSession()
soundPlayer.musicPlay(flagNum: 1)
}
}
setAudioSessionでオーディオ再生の設定を指定
SwiftUIで呼び出しているsetAudioSession()
ではオーディオ再生の設定を指定しています。
ここをちゃんと設定することで音声再生の挙動を安定させます。
設定次第では背景音(他の音とミックスされる)にすることができたりします。
今回は音楽トラックの再生に適したplayback
に設定しています。
これに設定するとマナーモード(消音モード)でも音を鳴らすことができます。
import Foundation
import AVFAudio
func setAudioSession(){
let audioSession = AVAudioSession.sharedInstance()
do {
// カテゴリの設定
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
// AVAudioSessionの有効化
try audioSession.setActive(true)
} catch {
print(error)
}
}
AVAudioPlayerで音声再生
AVFoundation
ライブラリのAVAudioPlayer
を用いてAssetsに登録した音声ファイルを再生します。
Assetsへはドラッグ&ドロップで入れるだけでOK!
今回は複数の音声を使用するので関数を呼び出すときにどの音声ファイルかを指定できるように実装しています。
import UIKit
import AVFoundation
class zawaPlayer: NSObject {
//assetsに入れた音声ファイルを指定
let musicDataShort = NSDataAsset(name: "zawa")!.data
let musicDataLong = NSDataAsset(name: "zawazawazawa")!.data
let musicDataReach = NSDataAsset(name: "reach")!.data
let musicDataTsumoWashizu = NSDataAsset(name: "tsumoWashizu")!.data
var player: AVAudioPlayer!
func musicPlay(flagNum: Int) {
do {
if flagNum == 1 {
player = try AVAudioPlayer(data: musicDataLong)
} else if flagNum == 2 {
player = try AVAudioPlayer(data: musicDataShort)
} else if flagNum == 3 {
player = try AVAudioPlayer(data: musicDataReach)
} else if flagNum == 4 {
player = try AVAudioPlayer(data: musicDataTsumoWashizu)
}
player.play()
} catch {
print("Error")
}
}
}
SwiftUI全体コード
あえてelse if
にしていないのは、ざわざわ画像を2つ表示して焦っている感をより演出するためです。
struct zawazawaView: View {
private var healthStore = HKHealthStore()
let heartRateQuantity = HKUnit(from: "count/min")
@State private var value = 0
let soundPlayer = zawaPlayer()
var body: some View {
VStack{
HStack{
if value > 100 {
Image("zawazawa")
.renderingMode(.original)
.resizable()
.frame(width: 128, height: 72)
.onAppear {
setAudioSession()
soundPlayer.musicPlay(flagNum: 1)
}
}
if value > 80 {
Image("zawazawa")
.renderingMode(.original)
.resizable()
.frame(width: 128, height: 72)
.onAppear {
setAudioSession()
soundPlayer.musicPlay(flagNum: 2)
}
} else {
Text("❤️")
.font(.system(size: 50))
}
Spacer()
}
HStack{
Text("\(value)")
.fontWeight(.regular)
.font(.system(size: 50))
Text("BPM")
.font(.headline)
.fontWeight(.bold)
.foregroundColor(Color.red)
.padding(.bottom, 28.0)
Spacer()
}
}
.padding()
.onAppear(perform: start)
}
//以下省略(心拍数の関数)
}
動画再生
公式ライブラリのAVKit
とAVFoundation
を使用して実装しています。
音声とは異なり、オブジェクト(Viewで表示できる物体)が存在するため、
AVKit
のVideoPlayer(player: )
でViewを作ります。
また、動画ファイルの指定が音声ファイルとは参照が異なるので注意が必要です。
今回はswiftファイルと同様の階層に動画ファイルを設置してから呼び出しています。
*forResource: "Assets/tsumo"
として呼び出せると思っていたがなんかうまくいかなかった。
ofType:
は動画ファイルの拡張子に合わせて変更してください。
↓対応ファイルタイプ
import SwiftUI
import AVKit
import AVFoundation
struct performanceView: View {
private let path = Bundle.main.path(forResource: "tsumo", ofType: "mp4")
var body: some View {
let url = URL(fileURLWithPath: path!)
let player = AVPlayer.init(url: url)
VideoPlayer(player: player)
}
}
加速度計測
勢いよくツモった時に「ツモ」音声流したい!ということで、
加速度を計測して一定数値以上になった時に音声再生を行う実装をしてみました。
公式ライブラリCoreMotion
を使用して実装しています。
加速度計測のためのView
まずは先ほどのperformanceView.swift
に加速度計測を起動する実装を追加します。
加速度計測を常に使用すると麻雀中に誤爆してしまう恐れがあることと後述しているとある理由のため、「リーチ」ボタンをタップしたらトラッキングを起動をする実装にしました。
import SwiftUI
import AVFAudio
import AVKit
import AVFoundation
import CoreMotion
struct performanceView: View {
@ObservedObject private var motionManager = MotionManager()
let soundPlayer = zawaPlayer()
private let path = Bundle.main.path(forResource: "tsumo", ofType: "mp4")
var body: some View {
let url = URL(fileURLWithPath: path!)
let player = AVPlayer.init(url: url)
if motionManager.tsumoFlag {
Text("ツモ!")
.onAppear() {
setAudioSession()
soundPlayer.musicPlay(flagNum: 4)
self.motionManager.stop()
}
}
VideoPlayer(player: player)
Button(action: {
if self.motionManager.isStarted {
self.motionManager.stop()
} else {
setAudioSession()
soundPlayer.musicPlay(flagNum: 3)
self.motionManager.start()
}
}) {
self.motionManager.isStarted ? Text("リーチ中") : Text("リーチ")
}
.controlSize(.mini)
}
}
加速度計測処理
CMMotionManager()
でx,y,z方向の加速度を計測していきます。
motionManager.deviceMotionUpdateInterval
で更新頻度を調整できます。
間隔の単位は秒です。
xStr = String(deviceMotion.userAcceleration.x)
では計測データをString型にキャストして代入しています。
Viewに表示したい時やdebugの際はこのxStrを使うのが便利です。
*今回は使用していませんが一応書いています。
if abs(deviceMotion.userAcceleration.x) > 2.0 || abs(deviceMotion.userAcceleration.y) > 2.0 || abs(deviceMotion.userAcceleration.z) > 2.0
では計測データの絶対値を取り、x,y,z方向のいずれかのデータが±2.0以上になった時を検出しています。
*数値は秘伝のタレ感ありますが、かなり素早く振らないと2.0超えません。
import Foundation
import CoreMotion
final class MotionManager: ObservableObject {
@Published var isStarted = false
@Published var xStr = "0.0"
@Published var yStr = "0.0"
@Published var zStr = "0.0"
@Published var tsumoFlag = false
let motionManager = CMMotionManager()
func start() {
if motionManager.isDeviceMotionAvailable {
motionManager.deviceMotionUpdateInterval = 0.1
motionManager.startDeviceMotionUpdates(to: OperationQueue.current!, withHandler: {(motion:CMDeviceMotion?, error:Error?) in
self.updateMotionData(deviceMotion: motion!)
})
}
isStarted = true
}
func stop() {
isStarted = false
motionManager.stopDeviceMotionUpdates()
}
private func updateMotionData(deviceMotion:CMDeviceMotion) {
xStr = String(deviceMotion.userAcceleration.x)
yStr = String(deviceMotion.userAcceleration.y)
zStr = String(deviceMotion.userAcceleration.z)
if abs(deviceMotion.userAcceleration.x) > 2.0 || abs(deviceMotion.userAcceleration.y) > 2.0 || abs(deviceMotion.userAcceleration.z) > 2.0 {
tsumoFlag = true
} else {
tsumoFlag = false
}
}
}
なぜさっきの動画再生ではないのか
本当は動画を再生したかったのですが、
CoreMotion
を用いた加速度測定を行っている時に同時に動画を再生することができませんでした。
Viewに表示しているVideoPlayer
を物理的にタップしても再生されないことからそういう仕様だと割り切るしかありませんでした。無念。。。
ただ勢いよくツモったと同時に演出画面(動画)を見せることができないということに気づき、音声再生に変更をしても同じ体験が得られると思い変更をさせていただきました。
まとめ
今回は
Viewや処理をまとめてSwiftUI
、
心拍数計測をHealthKit
、
音声再生をAVFAudio
,AVFoundation
、
動画再生をAVKit
,AVFoundation
,
加速度計測をCoreMotion
を用いて実装してみました。
作っているものはふざけていますが、
これまで自分がwatchOSアプリを作る時に使った技術を総動員させる大作になりました。
前提でリリースする予定はないとお伝えしましたが、
リリースしてほしい!というお声があれば、操作性の改善・著作権の考慮・Apple社との戦い(審査)を覚悟しながら取り組みたいと思います。
おまけ
AppleWatchの実機ビルドはクソ
今回の開発で一番時間をかけたことは何か。
それはXcodeとAppleWatchの接続です。
割合にして、接続:実装時間=9:1です。
終わってます。
なぜXcodeとAppleWatchの接続に時間を要したのか
Xcodeから実機ビルドを行う際、iOS端末等では有線接続が可能です。
しかしAppleWatchはというと有線接続が存在しません。
ではどうするのか。そう無線接続なんです。
これが最も罠です。
こちら公式のドキュメントですが、実機接続について記述があります。
AppleWatchの部分を引用すると、
To pair an Apple Watch to a Mac, connect its companion iPhone to the Mac with a cable, and ensure that the iPhone is paired for development. After this step, follow any instructions on the Apple Watch to trust the Mac. When paired through an iPhone running iOS 17 or later, Xcode connects to the Apple Watch over Wi-Fi. Series 5 and older models of Apple Watch additionally require the Apple Watch and Mac to be associated with the same Bonjour-compatible Wi-Fi network. When paired through an iPhone running older versions of iOS, Xcode requires the iPhone to remain connected to the Mac in order to develop on any model of Apple Watch.
(翻訳)Apple Watch を Mac にペアリングするには、ケーブルを使用してコンパニオン iPhone を Mac に接続し、iPhone が開発用にペアリングされていることを確認します。この手順の後、Apple Watch の指示に従って Mac を信頼します。iOS 17 以降を実行している iPhone を介してペアリングすると、Xcode は Wi-Fi 経由で Apple Watch に接続します。さらに、Series 5 以前のモデルの Apple Watch では、Apple Watch と Mac が同じ Bonjour 互換 Wi-Fi ネットワークに関連付けられている必要があります。古いバージョンの iOS を実行している iPhone を介してペアリングすると、Xcode では、どのモデルの Apple Watch でも開発を行うために、iPhone が Mac に接続されたままになっている必要があります。
つまり、MacOS端末とwatchとペアリングしているiOS端末を有線接続させた上で、MacOS,watchOS,iOSすべてを同一ネットワーク上に接続する必要があります。
また、MacOS端末とペアリングしているiOS端末とwatchOS端末とXcodeをすべて最新版にしないと不具合が起きることもあります。
面倒だけどこれで大丈夫になったね!と思ったそこのあなた。
ここまでやって接続されるかは運ゲーです。
今でもこの接続の問題はForumでもよく(悪い意味で)賑わっています。
自分はたまたま今うまくいっていますが、それでもビルドする度に下記をやっています。
- AppleWatchのWi-Fi接続を確認(大体切れている)
- MacPCを一度Wi-Fiオフにしてから再度オンに
- Xcode → Window → Devices & Simulatorsを起動して端末状態確認
- 接続が確認されるまでWatch上で別のアプリ起動したりしてみる
- 接続が確認されたらビルド実行
- ビルド完了するまでWatchをずっと注視(そうしないとロックがかかり接続が途切れる)
これでもうまくいかない時は各種再起動を試したり、
AppleWatchのデベロッパーモードをオンオフして再度MacPCを信頼したり、
ネットワークを変更してみたり、
Devices & SimulatorsからAppleWatchをUnpair Deviceしたり色々やっています。
Forumにはここまでやって上手くいってない人もいるぐらいです。
自分はハッカソンで宿泊していた場所のネットワークがおそらくBonjour-compatible Wi-Fi networkではなかったことが原因で6時間潰しました。
これだけネガキャンしてるけど大丈夫?
Apple Watchに良いアプリが少ない原因はここにあると私は考えているので、
むしろ色々な方に各方面でこのことを言いふらしてほしいです。
ストライキの意味も込めて。
ただし、Swiftは素晴らしい言語ということは覚えておいてください。
悪いのはAppleWatchとXcodeとAppleです。
参考文献
SwiftUI x watchOS
HealthKit
音声
実機テスト
公式ドキュメント
Discussion
くそおもろいwww