エフアンダーバー

個人開発の記録

シノビガミアプリの開発について

シノビガミの代用判定アプリの開発に関する記事です。

シノビガミの代用判定アプリ自体についての説明は前記事を参照してください。

www.f-sp.com

ソースコード

ソースコードはGitHubにて公開しています。

github.com

開発の経緯

前記事でも書きましたが、一般向けには書けなかったもう少し詳しい話を。

元々、ReactやElmを学んだことをきっかけに純粋関数型言語をしっかり学んでみたいと思い、 「すごいH本」こと『すごい Haskell たのしく学ぼう!』という書籍でHaskellの勉強をしていました。 すごいH本を読み終わり、さあ手を動かそう(いや変な意味でなく)と思ったのですが、 簡単すぎず難しすぎない手頃な制作対象が思い浮かばなかったので友人になんかないかと尋ねたところ、 「シノビガミの代用判定とかどうよ?」という回答が返ってきて、それだ、と思ったので開発を始めました。

友人に問いかけた時点では特に考えていなかったのですが、 どうせつくるならWebに公開して簡単に使えた方がいいよなと思い、 HaskellではなくPureScriptで書くことを決意、 そこからPureScriptの勉強を始めました。 実は以前にもPureScriptを学ぼうとしたことがあって、 その時は準公式っぽいドキュメントのサンプルでコンパイルエラーが出て、 これはダメだと諦めたのですが、 今回は先にHaskellを学んでいて、Haskellとの違いを確認するだけで済んだためなんとかなりました。

そんなこんなでPureScriptの練習としてつくったのが今回のアプリです。

開発環境

目的からしてPureScript以外の部分をあまり複雑にしたくなかったため、 webpackやParcelといったモジュールバンドラは使わずに、 PureScriptをコンパイルして終わりという形をとりました。

使用したnpmパッケージはPureScriptコンパイラであるpurescriptに、 PureScriptのパッケージマネージャとしてspago、 あとはデバッグ用ローカルサーバとしてhttp-serverだけです。 http-serverはルートパスへのアクセスをindex.htmlにリライトできないという問題があったため、 後にsuperstaticで置き換えました。

PureScriptのパッケージとしては、フレームワークにHalogen、テストにSpecを使いました。 Halogenの現バージョンはv4のようでしたが、v5のrc版が出ていたためそちらを使うことにしました (というか、最新版のpackage-setsを使ったらv5のrc版が指定されていました)。 Specは使い方を確認する程度に触ってみました。

UI設計

アプリとしてそれほど高機能なものをつくるつもりはなかったため、 入力内容等を一切保存せず、さっと設定してさっと使う、というイメージで設計しました。 以前作成した、クトゥルフ神話TRPGのアプリとは対照的ですね。 クトゥルフアプリはキャラクターシートの代わりに使うことを想定していましたが、 今回のシノビガミアプリはキャラクターシートと併用して使うことを想定しています。

シノビガミに限らず、TRPGはまずキャラクターを作成してから、それに対して様々な処理を行うというフローがあるため、 このアプリのページ分割もそれに則っています。 シノビガミではセッション中にキャラクターの特技が変わったりもするので完全にとはいきませんが、 設定画面はセッション前、判定画面はセッション中に行う操作が配置されています。

UIとして少し迷ったのは妖理のような対象特技を選ぶタイプの設定で、 元々は修得済みの特技をセレクトボックスから選ぶ形式でした。 ただこれだと、修得特技を選んでから妖理を設定しなくてはならなかったり、 修得特技が多い場合に選択しづらかったりと使い勝手がよくなかったため、表から選ぶ形式に変えました。 表から選ぶにしても、既存の表を再利用するか新しい表をつくるかで二通りありましたが、 見た目の簡潔さを重視して既存の表を文脈に応じて使いまわすことにしました。 プログラム的には新しい表を作った方が楽だろうとは思ったのですが、 フレームワークのおかげで表の生成と操作時の反応を分離できていたため、 再利用もそれほど苦も無く実現できました。

