2018年2月17日土曜日

NES Emulator for Android - version 1.5.0

先日公開したCycloaをAndroidで動かしたライブラリを色々と更新しました。
https://github.com/suzukiplan/nes-emulator-android
現時点の最新バージョンは1.5.0。

主な機能追加内容としては、以下3つ。

  1. マルチtick送信(n倍速)に対応
  2. 映像と音声のキャプチャインタフェースを追加
  3. ステートセーブ/ロードのインタフェースを追加

この他にも色々と細かい修正はしていますが。
これらの機能を全てテストアプリで触れるようにしておいたので、大分テストアプリの画面のガジェット感が上がりました。

1. マルチtick送信(n倍速)

これは普通のエミュレータでもよくある倍速プレイとかですね。
tickの内容を流すことでリプレイ再生することもちゃんとできるようにしておきました。

2. 映像と音声のキャプチャインタフェース

これは普通のエミュレータにはあまり無い機能ですが、映像と音声をキャプチャするというもの。例えば、プレイしている内容をライブストリーミングに流したりといった機能を作る時に役立ちます。
もっとも、実際に配信するにはこれだけだと不十分で、別途MediaCodecを使ってエンコードして、ライブストリーミング・プロトコルでの配信する機能を(これはAndroidの標準機能ではないので自前で)実装する必要があり、少し大変ですが。

3. ステートセーブ/ロードのインタフェース

Cycloaにはステートセーブ/ロードの機能は無いので、NES Emulator for Android側のインタフェースのみですが、とりあえず作っておきました。余力があれば、Cycloaにステートセーブ/ロード機能を追加した魔改造バージョンをforkして作るかもしれません。(作らないかもしれません)

ここ一週間でもりもりアップデートしてましたが、これで私が当初作ろうと思った機能は(少なくともインタフェースレベルでは)一通り揃ったかなという感じ。

2018年2月13日火曜日

Kotlinで100行以内で書けるファミコンエミュレータ

前回の記事で書いたnes-emulator-androidというライブラリをjcenterで公開しました。

jcenterに公開したので、Android projectの build.gradle の dependency 区に
implementation 'com.suzukiplan:nes-emulator-android:1.2.1'
と1行追加してあげるだけで、Androidアプリで簡単にファミコンエミュレータを動かすことができます。

Androidアプリでファミコンエミュレータを動かすには, nes-emulator-android の NESView という View派生モジュール を使います。
NESView は 通常のAndroidのViewと同様に、レイアウトXMLで配置できます。

(レイアウトXMLの記述例)
<com.suzukiplan.emulator.nes.core.NESView android:id="@+id/nes_view" android:layout_width="match_parent" android:layout_height="match_parent" />

ファミコンの表示解像度は256x240pixel(アスペクト比16:15)なので、ConstraintLayoutを用いて配置してあげても良いですが、Viewを配置したレイアウト内へ自動的にアスペクト比を保った状態で最大限に拡大してセンタリングした状態で表示されます。(余った領域は黒く塗りつぶされます)

このNESViewに対して、
    nesView?.load(romByteArray)
とすればROMファイルをロードできます。

そして、
    nesView?.tick(keyP1.code, keyP2.code)
とすれば1フレーム実行することができます。

NESView#tickを実行すると自動的に垂直同期の待機が行われ、また、サブスレッド(非UIスレッド)から実行できるので、これを連続的に実行してあげればゲームが動きます。

実際のNESViewを用いて実装したエミュレータアプリの本体コードが以下にありますが、Kotlinで100行未満で記述できることが分かります。
https://github.com/suzukiplan/nes-emulator-android/blob/master/test/src/main/java/com/suzukiplan/emulator/nes/test/MainActivity.kt

なお、このNESViewを用いて作成したアプリはGPLv3またはGPLv3と互換性のあるライセンス(Apache v2, MIT, 修正BSD等のOSSライセンス)で公開する必要があるので、ご注意ください。(OSSで公開されているエミュレータのソースコードはだいたいGPL系なので)

2018年2月12日月曜日

CycloaをAndroidで動かしてみた

OSSのファミコンエミュレータのソースを眺めていたところ、Cycloaというエミュレータが中々良くできていそうだった。
https://github.com/ledyba/Cycloa

ただし、quick-save等は実装できていないし対応mapperも少ないので、実用的かというと結構微妙なところですが、内部の実装が結構キレイに書かれていて、フロント実装も結構し易そうな感じだったので、「とりあえずファミコンエミュでも作ってみっかー」みたいな軽い気持ちで作ろうとした時に、最短時間で動くものを作ることに適していそう。

