====== ハイハイスクールアドベンチャー Android版 ====== ===== あらすじ ===== 2019年神奈山県立ハイ高等学校は 地盤が弱く校舎の老朽化も進んだため、 とうとう廃校にする以外方法がなく なってしまった。 ところで大変な情報を手に入れた。 それは、 「ハイ高校にATOMIC BOMBが仕掛けられている。」 と、いうものだ。 どうやらハイ高が廃校になった時、 気が狂った理科の先生がATOMIC BOMBを 学校のどこかに仕掛けてしまったらしい。 お願いだ。我が母校のコナゴナになった 姿を見たくはない。 早くATOMIC BOMBを取り除いてくれ……!! 行動は英語で、"<動詞>" 或いは、"<動詞>"+"<目的語>"のように入れていただきたい。 例えば、"look room"と入れれば部屋の様子を見ることが出来るという訳だ。 それでは Good Luck!!!............ {{::hhsadvrev:title.png?400|}} {{::hhsadvrev:isako.png?400|}} ===== 概要 ===== Android版勝手アプリです。 Google Play Storeには登録していないので、インストールにはちょいとひと手間かかります。 Android 16での動作は確認しましたが、他のバージョンでの動作については不明です。 ===== ダウンロードとインストール ===== こちらから[[https://www.wildtree.jp/~araki/HHSAdv121.apk|APKファイル]]をダウンロードして、インストールしてください。 ===== あれこれ ===== ==== オープニング・エンディング ==== 前から、オープニングとエンディングの画面はあった。 オープニングでは、ゲームのストーリーを、エンディングではスタッフロールをそれぞれ表示している。 旧来のものは、テキストを一行ずつ表示していたので、かくかく動いてかっこよくなかった。 [[ハイハイスクールアドベンチャー SDL版]]を作ったときに、ビットマップ画面に描いたものを滑らかにスクロールさせる画面を作った。 これを、Androidでもやろうと思った。 AIに聞くと、まず進められるのが TextViewを使う方法。 メッセージの高さの分のTextViewを作って、それを画面の下からちょっとずつスクロールさせていけばいいというもの。 確かに、テストコードはうまく動く。 だが、メッセージの量が一定値を超えたところで、ぶったぎられた。 どうも、TextViewの高さには何らかの制限がかかっていて、無制限にすることはできないらしい。 さんざんAIが提案してくるパラメータを次々とぶっこんで試した結果確認できたことだ。 次に、ScrollViewを組み合わせる方法が提案された。これを使うと高さは切られないが、画面が表示されたときに既に画面がメッセージで埋め尽くされていて、そこからスクロールが始まる。 ダメだ、イケてない。 もう、SDLと同じでビットマップ作ってそれスクロールさせればいいんじゃない? と、GeminiとCopilotにそれぞれ聞いてみたら、Geminiは「いや、ビットマップは素材の用意が大変だしメモリも食うから RecyclerViewを活用するのが正しいやり方だ」といって、新しい方法を提案してきたが、ScrollViewと大差なかった ー つまり期待してない動作しかしなかった。 Copilotはビットマップを使う方法を速攻で提案してきて、ほぼ完ぺきだった。 問題点は、例示されたコードをつかったら、ビューを初期化するところで落ちてて、原因を聞くと「ビューにコンストラクターがなければおちるのは当然」みたいなことを言われたことくらい。 いや、あんたが提示したコードじゃないのよ。 それ以外は、およそ文句の付け所はなかったので、速攻で採用した。 AIは便利だし、開発の大きな助けになってくれるが、時に頑固で時に忘れっぽくて、しかもGeminiのように効く相手を間違えると、いつまでも正解にたどり着けなかったりもするので、うまく付き合わないといけない。 ((今回はGeminiが駄目だったが、Copilotが駄目なこともあるだろう。)) ==== kotlinのこと ==== AndroidはもともとはJavaを主たる開発言語としていたが、現在は Kotlinもサポートしている。 というか多分 Kotlinが推奨される言語だと思う。 なので、ハイハイスクールアドベンチャーもKotlin化すべきかと思ったのですが、他のことも忙しかったので、できるところから。 ということで、KotlinとJavaの混在になってるのが現状である。 新しく書き足すものはKotlinにしていたのだけれど、さすがに混在していると、自分が何を書いているのかわかりにくいし、Java部分とやりとりするために @JvmFieldをプロパティにつけて getter/setterを抑制したりめんどくさい。 なので、全面的に Kotlinにした。 その過程で、あれこれ問題が噴出したのでここに整理しておく。 === 消えたコード === 基本的に、Java -> Kotlinのコード変換はAndroid Studioが面倒を見てくれる。 もともと Javaのプリプロセッサみたいな形でスタートしたので、変換は大体大過なく行われる。 ところが、理由は不明だが、EditTextウィジェットにくっつけていたリスナーがごっそり抜け落ちていた。 とりあえず、欠落した部分は自分で補ったが、他にも抜けている部分があるかどうかはわからない。 ゲームはクリアできたので特に問題はないとは思うが。 === Byte型の罠 === 全面的にKotlin化したら、なぜか落ちるようになった。 それもランダムに。 AIに尋ねたら「メモリ不足か、NDK側のコードのバグで不正なアクセスをしているか、無限ループなどでOSから強制終了されているかじゃないか」といわれた。 まあ、メモリーについては結構雑に使っている自覚はある。 Android版の後に作った M5版などでは結構けちけちやっているが、地図データを全部オンメモリで持っていたり、とにかくざっくりなので、その辺を、全面的に書き直して、けちけち使う分だけメモリにロードするように変更したが改善はない。 仕方がないので、デバッガで、ゲームの処理部分をステップ実行で、落ちるまで繰り返して、原因を特定した。 おちるのは先生が現れる瞬間。 ようするに先生の描画で落ちていた。 ランダムに発生するのは先生の出現がランダムだからだ。 それにしても、こんな枯れたコードでなぜ? で、デバッガで追ってみると、座標がなぜかところどころ負数になっている。 これが原因でペイントなどが破綻して死んでいた。 何故そんなことになっていたのか? 原因は強烈な思い込みにあった。 Kotlinの Byte型は**符号付8ビット整数**なのだ。 C頭なわたしは、byte_tみたいなやつはすべからく**符号なし8ビット整数**だと思い込んでいたのだ。 地図や、先生などの画像データは、バイト配列で座標や色コードを格納している。 座標は、まあ、ものによっては127を超えている。 これを、符号付整数として取り出せば、もれなく負数の出来上がりである。 val buf: ByteArray ... var o = 0 ... var x = buf[o++].toUByte().toInt() var y = buf[o++].toUByte().toInt() ... のように、いったん、UByte型((符号なし8ビット整数))に変換してから、整数に拡張すれば問題は起きない。 型の定義は言語ごとに類似性はあっても独自のものであるので、先入観はすててちゃんと調べないとダメ。 なお、バッファ自体を UByteArray にすれば、余計な toUByte()がいらなくなり、間違いも減りそうだが、UByteArrayを使おうとすると「それはExperimentalだから」といわれていろいろめんどくさいので、今回は採用していない。 === Lintがうるさい === Android StudioというかIntellijには KotlinのLintが組み込まれている。 勿論、ありがたい場合も多いのだが、とにかくうるさい。 Javaから移行したコードなので、Kotlinの推奨と違う命名規則などがとにかく大量に引っかかって、改めるべき問題が見つけにくいのだ。 勿論、すべてが改めるべきものだ、というのはそうなのかもしれないが。 ==== ランチャーアイコン ==== アプリを識別するもの。 それがアイコンです。 アイコンの扱いは、Androidが進化する中でちょいちょい変わってきています。 なので、時々対応作業が発生します。 === Android Asset Studio === Android Studio には Android Asset Studioなるツールが同梱されていて、画像ファイルから適切なアイコンデータを生成してくれます。 基本的にはこのツールで作成したアイコンであれば、大体 Android Compliantになるはずなので、古いアイコンを使っている場合には、一度このツールでアイコンの生成をさせておくのがいいかと思います。 == 背景画像のこと == アイコンは、前景画像と背景画像とを組み合わせて生成される仕組みになっています。 一般的には前景だけ用意しているのではないかとおもいます。 その場合は ic_launcher_background.xml というベクタ情報の画像が選ばれるのですが、背景が薄緑色で、場合によっては好ましくないかもしれません。 自前で用意するか、単色塗りつぶしでいいなら、頭の方にある path 要素の fillColorを好みの色に変えてしまえばいいです。 ハイハイスクールアドベンチャーでいえばアイコンはいさこちゃんの画像ですから全体に緑っぽいですので、背景は暗めの青にしてあります。 {{::hhsadvrev:ic_launcher_background.png?200|}} === Material You === Android13あたりから、Pixel Launcherでは、テーマを適用すると、アイコンがモノクロのものにさし変わる機能が付きました。 単にモノクロならいいのかといわれればそこはそうではないのでしょうが、対応のデザインを Matrial Youと呼称しています。 アプリ側で、カスタムアイコンに加えて、このモノクロのデザインガイドに沿ったアイコンを作って入れておけば、Pixel Launcher上でそっちのアイコンが表示されるのです。 AIに聞いたりガイドを見たりしながら作業したのですが、一向に出てこない。 * Asset Studioでモノクロアイコンをつけたすことはできない * 白黒二値(ただし白と背景だけ)で構成されたPNGファイルまたはベクタデータ(XML)である * 108dpx108dpで作成 * ic_launcher.xml(など)の定義ファイルに adaptive-icon要素のエレメントとして、monochrome を足し、このアイコンをポイント これだけ、って言いきられるので何度もいろいろやったんですが、一向に出てこない。 ずっといさこちゃんのアイコンのまま。 何度もあっちのAI、こっちのAIに聞いて、最後にたどり着いたのが、 **ic_launcher_round.xml にも monochrome要素足さないとダメ** ということ。 round iconは Android 8とかだけの機能だと思っていたのでノーマークだったのですが、結論から言うと、これが効いてました。 {{::hhsadvrev:custom_icon.png?200|カスタムアイコン}} {{::hhsadvrev:materialyou.png?200|Material You}} ==== 独自の設定要素 ==== {{::hhsadvrev:文字の大きさ.png?400|}} このゲームを最初に実装したころは Android 2.2とか2.3で端末画面も4インチないくらいのサイズだったので、画面の解像度も低く、あまり気にならなかったのですが、さすがに、昨今の大画面、高解像度のスクリーンでは、メッセージを表示している文字が小さく感じるようになってきました。((決して、老化が原因ではない……と信じてるから!)) いえ、たぶん、システム設定を変えれば多少は大きくなるのでしょうが、そろそろ独自の設定を持ってもいいのでは、と、思いました。 が、ドロップダウンで数字を選ぶとか、あんまりイケてない。 どうせなら、文字の大きさがわかるボタンで選びたい。((異論はあるかと思いますが。)) そう思って、独自の設定ウィジェットを作ることにしました。 要するに、Preference クラスを継承して、自前の処理を組み込んでやればいいわけです。 が、これがなかなかのはまり要素満載の作業でした。 === 画面幅いっぱいに広がってしまう === 設定画面を開くと、標準の要素は左側にスペースがあって右側に表示されるのですが、標準のレイアウトをレイアウトインスペクタで調べて、可能な限り似せたレイアウトを作っているのに、どうしても左の隙間がつぶれてしまうのです。 標準的なレイアウトは、全体を覆うLinearLayout(horizontal)があって、左側からicon_frameというLinearLayout(horizontal)が来て、これが左側に72dpの隙間を作り出し、その隣にRelativeLayoutがあって、その中に titleを表示する TextViewや、スイッチなどの要素が並びます。 なので、この構造をまねて、icon_frameには16dpの左マージンをつけて、56dpのMinWidthをつけて、計72dpのスペースをつけるようにしました。 その隣には width="0dp" layout_weight="1"のLinearLayout(horizontal)を置いて、この中に、FontSizeInlinePreferenceという自前のクラスの中でボタンを並べます。 {{::hhsadvrev:文字の大きさ幅いっぱい.png?400|}} ところが、こんな感じで、左端のスペースをかき消して、幅いっぱいに広がってしまうのです。 AIに聞くと、やれ、ボタンに表示してる "Aあ"が悪いだの、レイアウトパラメータに問題があるだの、いろんなことを言って、あれをやれこれをやれと言ってきますがどれも問題を解決するに至りません。 仕方がないので、Layout Inspectorでちまちまと、標準のレイアウトと、自前のレイアウトとを比べていきます。 すると icon_frameの Visibilityが、標準では INVISIBLEになっているものが、GONEになっているじゃありませんか。 とりあえず、FontSizeInlinePreference()のコードのほうで、icon_frame の Visibilityを ItemViewのVisibilityと同じになるようにしてやると、あら不思議。ちゃんと左端にスペースが出るじゃないですか。 Visibilityの操作は、親クラスの中で行われているようなので、Android Studioで親クラスの処理を追っていくと、mIconSpaceReservedというprivate変数の値がfalseだとGONEにしていることがわかりました。 private の変数なのでコード側からは変更できないですが、XML側でできることがわかり、問題を解消することができました。 === PrefernceCategoryとの間に線 === 上のうまくいってない画面。「画面の設定」という PreferenceCategoryの表示と、「テキストの大きさ」という項目の間に横線が入っているのが見えるかと思います。 ほかの項目では、カテゴリの下には線が入ってないのに、なぜか線が入る。 消したい。邪魔だ。 だが、Layout Inspectorでもいまいち、犯人がわからない。 まあ、幅問題が解決しても、線は横幅いっぱいに出ているので、PreferenceCategory側か、FontSizeInlinePreferenceの一番外側のLinearLayoutのどっちかなわけですが。 結論から言うと、PreferenceCategory側でした。 app:allowDividerAboveとapp:allowDividerBelowというパラメータがあって、これが線を引くかどうかの制御をしていました。 しかし、ほかの要素ではこれ特に指定してないのだから、allowDividerAboveが trueで、allowDividerBelowがfalseがデフォルト値じゃないのかって思うんですが、やや解せぬ思いは残りましたが、線は消せました。 ==== モノクロアイコンのデザインについて ==== モノクロアイコンは、いさこちゃんの白黒化も考えたのですが、ごちゃごちゃしすぎるので断念。 では、何がいいか? そういえば、ハイ高校には校章はないのか? と、いうことで、勝手に校章をデザインしました。 もちろん、ゼロから考えたわけじゃないですよ。 どこぞのものを参考にでっち上げたのがこちらです。 {{::hhsadvrev:校章.png?100 |校章}} Z市の市花がひまわりであることから、太陽を象徴する卍型にハイ高校の頭文字Hを組み合わせてあります。 卍の四つの腕は教育目標である、真・善・美・用をあらわし、ひまわりの黄色を基調としています。 このデザインをモノクロ化したものが、今回のアイコンとなっています。 ==== 処理のタイミング ==== クレジットロールはActivityとして実装しています。 呼び出しは Intent で行いますが、呼び出した時点で処理されるわけではなく、登録だけされて、呼び出しはユーザプログラム側の処理が済んでシステムに処理が戻されたタイミングになります。 何がいいたいかというと、例えばゲームの開始時、オープニングのクレジットロールを呼び出すとともに、画面をゲームの初期画面に書き換えるのですが、オープニングの処理が始まる前に画面が書き換わるので、一瞬ちらっとゲームの開始画面が出た後にクレジットロールが始まります。 かっこ悪い。 暗転している裏で書き換えてほしいのに、舞台裏が見えちゃっているようでイケてません。 エンディングでも同じで、タイトル画面に戻ってからクレジットロールが始まります。 回避するためには、クレジットロール側の処理が終わって、Main Activityに処理が戻されるときに書き換えればいいことになります。 具体的には、処理が戻ってくるときに onResume() が呼び出されるので、ここで適切な書き換えをすればいいことになります。 単純に、ここに、オープニング処理から戻されたらしいならこれ、クリアしたらしいならこれ、という処理を書くと、スクリーンロックから復帰したり、タスクマネージャで別のアプリから戻されたときにも、onResume()が呼ばれるので、余計なタイミングでの呼び出しが発生することになります。 そこで、onResumeOnce という ()->Unit 型の ArrayListを作って、クレジットロールを呼び出すときに、画面書き換えの一連の処理を登録して、onResume()側では呼び出したら捨ててしまうという処理にすればいいのではないかと考えてそのようにしています。 val onResumeOnce: ArrayList<()->Unit> = ArrayList<()->Unit>() ... override fun onResume() { ... if (onResumeOnce.isEmpty()) return for(f in onResumeOnce) { f() } onResumeOnce.clear() } ... onResumeOnce.add { // 処理を書くと onResume()の中で一度だけ呼ばれます }