ビジュアルデザインについては最初から画像なしでフルCSSと決めていました。 画像を使わないのは単に管理が面倒なのと、私が効果的に画像を使えるとは思えなかったからです。 CSSならある程度できることが限られるのでデザインがイメージしやすく、 できないことはできないので諦めもつき、細かいことに労力を使わなくて済みます。

基調となる色やデザインはルールブックの表紙なんかを参考にしながら(パクリながら?)決めました。 とはいえ、それだけではどうにもならない部分も多かったので、フィーリングな部分も多々あります。 シノビガミの基本配色は白・黒・赤で、 前回のクトゥルフアプリが黒背景だったので、今回は白背景にしようかと思ったのですが、 白背景に黒赤となるとどうしても『和』な感じが前面に出てきてしまって難しかったのでやめました。 シノビガミはもちろん『和』の要素もあるのですが、それ以上に中二感が大事な気がするので。

表の部分は読みやすさを考慮して白背景にしています。 経路の表示方法については結構悩みました。 数字を表示するというのは最初から考えていたのですがこの表示方法が問題で、 目立ちすぎるとその部分の特技名が読めなくなりますし、 目立たなさすぎると数字が読めなくなります。 最初、特技部分が裏返って数字が出てくる演出とかカッコよさそう、と思い試してみたのですが、 特技名が読めなくなってしまうのでやめました。 最終的には商店の少し古めのポイントカードみたいな感じになりましたが、 実用性を考慮するとこんなものかなと思います。

表部分の操作、タッチ時に色を変えて離した時に選択する、 というのはモバイル端末の実機でデバッグした際に押しづらさを感じたので導入しました。 初期からギャップの選択はしづらいだろうなという予想はしていて、 特定のボタンでギャップの幅を変更するUIにしようかなどとは考えていたのですが、 実機で試してみたところギャップどころか普通の特技すら選択しづらかったので再考することにしました。 選択しづらいといっても幅が狭すぎるというよりは、 ディスプレイの厚みや指の太さの影響で目測とタッチ位置がずれることに原因があるようだったので、 位置修正を意識した現在の操作になりました。 実はマヒの操作方法は元々ロングタップだったのですが、この修正に伴い現在の形に変更しました。

最後にアプリアイコンについて。

アプリアイコン

実は開発にあたって一番最初につくったのはアプリアイコンでした。 本当は最後につくった方がアプリの雰囲気に合わせたアイコンになるのですが、 最初にアプリアイコンつくるとモチベーションが上がるんですよね。 まあ今回はそれが原因でアプリ本体とあまりマッチしていないアイコンになったわけですが……。

アイコンをデザインする上で一番難しかったのは「ミ」の扱いです。 私は頭の中でデザインできるほど想像力豊かではないので、 とりあえず「ノ」をアクセントとして区切りのように入れてあとはてきとーに、 くらいの方針でつくり始めました。 ところが、「ミ」に到達したところでこの文字だけ全体の流れに合わないことに気づき、苦戦するはめになりました。 アイコンは全体として左下と右上を結ぶような線で構成されているのですが、「ミ」は左上から右下に向かう線です。 最初は左右反転したような形で、右上から左下に向かう線にしてみたのですが、いまいち「ミ」に見えませんでした。 そこで今度は左下から右上に向かう線で、漢字の「三」のような書き方をしたところ少しマシになったので、こちらを採用することにしました。 一応みれるくらいにはなったかなと自負してます。

プログラム設計

プログラムに関してはフレームワークに従って書いただけで、設計と呼ぶほどのものはほとんどないのですが、 目標値や経路算出のあたりについてだけ少し書いておきます。

目標値の算出について、 最初は「修得特技それぞれに対するマンハッタン距離の最小値をとればいいだろう」くらいに考えていたのですが、 各忍法によって上下や左右が繋がったり斜め移動が可能になったりする上にそれらがあらゆる組み合わせで発生しうると理解した時に、 「真面目にモデル化しないと簡単に破綻するな」と直感したので少し構造について整理してみることにしました。

