元ネタ
#attefes で @akio0911 さんが「Rxってゲームの入力に使えそう」と言っていてなるほどと思ったので、試しに昇竜拳判定をやってみようかな。直近が➡️⬇️↘️👊か判定。ズンドコキヨシとの違いは各入力間の時間制限か https://t.co/ynFFFo42RL
— Shinichiro Oba (@ooba) April 20, 2016
目標
制限時間内に右
、下
、右下
、パンチ
が入力されたら値を流す昇竜拳
オペレータを作ります。時間を扱うのでスケジューラを引数で渡せるようにしてます。
import RxSwift
enum 入力: Int {
case 左下 = 1, 下, 右下, 左, 右 = 6, 左上, 上, 右上
case パンチ, キック
}
let 入力川 = PublishSubject<入力>()
_ = 入力川
.subscribeNext { print($0) }
_ = 入力川
.昇竜拳(MainScheduler.instance)
.subscribeNext { _ in print("昇竜拳!") }
本当は➡️や👊をcaseに使いたかったんですが、無理でした…。
実行イメージはこんな感じ。
上
右
右
下
右下
パンチ
昇竜拳!
上
下
...
仕様の簡略化
実装を楽にするために仕様を簡略化しています。
- 入力猶予フレームは全入力間で7フレームに固定する(小昇竜の仕様)
- 同時押しはサポートしない
- 押しっぱなしやニュートラルは考慮しない
格ゲーのコマンド判定ロジックって、ちゃんと考えると結構複雑ですね…。
実装
extension ObservableType where E == 入力 {
func 昇竜拳(scheduler: SchedulerType) -> Observable<[E]> {
return self
.scan([]) { Array(($0 + [$1]).suffix(4)) }
.timeout(8 / 60, scheduler: scheduler)
.retry()
.filter { $0 == [.右, .下, .右下, .パンチ] }
}
}
「ズンドコキヨシ with RxSwift」のときのように、まず直近の4入力を配列にまとめます。
そのあと、timeout
で8フレーム以上値が流れていない場合はエラーを起こし、それをretry
で捕まえて最初からsubscribe
し直すようにします。これによって、連続で7フレーム以内に入力されたものだけが配列にまとまるようになります。
あとは、その配列が昇竜拳コマンドである[右, 下, 右下, パンチ]
かどうかでfilter
するだけです。
動作確認
ジョイスティックをつなげてその入力を入力川
に流すようにしようかと思ったのですが、かなり壮大になりそうなので断念。そもそもフレーム単位の判定になるため、手入力でのきちんとした動作確認は厳しそうです。
そこで、時間を自由に操れるHistoricalScheduler
を使ってみます。advanceTo()
というメソッドで任意の時点に同期的に時間を進めることができます。便利!
let scheduler = HistoricalScheduler()
_ = 入力川
.subscribeNext { print($0) }
_ = 入力川
.昇竜拳(scheduler)
.subscribeNext { _ in print("昇竜拳!") }
入力川.onNext(.右)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(7 / 60))
入力川.onNext(.下)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(7 / 60))
入力川.onNext(.右下)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(7 / 60))
入力川.onNext(.パンチ)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(5 / 60))
入力川.onNext(.右)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(7 / 60))
入力川.onNext(.下)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(8 / 60))
入力川.onNext(.右下)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(7 / 60))
入力川.onNext(.パンチ)
scheduler.advanceTo(scheduler.now.dateByAddingTimeInterval(7 / 60))
入力川.onNext(.右)
実行結果
右
下
右下
パンチ
昇竜拳!
右
下
右下
パンチ
右
ちゃんと7フレーム以内に昇竜拳コマンドが入力できているときだけ値が流れているようです。
RxTestsに含まれるTestScheduler
を使うと、同じように時間を操作してオペレータの単体テストもできるのですが、それはまた次の機会に。
改良
この実装だと、何も入力をしないと8フレームごとに入力川
に対してsubscribe
が走ってしまい、一応意図通り動いているとはいえちょっと気持ち悪いです。
@inamiy さんが実装されたReactiveCocoa版ではこの点をちゃんと考えてあり、最後の入力を起点にtimeout
が実行されるようになっていました。昇竜拳以外の必殺技にも対応していたり、実際に遊べるようになっていたりして、色々すごい。
該当箇所は以下になります。flatMapLatest
でtimeout
につなげているのがポイントです。
https://github.com/inamiy/ReactiveCocoaCatalog/blob/ab7291561b4ec015a68b915c2ed56b6b75a626b7/ReactiveCocoaCatalog/Samples/GameCommandViewController.swift#L45-L49
これを真似したのが以下の実装です。この方式であれば、最後の入力によって入力猶予フレームを可変にしたりできますね。
extension ObservableType where E == 入力 {
func 昇竜拳(scheduler: SchedulerType) -> Observable<[E]> {
return self
.scan([]) { Array(($0 + [$1]).suffix(4)) }
.flatMapLatest { input -> Observable<[E]> in
Observable.just(input)
.concat(Observable.never())
.timeout(8 / 60, scheduler: scheduler)
}
.retry()
.filter { $0 == [.右, .下, .右下, .パンチ] }
}
}
参考文献