論より証拠ということで、CycloaをAndroidで動かしてみたフロントを書いてみました。
https://github.com/suzukiplan/nes-emulator-android

少し目新しい点としては、
・Android Studio 2.2の時に対応した cmake スタイルのNDKビルドを適用
・エミュレータコア機能の実装を library module に分割(bintrayとかにデプロイはしてませんが追記: bintrayにデプロイしてjcenterへのリンク申請中
・↑を用いたアプリ本体の実装を Kotlin で書いてみた
ぐらいでしょうか。

少し話しが逸れますが、私はSwiftが大嫌いなのですが、Kotlinは割と良い感じだと思い始めてます。Swiftがキライな理由は、言語的な意味での良さを全て食い潰すバージョン間互換性の欠如とIDE(XCODE)の対応がIDEAとかと比べるとお粗末過ぎるところでしょうか。言語自体は結構良い感じです。しかし、言語の良し悪しというのは、言語自体の良し悪しではなくIDEの良し悪しに左右されるというのが恐らく真理です。(だから、iOS用のコードを書くならObjective-Cにしておいた方が良い。Swiftと比べればまだ安定している方なので。)

2018年2月11日日曜日

東方VGS(iOS)コードレビュー③ VGSView

VGSViewについて解説します。
https://github.com/suzukiplan/tohovgs-ios/blob/master/Touhou%20VGS/VGSView.m

VGSViewはiOS版VGSのグラフィックス描画処理を実装したViewです。
VGSのグラフィックス描画は、vgs2_putBG(BGを描画)やvgs2_putSP(スプライトを描画)といったVGS API(C/C++)を用いて行いますが、それらを用いて描画された情報はVGS VRAMというメモリ上に記憶されています。

VGSViewの責務(役割)を箇条書きで書くと、

  • VGSのメインループを回す(VGS VRAMに1フレームの描画がされる)
  • VGS VRAMを、iOSの描画処理系に合わせた形式に変換
  • 画面に出力(表示)
  • 上記を1秒間に60回の間隔(60fps)で繰り返し実行

となります。

iOSのゲームに適したグラフィックス描画機能としてはOpenGL/ESやMetal(GPU Renderer)がありますが、これらは上述の用途で使うのは不適当です。というのも、GPU Rendererで上記要件を満たそうとすると、

  • 毎フレームVGS VRAMをテクスチャ変換して描画 or
  • 1pixelを2つのポリゴンで表現して、(変化があったpixelの)色情報を設定

といった処理方式で実現することになりますが、前者(テクスチャ変換)は変換オーバヘッドが物凄く大きく、VGS VRAMのサイズ(東方VGSの場合は240x320)のテクスチャを毎フレーム生成することは現時点最新のGPUでも恐らく不可能です。仮にできたとしてもGPUが火を吹く勢いで回り熱暴走を起こします。

後者の処理は前者よりは現実的ですが、その場合全画面のpixelの色情報を変えようとすると最大240x320(76800)回のパレット変更命令をGPUに飛ばす必要があり、GPUへのデータ伝送は物凄く遅いので60fpsではとても動けないようになるでしょう。

つまり、VGSの要件を満たすことができるのは、GPU描画ではなくCPU描画一択になります。

まず、VGSViewを生成するとCALayer(Core Animation Layer)を独自のもの(VGSLayer)へ差し替えます。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L156-L159

そして、CADisplayLinkを用いて垂直同期の発生間隔でsetNeedsDisplayをコールするようにしています。これでVGSLayerの描画更新が1/60秒間隔で行われるようになります。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L170-L171

VGSLayerが初期化されると、まず2枚のCore GraphicsのBitmap Contextを生成します。これが変換後のVGS VRAMバッファになります。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L101-L115
2枚準備している理由は、VGSLayerがダブルバッファリングと呼ばれる方式で描画出力を行っている為です。ダブルバッファリングとは、一方のバッファの内容を表示中にもう一方のバッファ更新を非同期で行うことで、60fpsの描画がスムースに行われるようにするためのものです。(実のところ、最近のiPhoneならこんなことしなくてもシングルバッファで余裕で回せますが...)

その後、ダブルバッファリングを実現するためのGameLoopスレッドを生成して起動しています。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L117-L121

GameLoopスレッドでは、100μs間隔で描画指示が発生するのを待機して、
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L34
描画指示が発生したら、VGSのメインループを1回回し、
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L35-L42
その後、VGS VRAMの内容をCore GraphicsのBitmap Contextの形式に変換しています。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L48-L61

GameLoopが本来必要な処理は以上ですが、VGSにはグローバルリクエストという機能があり、それに応じてAppStoreのページを開くといった要求処理を実装しています。(東方VGSでは使ってませんが、VGSからのネイティブ広告の表示制御などはこのグローバルリクエストで実現しています)
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L63-L88

最後に、CADisplayLinkにより1/60間隔で発生するVGSLayerの描画要求のコールバック(display関数)では、描画指示を出しつつ、描画が確定している方のCore Graphics/Bitmap ContextをViewの表示内容(content)としてセットする処理を行っています。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSView.m#L131-L139

以上でVGSViewの解説は終わりです。

この部分は東方VGSに限らず、VGSを最初にiOSへ移植した時から一切変更していないので、あまり面白い内容ではないですね。だから、実装内容を淡々と解説しましたが、このVGSViewを最初に作った当初はもの凄く苦労しました。
というのも、市販のiOSアプリ関連の書籍には、役立つ内容が書かれたものが一切無かったので。(仕方が無いのでApple公式の分かり難いCore Graphicsのドキュメントを読むなどして理解しました)
ゲームのグラフィックス描画処理というと、最近ではOpenGL or MetalといったGPU Rendererを用いた方式に一本化されていて、スプライトやラスタといった昔ながらのコンピュータグラフィックスの描画方式は、完全に過去のものになってしまったのかな。

2018年2月5日月曜日

東方VGS(iOS)コードレビュー② ViewController

大規模なアプリの場合、ViewControllerが幾つもあったりしますが、東方VGSのViewControllerはひとつだけです。
https://github.com/suzukiplan/tohovgs-ios/blob/master/Touhou%20VGS/VGSViewController.m

更に言うと、その唯一のViewControllerが管理するViewも最大5つ(iPhone4s以前 or iPadの場合は4つ)だけしか無いので、ViewControllerの実装は物凄くシンプルになっています。
※上図では省略していますが、全てのViewの親(ルートView)としてVGSScreenViewというものがあります。

ViewControllerが生成されるとiOSからデリゲートメソッドのviewDidLoadがコールバックされますが、VGSViewControllerでは、そこで上述のViewを作って並べつつ、VGS特有の初期化処理を行っています。

以下、VGSViewControllerのviewDidLoadに実装されている処理内容を見ていきます。

(1) 多重初期化の抑止
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSViewController.m#L38-L40
これは、iOS8の時にviewDidLoadが2回実行され結果的にアプリが起動時にクラッシュするバグ(※iOS側のバグ)が発生したので、その時に入れた対処コードです。

このバグ自体、発生条件がかなり限定的なのですが、東方VGS(というか私が作った全てのiOSアプリ)は不運にもその条件に該当してしまったので、当時公開していた全てのアプリにこの対処コードを入れてアップデートしました。(物凄く大変でした)

そういえば、この対処バージョンの東方VGSをアップデートするついでに、東方幻想郷から「Bad Apple!!」を追加しましたが、これは当然そういう意図で追加されたものです。

iOS版東方VGSをAppStoreで公開していた約4年間ほどの短いようで長い歴史の中で、ほぼ唯一不評のレビューが付き、総平均レビュー値を4.9から4.8に下げる原因となったのがコレでした。ただ、普通のアプリだったら「起動すると落ちる☆1」と低評価レビューの嵐となる所、「起動すると落ちる☆5」という風に、不満を言いつつも評価を下げないでくれる人が結構居ることに少し驚きました。
このiOS8デグレード事件以外に、もう一つ不評を買ったのが、AppStoreだとアップデート時の更新内容の説明文を最大4000字書けたので、「4000字きっかり埋める説明文を書いてみよう」と思い立ち、少し長めのポエムを書いた時ですね。 
逆に一番評価が高かったのが「神々が恋した幻想郷」と「芥川龍之介の河童」を追加した時のレビューで、平均☆5.0(約300件ついたレビューがオール☆5)でした。オール耳コピで作る必要がある風神録の楽曲全曲収録という無謀な挑戦ができたのは、この時のオール☆5が原動力になりました。
(2) バージョン切り分け
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSViewController.m#L44-L67
この辺のバージョン切り分けは色々と謎ですね。
過去バージョンとの互換性を保つ為に色々な試行錯誤が行われていたようです。(他人事)
iOSの場合、Androidと違ってOSバージョンがフラグメント化しないので、古いバージョンはガンガン切ってしまって良いかと思うのですが、東方VGSではApple側から「おい、やめろ!」と言われるまで最低サポートバージョンをiOS4.3(東方VGSの機能を実現する上で最小限の機能セットが実装されているiOSの最低バージョン)以降にしてました。(最終的には、古すぎるバージョンのiOSをサポートしているとAppleから審査提出を拒否されるようになったので、最低サポートバージョンをiOS6.1にまで引き上げました)

(3) ルートViewの作成
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSViewController.m#L68
ViewControllerのルートViewとしてVGSScreenViewというものを作成して追加しています。VGSScreenViewには「タッチイベントを取得してVGSへ送信する」という役割があります。(その役割が無ければルートViewは単なるUIViewでも良かったとも言えます)
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSScreenView.m
普通のiOSアプリを作った事がある人なら、「タッチイベントの処理は各Viewが担えば良いのでは?」と考えるかもしれません。実際にタッチを受け付けるのはVGSViewなので、わざわざタッチイベントを処理する専用のViewなんて設けるのはナンセンスだと言えます。ただし、UIViewは存在自体が結構重いモノなので、Viewをグルーピングする用途しか持たない空のUIViewをルートに配置するのではなく、そこにタッチイベント処理を集約させる機能を持たせ、VGSViewの責務範囲を減らしつつロジック分散するのは、ある意味(※VGSというスペシフィックなモノに限れば)合理的かもしれません。
ちなみに、このViewのグルーピングという考え方でAndroidとiOSで決定的な違いがあります。Androidの場合、Viewのグルーピングを行う専用の機能(ViewGroup)をViewとは分離して定義した一方、iOSの場合Viewのグルーピング機能をそのままView(UIView)に持たせています。結果的に、Viewグルーピングの実装はAndroidの方がシンプルになった反面融通が効きにくいものになり、iOSの方はかなり融通が効く反面実装コストが高く付くものになりました。何れにせよ一長一短あるのですが、だいたいどのアプリも似たり寄ったりなmaterial designとかいうクソみたいなデザインを採用している現状を勘案すると、この部分のスキームは結果的にAndroidの方が優れたものだったのではないかと思っています。
(4) romdata.binの読み込み
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSViewController.m#L69-L77
VGSの場合、グラフィック、音声(効果音、BGM)、テキストなどのアセットデータは独自形式のバイナリデータとして「romdata.bin」というファイルに集約しています。そのromdata.binファイルを読み込みメモリ上に配置する処理をここで行っています。そして、ここで読み込んだメモリ上のデータ(ROMデータ)は、アプリのプロセスが生存中維持し続けます。これにより、東方VGSでは一般的なiOSネイティブアプリやUnity等で書かれたゲームアプリを作る時に悩まされる「ガーベージコレクト」が一切発生しません。(その代わりに表現力が貧弱になる)

(5) VGSViewとUIImageViewの作成+配置
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSViewController.m#L79-L114
VGSViewというVGSのコア機能を持つViewと、上下の画像イメージの作成と配置をココで行っています。
16:9の端末と4:3の端末で処理が分かれていますね。
iOSアプリを作ったことがある方なら、「これだとiPhone6や6 plusで画面下にスキマが出来てしまうのでは?」と疑問に思うかもしれませんが、東方VGSはiPhone6(4.7")やiPhone 6 plus(5.5")の場合は互換モードで動くので、特に問題なく配置されます。(iPhone Xでどうなるかは知らない)
この部分はちょっと直したいですね...

(6) VGSBarViewの作成+配置
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSViewController.m#L116-L118
最後にVGSBarViewという画面最上部の時計を表示しているViewを作成して配置しています。リリース当初の東方VGSにはこのViewは無くて、iOS標準のステータスバーを表示していました。しかし、iOS側の度重なる仕様変更で嫌気が差し、最終的にはiOS標準のステータスバーを削除して自前のステータスバーを置く形に落ち着きました。
iOSはバージョンアップの都度、かなりラジカルに仕様変更をしてきますが、最も煽りを食うのがこのiOS標準のステータスバーの部分です。ステータスバーはiOS側の持ち物なので、当然といえば当然ですが。(最近では、iPhone Xのあの変態的な形状のステータスバー...ハードレベルで変えてくるから堪ったものではない)

2018年2月3日土曜日

東方VGS(iOS)コードレビュー① AppDelegate

先日OSSとして公開したiOS版東方VGSのアプリ実装を眺めつつザックリと解説するシリーズ。(シリーズが続くかは不明)
本稿は、iOSアプリケーション開発経験者ではなくても意味が分かるように書いていき、最終的にはiOS版東方VGSの全体的な作りが何となく分かるようになることを目標にしています。
第1回の今回はAppDelegateというiOSアプリの一番根っこの部分で何をやっているかを見ていきます。

↓このソースの解説
https://github.com/suzukiplan/tohovgs-ios/blob/master/Touhou%20VGS/VGSAppDelegate.m

(1) AppDelegate(起動時の処理)

iOSアプリの場合、起動が完了するとAppDelegateのdidFinishLaunchingWithOptionsが呼び出されます。(もっと正確に言えば他のUNIXプログラムと同様main関数から起動して、そこでAppDelegateが登録されます。そして、iOS側の制御でアプリの起動処理が完了した時にdidFinishLaunchingWithOptionsがコールバックされます)
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSAppDelegate.m#L23

そこでやっていることは大まかに、
・ViewControllerの登録
・OpenALの初期化
・AudioSessionへのproperty-listenerとinterruption-listenerの登録

ViewControllerはAndroidで言うところのActivityやFragmentのことで、アプリのViewを制御するコードを書きます。ViewとControllerが登場したということは、Modelを作ってMVCのデザインパターンで設計すべしと考えるのが普通ですが、東方VGSはMVCとかそういうデザインパターンでは作っていません。もちろん、DDDとかそういうモノでもなくて、デザインパターン的には好き勝手に作っています。一人でプログラミングする場合、それが一番生産性が高い。

OpenALは音を再生する為に使っています。普通音を再生するにはAudioPlayer等を使いますが、東方VGSの場合、VGSが音声を発生する装置をエミュレーションしてパルス符号データを時系列に吐き出し続け、それを拾って再生するのでAudioPlayerよりも波形を逐次直書きできるOpenALを使っています。(Androidの場合はOpenSL/ESを使っています)

AudioSessionのproperty-listenerは、kAudioSessionRouteChangeReason_OldDeviceUnavailableというハードウェアイベントを拾う為に登録していて、これは、ヘッドセットがデバイスから外された時に発生するイベントで、これを検知した時に音声の再生をポーズするために使っています。これは、アプリ公開後にユーザからそういった要望を受けて実装しました。(iTunesがそういう仕様だったらしく、東方VGSも同じ仕様だと思ってしまったのだとか。学校の休み時間に東方VGSを聴いて授業が始まる時にヘッドセットを抜いて仕舞ったところ、授業中にうっすらとBGMが流れてしまうハプニングがあったと伺ったので、申し訳なく思って速攻で実装しました)
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSAppDelegate.m#L72

interruption-listenerは、東方VGSのオーディオセッションに対して割り込みが発生した時にアプリを停止させる処理を実行しています。これは、バックグラウンドで再生中に電話が掛かってきたり、別の音楽アプリ(iTunesなど)を起動した時にアプリが動き続けると煩わしいので、それなら止めた方が良かろうということで実装しました。この影響で一部アプリ(オーディオセッションを奪うアプリ)とは共存できないという仕様になりました。この仕様については、アプリ公開中に一部ユーザから不満を言われていたので、直せばもっと高い評価が得られたかもしれません(ちなみにiOS版のレビュー評価件数は最終的に、DL数約20万に対して5千件ほどで平均4.8ぐらいでした)が、逆に治してしまうと電話が掛かってきた時にアプリがちゃんと止まってくれるか分からなかった(開発当時はiPhoneではなくiPod touchしか持っていなかった)ので、怖くて修正できなかったと記憶しています。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSAppDelegate.m#L84

(2) AppDelegate(バックグラウンド関連)

バックグラウンド関連での処理もAppDelegateで行われますが、ココでやっていることはバックグラウンドに入ったフラグのON/OFFぐらいです。
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSAppDelegate.m#L104
https://github.com/suzukiplan/tohovgs-ios/blob/71464aae09f107fbf02e9ce1989d9bfb80381e37/Touhou%20VGS/VGSAppDelegate.m#L119
後付で、音楽がプレイ状態でない時にバックグラウンド遷移する時はアプリをexitするようにしました。(その方が若干程度ではありますが電池が節約できたので)

もっと淡々と書こうと思ったのですが、コードの1行1行にそれなりに思い出が詰まっているので、全然淡々としていないな。

合理的ではないものを作りたい

ここ最近、実機版の東方VGSの開発が忙しくて、東方VGSの曲追加が滞っています。 東方VGS(実機版)のデザインを作りながら検討中。基本レトロUIベースですがシークバーはモダンに倣おうかな…とか pic.twitter.com/YOYprlDsYD — SUZUKI PLAN (...