まず、特技表は各特技をノードとする無向グラフで、隣接特技間には水平方向にコスト2、垂直方向にコスト1のエッジがあります。 各忍法は有効である場合やギャップが埋められている場合には、このグラフのエッジを追加あるいは修正します。 例えば、忍法によって左右が繋がった場合には、左端の特技と右端の特技の間にコスト2のエッジを追加します。 目標値は、対象特技から任意の修得特技への最短経路のコスト(+5)です。 マンハッタン距離の計算に比べると計算コストはかかりますが、 特技表の大きさは11x6で固定なのでまあ問題ないだろうと判断しました。

このモデルをそのままプログラムに落としてもいいのですが、 存在するノードは固定ですし、エッジも特定のルールに従って追加されるので愚直にやる必要はありません。 各特技のノードは存在するものとして、二次元配列にデータだけ記憶し、座標によって識別します。 エッジは生成ルールだけを記憶し、各特技に対してその都度生成します。

また、最短経路の探索を毎回するのも手間なので、 グラフ生成時にすべての特技への最短経路を事前計算することにしました。 この場合、各修得特技からダイクストラ法で全探索すれば済みます。 この際、優先度付きキューが必要だったので、「ソートされていなければソートする」というひどい方法で優先度付きキューを実装しました。 速度面で問題があれば差し替えようと思っていたのですが、案外問題なく動きました。

なお、これらのグラフ探索ですが、 普通にやるとコピーコストが嵩みそうな気がしたのでSTモナドを使って手続き的に書きました。 単にSTモナドを使ってみたかったというのもあります。 比較していないのでSTモナドのおかげかどうかはわかりませんが、とりあえず速度面に問題はなさそうです。

PureScript所感

とりあえず良かった点から。

やはり使っていて最も素晴らしいと感じたのはコンパイルさえ通れば、バグがほとんど発生しない点ですね。 コンパイル後のバグで一番多かったのも、unsafe系関数の使用を誤ったことが原因でした。 副作用や状態遷移の有無が型として明示されているというのは本当に強いです。 この代償としてパフォーマンス問題が起きるんじゃないかと思っていたのですが、 少なくとも今回のアプリ程度では一切気にならない速さで動きました。

同じようなコンセプトを持ったElmとの比較としては、FFIが手軽に利用できるのがいいですね。 Elmの思想はElmの思想として理解できるのですが、 やはり今コードを書く上でFFIに逃げられるというのは非常に便利です。 あとは型クラスによる抽象化が非常に高度ですね。 難しい一方で、自分で下手な抽象化をする必要がないのは嬉しいところです。

また、PureScriptは書いていて非常に面白い言語でした。パズル的な楽しさがあります。 言語としてそれがいいかどうかはさておき、趣味で書く上では重要な性質です。 Effect (Maybe a)で返ってきた結果から>>= traverse_ \x -> ...Effect Unitを生成してるコードを見て、 なるほどなぁと感心したりしていました。 まだFreeモナドやコモナドはおろか、モナド変換子すらまともに触っていないので、 知る楽しみはまだまだありそうです。

……とはいえ、良い言語ほど悪い部分は目につくもので。 以降はイマイチだった点についてです。

PureScriptは言語としては新しい方ではありますが、それでも発表は2013年だそうです。 その頃からはWebも随分と様変わりしました。 一方でPureScriptはその変化にいまいちついていけてないように感じます。 ServiceWorkerあたりを書こうとすると必要機能がほとんどないため、FFIが酷いことになります。 また、モジュールの仕様も未だCommonJSです。 FFIでは何故かJavaScriptを解析しているようでちょっと新しめの文法を使うとエラーになります(エディタ拡張の問題?)。 比較的マイナーな言語で人手が足りていないのかなとも思いますが、 変化の速いWeb用の言語としては少しつらいところです。

また同じく人手不足が原因と思われる問題として、 開発環境がまだ十全でないかなと感じました。 特に困ったのはコードフォーマットとリネームリファクタリングです。 フォーマッタはあるにはあったのですが、使ってみたところ、 文字列リテラル中の日本語がすべてUnicodeエスケープシーケンスに変えられてしまって使い物になりませんでした。 PureScriptコンパイラ自体も同じ仕様みたいなのでその辺に原因があるのかもしれません。 自動リネームリファクタリングは個人的によく使うので対応してほしいところ。

言語仕様面ではlettypeあたりで困惑しました。

letは初期値の式を複数行にわたって書こうとしたときにエラーが発生してしばらく悩みました。 具体的には次のような感じ。

let x = foo do
  bar
  baz

letでは同時に複数の変数を定義できるのでそれと勘違いされてしまうようです。 わかってしまえば回避方法はいくらでもあるのですが、初心者の感想として直感的じゃないなと思いました。

typeについてはいらないものまでエクスポートしてしまう点が気に入りませんでした。 HalogenではReactのようにInput(Props)の内容をそのまま参照することができないため、 Inputで取得した内容を一度Stateに保存します。 そのため、InputとStateの型には共通部分ができます(StateにInputを包含するのが正解なのかもしれませんが)。 そこでInputの型の定義を元にStateの型を定義しようと思ったのですが、 どうも直接それはできないようなので、InputのRowをtypeで定義してそれによってそれぞれの型を定義することにしました。

type InputRows = ( foo :: Int )
type Input = { | InputRows }
type State = { bar :: Int | InputRows }

しかし、そうして定義されたInputをエクスポートしようとしたところ、InputのRowの定義のエクスポートまでもが要求されました。 Rowの定義というのはモジュール内の記述のための便宜的なものであって、モジュール外に公開するような仕様ではありません。 現状typeは元の型と同一とみなせる型の定義となっているようですが、もっと単純な別名定義が欲しいと感じました。 あと複数のRowのマージなど手軽にレコード型の操作ができるようになると嬉しいですね。

最後、難しそうだなと思いつつも最もどうにかしてほしいと願っているのはエラーメッセージです。 強力な型推論を積んでいる代償なのか、何がどう間違っているのかをエラーメッセージから読み取るのが非常に困難です。 そのうえ結構な確率で、エラーの表示箇所と実際の問題箇所がずれています。 最初のうちは発生箇所に何か問題があると思い込んでいたので、原因を見つけるのに骨が折れました。

……と、(不満ばかり)いろいろ書きはしましたが、総評としては好印象でした。 悪い部分もコンセプトが云々というよりは開発が追い付いていない部分が多いので、 これからより良くなっていくことに期待ですね。

ブラウザバグたち

Webフロントエンド開発において最も手強い相手というのはブラウザのバグだと思います。 今回、せっかくだからCSSの新しい機能を試そうといろいろ使った結果、大量にバグを踏み抜きました。 同じバグに悩まされている人のためにとりあえず全部書いておこうと思います。

これらのバグはブラウザに報告はしていません。 報告方法を少し調べて、面倒になったのでやめました。 報告に慣れている方がいれば、代わりに報告してもらえると助かります。

現象確認時の正確なバージョンまでは覚えていませんが、 ひと月以内のことなのでだいたい以下の通りだと思います。

  • Chrome: 75
  • Firefox: 68
  • iOS Safari: 12.3

未対応

アプリにて現在も確認できるもの。 見方を変えれば、アプリ側ではちょっとどうしようもないものです。

縦書きモードと表

対象ブラウザ: Firefox, iOS Safari

縦書きモード(writing-mode:vertical-rl)で表を使うと、表の中身がずれるようです。 FirefoxとiOS Safariで壊れ方は違いますが、どちらも正しく表示されません。 すごくわかりやすく壊れているので、 対象ブラウザにて縦画面でアプリを使えば一目でわかると思います。

Firefoxでは画面サイズを変えると何故か正しくなったりすることがあります。

表の枠線の消失

対象ブラウザ: Firefox

細い枠線を持った表のセルに対して、背景になんらかの変更を加えると、 近くの(隣接しているとは限らない)枠線が消失したように見えます。 アプリの表のセルが白に近いグレーなためわかりづらいですが、 よくよく見てみると枠線の色が完全な白になってるのがわかります。

何かの計算誤差が原因かと思い、開発ツールで枠線の太さを変えて実験してみることにしました。

もともとは1pxです。
2pxにしてみました。直りません。
3pxにしてみました。直りました。
1pxに戻してみました。直りました。

……意味がわかりません。

表端の謎の空白

対象ブラウザ: Chrome

ビューポートサイズによって、表の終端側の枠線が始端側に比べて太く表示されることがあります。 詳しく調べてみると、どうも枠線自体が太くなっているわけではなく、 表の終端側に謎の空白が挿入されていて、その分だけ背景が多く表示されるために太く見えるようでした (アプリの表は透明な枠線で背景を透かすことで枠線があるように見せかけています)。

さらによく見てみると、始端側の最初のセルが別のセルと比べて若干小さくなっています。 どうも表のサイズを計算した後にセルの配置を誤った結果、終端側が余ってしまったように見えます。

縦書きモードと日本語の位置ズレ

対象ブラウザ: Chrome

何故かAndroid版のChromeでのみ発生するようなのですが、 縦書きモードにて日本語が行ボックスからはみ出して描画されるようです。

見た感じの印象としては、文字のベースラインがボックスの端と一致するように描画されているように思えます。

対応済

対応済みでアプリにてもう確認できないもの。 見方を変えれば、対処法のあるものです。

論理プロパティのborderとCSS変数

対象ブラウザ: iOS Safari

論理プロパティ版のborderでCSS変数を指定すると正しくパースされないみたいです。

.border {
    border-bottom: 1px solid red;            /* OK */
    border-bottom: 1px solid var(--red);     /* OK */
    border-block-end: 1px solid red;         /* OK */
    border-block-end: 1px solid var(--red);  /* Error */
}

こうして並べてみると「なんでだよ!?」とツッコミたくなりますが、 よく考えるとCSS変数を含めたショートハンドのパースってすごく面倒くさそうですよね。 中身見ないと、どれに対する指定なのかがわからないあたり。

Grid Layoutと縦書きモード

対象ブラウザ: Chrome

横書きのGrid Layoutの中に縦書きのGrid Layoutを入れると内側の領域の計算でバグるようです。 DevToolsで分割を見てみると、どうも横書きの要素で長さを計算してから縦書きの要素を配置しているような印象です。

ちなみに、何故か縦書きGrid Layoutにさらに横書きの要素を追加してやると直ります。

.container::before {
    content: "";
    visibility: hidden;
    writing-mode: horizontal-tb;
}

わけがわからないよ……。

Grid Layoutによるセンタリング

対象ブラウザ: iOS Safari

Grid Layoutを利用してひとつの要素のセンタリングをしようとするとうまく配置されないことがあるようです。

これに関してはあまり詳しく調べていないため、 何か他の部分が原因だった可能性も高いですが、 他のブラウザでは動いたけどSafariで動かなかったケースにこういうパターンがあったということで一応参考までに。

.container {
    display: grid;
    align-items: center;
    justify-items: center;
}

all:unsetの罠

対象ブラウザ: iOS Safari

all:unsetによってCSSリセットをした場合にcolorが一切反映されなくなります。

原因は-webkit-text-fill-colorという非標準プロパティにあるようです。 -webkit-text-fill-color-webkit-text-stroke-colorとセットで文字の内部と枠を分けて描画するためのもので、 他のブラウザでは初期値がcurrentColorなのでcolorの色が参照されますが、 Safariでは別の値が入っているようでcolorが無視されてしまいます。 あるいはinheritcurrentColorという値ではなく色自体を継承してしまっているという可能性もあるかもしれません。

値をcurrentColorで上書きしてしまえば直ります。

*, *::before, *::after {
    all: unset;
    -webkit-text-fill-color: currentColor;
}

transformの拡大と文字

対象ブラウザ: iOS Safari

transformプロパティによって要素を拡大すると要素の文字がぼやけます。

Safariと他のブラウザで、transformプロパティの適用方法に違いがあるようで、 他のブラウザでは文字を変形させてから描画するのに対し、 Safariでは文字を描画してから変形させるようです。 結果として、他のブラウザでは拡大後も文字の輪郭がくっきりしているのに対し、 Safariでは輪郭部分が拡大時に補間されることでぼやけてしまいます。

仕様がどうなっているのか知らないのでバグと呼んだものかどうかはわかりませんが、 文字がぼやけることを期待する人はあまりいないでしょうし、直してほしいなぁという感じ。

font-sizeの方を大きくすることで一応回避はできます(レイアウトに影響するので面倒ですが)。

多重filter

対象ブラウザ: iOS Safari

filterに何重にもフィルタを指定すると表示がバグることがあるようです。

ちょっとおかしいとかそういうレベルではなく、 明らかに何かを取り違えたかのように表示が乱れます。 偶発的に発生するので、非同期処理の問題か、メモリプール的な問題じゃないかと思います。

元々アプリ中の文字の縁取りは次のような力技で実現していたのですが、

.outline {
    filter:
        drop-shadow(-3px 0 0 var(--black))
        drop-shadow(3px 0 0 var(--black))
        drop-shadow(0 -3px 0 var(--black))
        drop-shadow(0 3px 0 var(--black))
        drop-shadow(-3px 0 0 var(--red))
        drop-shadow(3px 0 0 var(--red))
        drop-shadow(0 -3px 0 var(--red))
        drop-shadow(0 3px 0 var(--red))
        drop-shadow(0 0 10px var(--white));
}

仕方がないのでより力技で実現することにしました。

.outline {
    filter: drop-shadow(0 0 10px var(--white));
    text-shadow:
        -4px 0 0 var(--black),
        -2.83px -2.83px 0 var(--black),
        -2.83px 2.83px 0 var(--black),
        0 -4px 0 var(--black),
        0 4px 0 var(--black),
        2.83px -2.83px 0 var(--black),
        2.83px 2.83px 0 var(--black),
        4px 0 0 var(--black),
        -8px 0 0 var(--red),
        -6.93px -4px 0 var(--red),
        -6.93px 4px 0 var(--red),
        -4px -6.93px 0 var(--red),
        -4px 6.93px 0 var(--red),
        0 -8px 0 var(--red),
        0 8px 0 var(--red),
        4px -6.93px 0 var(--red),
        4px 6.93px 0 var(--red),
        6.93px -4px 0 var(--red),
        6.93px 4px 0 var(--red),
        8px 0 0 var(--red);
}

insetbox-shadowのはみ出し

対象ブラウザ: iOS Safari

透明なborderを持つ要素にinsetとオフセットを指定してbox-shadowを設定すると、 border上にはみ出して表示されてしまいます。

仕様を読んでいないのでバグかどうかわかりませんが、バグっぽい挙動だなと思います。

.inset-shadow {
    border: 1px solid transparent;
    box-shadow: inset -1px -1px 2px 0 var(--gray);
}

最上位のposition:absolute

対象ブラウザ: Chrome

祖先要素にposition:static以外の値が指定されていない要素にposition:absoluteを指定した場合に、 ビューポートサイズの変更が正しく反映されない場合があるようです。 一瞬で大きくサイズを変えないと起きない、あるいは変化がわからないようなので、 DevToolsにてモバイル用の画面回転機能を使うと確認しやすいと思います。

position:absoluteの代わりにposition:fixedを指定すると直りました。

おわりに

とりあえず開発に関すること片っ端から書いてみたんですが、 よく整理もせず思いつくままに書き足していったのでちょっと読みづらいですね、すみません……。

シノビガミアプリの拡張についてはいくつか考えてGitHubのIssueに書いておきましたが、 実際にやるかどうかは未定です。 拡張について考え出すと、以前のクトゥルフアプリ同様キャラシの代替のようになってしまって、 どこまでやるべきか悩むのですよね。 クトゥルフアプリもそれほど使っている人は多くないみたいですし、 どうするにしろ、しばらくは様子見ですね。



執筆時のPureScriptのバージョン:0.13.2