18
Help us understand the problem. What are the problem?

posted at

updated at

タイピングガチ勢が本格的タイピングゲーム(ローマ字入力)を実装してみた

※この記事はタイパーアドベントカレンダー2019 17日目の記事です。

こんにちは!白狐(びゃっこ)といいます。

今回は、Unity で本格的にタイピングゲームを実装してみた話をアドベントカレンダーのネタとして書いてみようと思います。

(2021/6/20 追記)
4番の項目を追加しました。コメントにあった実装方法を採用したものです。

(2021/12/12 追記)
ローマ字日本語入力タイピングゲーム製作の一番めんどくさい「複数入力判定」を助ける という記事を書きました!
以下で紹介している実装のめんどくさい部分をライブラリ化したものになっています。併せて読んでください!

0: 筆者のタイピングの腕前

興味がなければ飛ばしてください。

詳しくはツイッターに載っていますが、とりあえずすぐに腕前がわかってもらえそうなところだと以下のツイートかなと思います。寿司打は有名なので、一度はプレイしたことあるんじゃないでしょうか。

e-typing の腕試しはローマ字最高 743(ワード:元気が出る言葉)です。

1: 実装する内容

今回はタイピングゲームの肝となる以下の部分に絞って実装を行います。

  • 文章(日本語、ひらがな、ローマ字)を表示する
  • キーボードから入力を受け付けて正誤判定を行う
  • 複数の入力方法(e.g. 「ふ」→ 「fu」「hu」など)の対応

Unity の UI や Text の細かい扱いについては Unity の扱い方について説明されている記事に任せることにします。

ゲームの流れは

  1. 問題文を表示する(日本語、ひらがな、ローマ字)
  2. キーボードからの入力を受け付けて、正誤判定を行う
  3. 1文入力が終わったらランダムに問題文を選んで1に戻る

というような感じになります。

2: 実際の実装について

2-1: UI 部分を作成

まずは「日本語文」「ひらがな文」「ローマ字入力文」の部分を作っていきます。ここは今回本質ではないのでスクリーンショットだけ掲載しておきます。

スクリーンショット 2019-12-16 13.58.27.png

一番上の「あいうえお...」の部分に日本語文、上から2つ目の「あいうえお...」の部分にひらがな文、上から3番目の oooooxxxxx の部分にローマ字の文章が表示されるようにテキストを配置します。
「あいうえお」など、文章を仮置きしておくと何文字くらい入るか見えるのでこのようにしてあります。

下の白い枠2つは何文打ち終わったかとか速度とか正確さを表示するためだったり、操作方法などを記述するために設けたので今回は無視していいです。

2-2: 文章からローマ字入力文の生成

次は自分で用意した文章からローマ字入力文を生成する方法です。

例えば「こんにちは」という例文から「konnnitiha」「konnnichiha」などと生成できるようにします。

ここが結構難しいです。なぜならローマ字入力においてひらがな1, 2文字に対し入力の仕方が1通りに定まっているわけではないからです。

例として、

  • 「ふ」は fu, hu どちらでも打てる
  • 「きゃ」は kya, kixya, kilya がある
  • 「しゃ」は sya, sha, silya, sixya, shilya, shixya, (さらに「し」は ci を許容することもあるため、cilya, cixya もOK)
  • 「ん」は基本 nn, xn だが、「ん」の手前が母音、ナ行、ヤ行、ニャ行でない、かつ文末の「ん」でないならば n でも良い
  • 「っ」は xtu, ltu, xtsu, ltsu のほか、子音を2つ重ねることでも打てる(「だった」は datta と打つように)
  • マイナーで例外としてほとんど知られていないですが「グッナイ」「イッヌ」のように「っ」+「ナ行」は子音の n を2つ重ねても「っ」は打てないので「っ」は xtu, ltu, xtsu, ltsu で打つしかない

といったようにローマ字入力ではかなりいろいろな入力方法があります。

人によっては冗長に打つ方法をあえて用いることでタイピングゲームにおいてスコアや精度を上げる人もいるので、冗長な入力方法を排除するわけにはいきません(例えば e-typing のように測定時間にレイテンシを含む仕様だと短い文章ではスコアが低めに出てしまうためあえて長く冗長に打つ戦法が有効なことがあります)。

また、このようにひらがな1, 2文字に対して複数の入力方法があるため、いちいち自分でローマ字文を生成すると、最悪指数オーダーで入力パターンが増えてしまうことが考えられるので、ローマ字入力文のデータの持ち方も工夫しなければなりません。

たったひらがな20文字程度の文章でも、ひらがな1文字に対して2通りのローマ字入力方法があるとすると、その文章を打つ方法は $2^{20} = 1048576$ 通りあり、いちいち手入力で生成してたら間違いなく人生が終了してしまいます Σ(っºΔºc)...


ここで、ローマ字入力においては、一度に高々ひらがな2文字分までしか入力できないという性質を利用してひらがな文からローマ字入力文を生成することを考えます(「っちゃ」などは ttya とかで3文字入力できますがここでは考えないことにします)。具体的に言うとひらがな文の解析に uni-gram と bi-gram を用います。

例えば「一家に一台エアコン」という例文があったとして、ひらがな文とローマ字入力文の対応のイメージは以下のような感じです。表で書いてますがオートマトンみたいなものを生成すると思えば良いでしょうか。

i k ka ni i ti da i e a ko n
yi ltu ca yi chi yi nn
xtu xn
ltsu
xtsu

この表の中から無効なつながり(例えば「っか」は kka はOKだけど kca と打つのはダメ)を排除します。これは入力判定の部分で扱うことにします。

この例文には出てきませんが、「きゃ」などは kya と打つパターンや「き」+「ゃ」と打つパターンも考慮します。

ki lya
xya
きゃ
kya

準備としてあらかじめ日本語の文章の読み方をひらがなで書いておきます。例えば日本語文とひらがな文をペアにしたもののリストを持っておくとかが良いでしょう。リストにすることで後でランダムに問題文を抽出するのにも使えます。

private List<(string jp, string h)> qJP1 = new List<(string jp, string h)>() {
    ("一家に一台エアコン", "いっかにいちだいえあこん")
};

また、ひらがなからローマ字へのマップを行う Dictionary も作っておきます。ひらがなを key、ローマ字入力のパターンを持った配列を value にします。例えば「あ」→["a"]、「ん」→["n", "nn", "xn"], 「しゃ」→["sya", "sha"] という感じです。ここでは「し」+「ゃ」みたいなパターンは入れないでおきます。

private Dictionary<string, string[]> mp = new Dictionary<string, string[]> {
    {"あ", new string[1] {"a"}},
    {"い", new string[2] {"i", "yi"}},
    {"う", new string[3] {"u", "wu", "whu"}},
    {"え", new string[1] {"e"}},
    {"お", new string[1] {"o"}},
    {"か", new string[2] {"ka", "ca"}},
    {"き", new string[1] {"ki"}},
    {"く", new string[3] {"ku", "cu", "qu"}},
    {"け", new string[1] {"ke"}},
    {"こ", new string[2] {"ko", "co"}},
    {"さ", new string[1] {"sa"}},
    {"し", new string[3] {"si", "shi", "ci"}},
    {"す", new string[1] {"su"}},
    {"せ", new string[2] {"se", "ce"}},
    {"そ", new string[1] {"so"}},
    {"た", new string[1] {"ta"}},
    {"ち", new string[2] {"ti", "chi"}},
    {"つ", new string[2] {"tu", "tsu"}},
    {"て", new string[1] {"te"}},
    {"と", new string[1] {"to"}}
    ......
};

タイピングサイトで対応している入力方法を参考に頑張って打ち込みました。結構疲れます。 記号等も含めてこれだけで 220 行になってしまいました。

このひらがなの文章を uni-gram と bi-gram で同時に解析することで考えられるすべての入力パターンを列挙できるようにします。

はじめに、ひらがなの文章を先頭からパースして、1, 2文字ごとに区切っていきます。「きゃ」などは「き」+「ゃ」にここでは分解せず、長くマッチングする方に合わせます。例えば「きゅうきゅうしゃ」なら「きゅ|う|きゅ|う|しゃ」という感じです。

List<string> ParseHiraganaSentence(string str){
    var ret = new List<string>();
    int i = 0;
    string uni, bi;
    while (i < str.Length){
        uni = str[i].ToString();
        if(i + 1 < str.Length){
            bi = str[i].ToString() + str[i+1].ToString();
        }
        else {
            bi = "";
        }
        if(mp.ContainsKey(bi)){
            i += 2;
            ret.Add(bi);
        }
        else {
            i++;
            ret.Add(uni);
        }
    }
    return ret;
}

コードを解説すると str はひらがな文、uni は 0-index でひらがな文の i 文字目のひらがなを格納する変数で、bi は 0-index でひらがな文の i 文字目と i+1 文字目のひらがなを格納しています。例えば、ひらがな文 strstr = "きゅうきゅうしゃ"」で i = 3 のとき uni = "き", bi = "きゅ" です。2文字取ってこれない場合 (i = str.Length - 1)は bi = "" としておきます。

そして、先ほど作った Dictionary に対して、biuni の順でこれをキーとして検索をかけて、マッチングしたらここで区切るというふうにします。

これでひらがな1, 2文字ごとに区切る処理は終わりです。このメソッドに str = "きゅうきゅうしゃ" を引数として渡してあげると ["きゅ", "う", "きゅ", "う", "しゃ"] という感じで、ひらがな文が区切られたものがリストで返ってきます。

あとは、このリストに入った文字列をキーとしてふたたび先ほどの Dictionary に検索をかけて得られた文字列をどんどん突っ込んでいきます。"きゅ" などは "きゅ" と検索して得られたローマ字入力と "き" "ゅ" に分けて検索したものを結合したローマ字入力パターンを両方格納していきます。

例文が "きゅうきゅうしゃ" のとき、先頭からローマ字入力文字列に変換していくと以下のようになります。

1: "きゅ"

ki lyu
xyu
きゅ
kyu

結合して

きゅ
kyu
kilyu
kixyu

2: "う"

u
wu
whu

3: 4, 5文字目の"きゅ",6文字目の"う"は先ほど同様,
4: 3文字目 "しゃ"

si lya
shi xya
ci
しゃ
sya
sha

結合して

しゃ
sya
sha
silya
sixya
shilya
shixya
cilya
cixya

5: 最後に文章全体で結合

きゅ きゅ しゃ
kyu u kyu u sya
kilyu wu kilyu wu sha
kixyu whu kixyu whu silya
sixya
shilya
shixya
cilya
cixya

これでほとんどの例文のローマ字入力文が生成できるようになりました!

あとは、

  • 「ん」は基本 nn, xn だが、「ん」の手前が母音、ナ行、ヤ行、ニャ行でない、かつ文末の「ん」でないならば n でも良い
  • 「っ」は xtu, ltu, xtsu, ltsu のほか、子音を2つ重ねることでも打てる(「だった」は datta と打つように)

の例外パターンも変換できるようにして完成です。

コードは以下のような感じです。雰囲気だけ伝わってくれればいいです。

public List<List<string>> ConstructTypeSentence(List<string> str){
    var ret = new List<List<string>>();
    string s, ns;
    for (int i = 0; i < str.Count; ++i){
        s = str[i];
        if(i + 1 < str.Count){
            ns = str[i+1];
        }
        else {
            ns = "";
        }
        var tmpList = new List<string>();
        // ん の処理
        if (s.Equals("ん")){
            bool isValidSingleN;
            var nList = mp[s];
            // 文末の「ん」-> nn, xn のみ
            if(str.Count - 1 == i){
                isValidSingleN = false;
            }
            // 後ろに母音, ナ行, ヤ行 -> nn, xn のみ
            else if(i + 1 < str.Count &&
                (ns.Equals("あ") || ns.Equals("い") || ns.Equals("う") || ns.Equals("え") ||
                ns.Equals("お") || ns.Equals("な") || ns.Equals("に") || ns.Equals("ぬ") ||
                ns.Equals("ね") || ns.Equals("の") || ns.Equals("や") || ns.Equals("ゆ") || ns.Equals("よ"))){
                isValidSingleN = false;
            }
            // それ以外は n も許容
            else {
                isValidSingleN = true;
            }
            foreach (var t in nList){
                if(!isValidSingleN && t.Equals("n")){
                    continue;
                }
                tmpList.Add(t);
            }
        }
        // っ の処理
        else if(s.Equals("っ")){
            var ltuList = mp[s];
            var nextList = mp[ns];
            var hs = new HashSet<string>();
            // 次の文字の子音だけとってくる
            foreach (string t in nextList){
                string c = t[0].ToString();
                hs.Add(c);
            }
            var hsList = hs.ToList();
            List<string> ltuTypeList = hsList.Concat(ltuList).ToList();
            tmpList = ltuTypeList;
        }
        // ちゃ などのように tya, cha や ち + ゃ を許容するパターン
        else if(s.Length == 2 && !string.Equals("ん", s[0])){
            // ちゃ などとそのまま打つパターンの生成
            tmpList = tmpList.Concat(mp[s]).ToList();
            // ち + ゃ などの分解して入力するパターンを生成
            var fstList = mp[s[0].ToString()];
            var sndList = mp[s[1].ToString()];
            var retList = new List<string>();
            foreach (string fstStr in fstList){
                foreach (string sndStr in sndList){
                    string t = fstStr + sndStr;
                    retList.Add(t);
                }
            }
            tmpList = tmpList.Concat(retList).ToList();
        }
        // それ以外
        else {
            tmpList = mp[s].ToList();
        }
        ret.Add(tmpList);
    }
    return ret;
}

ここまでできたらあとは最初に作成した UI 部分に日本語文、ひらがな文、ローマ字入力文を表示してあげます。

2-3: キーボード入力受付と正誤判定

いよいよキーボード入力を受け付けて、正誤判定をする部分に移ります。

まずキーボード入力を受け付ける部分を書きます。

他の人のタイピングゲームのコードを読むと Unity の Update() を用いている人がいますが、これではダメです。知っている限り最も速い人で平均 20~21 key/s の速度で打つので一見 60fps もあれば十分な気がしますが、Update() が呼ばれた時点で「キーが押されているかどうか」で判断するので、Update() を用いてしまうとキーを押したのに押してない判定になったということがかなり発生します。フレームレートに依存してしまうと言ったほうがいいでしょうか。

どうしようかと思ったのですが、いい記事があったので参考にしてみました。

Sophie's Blog : Super-fast input in Unity!

この記事の "framerate independent model" がとても良かったので、アイデアを拝借しました。

Update() の代わりに OnGUI() を用いようというものです。これはキーが押されたりキーが離されたりすると呼び出されるのでタイピングゲームには適合します。

// キーが入力されるたびに発生する
void OnGUI() {
    Event e = Event.current;
    if (isInputValid && e.type == EventType.KeyDown && e.type != EventType.KeyUp && e.keyCode != KeyCode.None
        && !Input.GetMouseButton(0) && !Input.GetMouseButton(1) && !Input.GetMouseButton(2)){
        if (isFirstInput){
            firstCharInputTime = Time.realtimeSinceStartup;
            isFirstInput = false;
        }
        queue.Enqueue(e.keyCode);
        timeQueue.Enqueue(Time.realtimeSinceStartup);
    }
}

コードを簡単に説明すると、isInputValid はキーボードから入力を受け付けてよいかどうかを表す bool 型変数(クラス内に定義してあるのでメソッド内には書いていない)です。

Event e = Event.current はキーボード入力などのイベント情報を格納する変数です。今回は「キーが押された」というイベントだけを取得したいので、次の if 文では「キーが離された」というイベントをきちんと弾くようにしてあります。あとは念のためマウスで入力されても何も起きないようにしてあります。

そのあとは何のキーが入力されたかを取得して、Queue にキーのコードを入れている感じです。同時にいつ押されたかの情報も別の Queue に入れてあります。

if (isFirstInput) { ... } の部分は、新しい問題文が表示されてから1文字目を打つまでのレイテンシは計測時間に含めないように実装をしたかったのでそのように書いてあります(実際の実装では問題文が表示されてから2秒間猶予があります)。

あとは、キー入力の正誤判定を実装します。

これは Queue に入っているキーコードを Dequeue して取り出し、前に構築したひらがなとローマ字入力の対応テーブルをチェックし、無効になったものを随時排除していくことでできます。

例えば「きゅうきゅうしゃ」という例文の kyuu まで打ちおわっていたとすると以下の表の太文字部分をたどっていることになります。

きゅ きゅ しゃ
kyu u kyu u sya
kilyu wu kilyu wu sha
kixyu whu kixyu whu silya
sixya
shilya
shixya
cilya
cixya

ここから Queue に k, y, l, u, u, s の順で入ったとすると、判定は以下のような遷移になります。太字イタリック部分が判定で見た部分となります。打ち消し線はその部分はもう判定の対象外となっているという意味です。

1: k ... kyu, kilyu, kixyu の3パターンを全て残す

きゅ きゅ しゃ
kyu u k yu u sya
kilyu wu k ilyu wu sha
kixyu whu k ixyu whu silya
sixya
shilya
shixya
cilya
cixya

2: y ... kyu だけを残す

きゅ きゅ しゃ
kyu u k y u u sya
kilyu wu k i lyu wu sha
kixyu whu k i xyu whu silya
sixya
shilya
shixya
cilya
cixya

3: l ... ミスタイプなので kyu の u は見るが先には進めない

きゅ きゅ しゃ
kyu u ky u u sya
kilyu wu ki lyu wu sha
kixyu whu ki xyu whu silya
sixya
shilya
shixya
cilya
cixya

4: u ... kyu の u と一致する

5: u ... u, wu, whu の先頭の文字を見て u のみ残す

きゅ きゅ しゃ
kyu u ky u u sya
kilyu wu ki lyu w u sha
kixyu whu ki xyu w hu silya
sixya
shilya
shixya
cilya
cixya

6: s ... 「しゃ」のうち s で始まるもののみ残す

きゅ きゅ しゃ
kyu u kyu u s ya
kilyu wu k i lyu w u s ha
kixyu whu k i xyu w hu silya
sixya
shilya
shixya
c ilya
c ixya

ここの判定部分のコードは結構ややこしいことになってしまっているので、今回は割愛させていただきます(興味があればわたしの github の repository を覗いてみてください)。ここでも「ん」と「っ」だけは例外処理を行っています。

3: できたもの

UnityRoom に仮で公開しているものをリンクとして貼っておきます。

ランダム例文生成機能や速度や時間測定、正確さやスコア測定などいろいろ付け足してあります。デザイン面は仮置きなのでご了承ください。

FoxTyping

とりあえず最小限の機能だけです。

4: 別実装(おすすめ、2をある程度理解することが前提)

(2021/6/10追記)

「しゃ」を "sya", "sha" と一度に入力してしまうパターンと「し」+「ゃ」とするパターンをコード内で条件分岐するというように実装しました。最初に入れる「ひらがな」から「ローマ字」へのマッピング部分のデータ量は少なくて済みますが、これだとバグったときの発見のしづらさやテストのしづらさがあり、結構めんどくさかったりします。

そこで、コードの例外処理を全て削除し、メンテナンス性やテストのしやすさを向上させるためにひらがな文のパースの仕方をさらに変えてみます。

先ほどの実装では高々2文字までとしていましたが、今度は3文字までに広げてみましょう。
そして、さきほどの「ひらがな」から「ローマ字」へのマッピングを行う部分を以下のように変更します。

  • 「あいうえお」〜「わをん」といった1文字打つものはそのまま
  • 「きゃ」などを「き」+「ゃ」に分けるのではなく最初から kya, kixya, kilya などとして変換してしまう
  • 「ん」+「あ〜ん」「きゃきゅきょ...」のパターンも全てマッピングしてしまう
  • 「っ」+「あ〜ん」「きゃきゅきょ...」のパターンも全てマッピングしてしまう

あいうえお〜わをん、がぎぐげご〜ばびぶべぼ、ぱぴぷぺぽなどはこれまでと同じマッピングにします。

private static Dictionary<string, string[]> romanTypeMap = new Dictionary<string, string[]> {
{"あ", new string[1] {"a"}},
{"い", new string[2] {"i", "yi"}},
{"う", new string[3] {"u", "wu", "whu"}},
{"え", new string[1] {"e"}},
{"お", new string[1] {"o"}},
{"か", new string[2] {"ka", "ca"}},
{"き", new string[1] {"ki"}},
{"く", new string[3] {"ku", "cu", "qu"}},
{"け", new string[1] {"ke"}},
{"こ", new string[2] {"ko", "co"}},
...

きゃきゅきょなどは以下のようにマッピングします。さきほどコードで「きゃ」と打てるパターンと「き」+「ゃ」と打てるパターンを最初から入れてしまうのがポイントです。

...
{"きゃ", new string[3] {"kya", "kilya", "kixya"}},
{"きぃ", new string[5] {"kyi", "kili", "kilyi", "kixi", "kixyi"}},
{"きゅ", new string[3] {"kyu", "kilyu", "kixyu"}},
{"きぇ", new string[5] {"kye", "kile", "kilye", "kixe", "kixye"}},
{"きょ", new string[3] {"kyo", "kilyo", "kixyo"}},
{"ぎゃ", new string[3] {"gya", "gilya", "gixya"}},
{"ぎぃ", new string[5] {"gyi", "gili", "gilyi", "gixi", "gixyi"}},
{"ぎゅ", new string[3] {"gyu", "gilyu", "gixyu"}},
{"ぎぇ", new string[5] {"gye", "gile", "gilye", "gixe", "gixye"}},
{"ぎょ", new string[3] {"gyo", "gilyo", "gixyo"}},
{"くぁ", new string[8] {"qa", "kwa", "kula", "kuxa", "cula", "cuxa", "qula", "quxa"}},
{"くぃ", new string[14] {"qi", "qyi", "kuli", "kuxi", "kulyi", "kuxyi", "culi", "culyi", "cuxi", "cuxyi", "quli", "quxi", "qulyi", "quxyi"}},
{"くぅ", new string[7] {"qwu", "kulu", "kuxu", "culu", "cuxu", "qulu", "quxu"}},
{"くぇ", new string[14] {"qe", "qwe", "kule", "kuxe", "kulye", "kuxye", "cule", "cuxe", "culye", "cuxye", "qule", "quxe", "qulye", "quxye"}},
{"くぉ", new string[8] {"qo", "qwo", "kulo", "kuxo", "culo", "cuxo", "qulo", "quxo"}},
{"ぐぁ", new string[3] {"gwa", "gula", "guxa"}},
{"ぐぃ", new string[5] {"gwi", "guli", "gulyi", "guxi", "guxyi"}},
{"ぐぅ", new string[3] {"gwu", "gulu", "guxu"}},
{"ぐぇ", new string[5] {"gwe", "gule", "guxe", "gulye", "guxye"}},
{"ぐぉ", new string[3] {"gwo", "gulo", "guxo"}},
{"しゃ", new string[8] {"sya", "sha", "silya", "sixya", "shilya", "shixya", "cilya", "cixya"}},
{"しぃ", new string[13] {"syi", "sili", "sixi", "silyi", "sixyi", "shili", "shixi", "shilyi", "shixyi", "cili", "cixi", "cilyi", "cixyi"}},
{"しゅ", new string[8] {"syu", "shu", "silyu", "sixyu", "shilyu", "shixyu", "cilyu", "cixyu"}},
{"しぇ", new string[14] {"sye", "she", "sile", "silye", "sixe", "sixye", "shile", "shilye", "shixe", "shixye", "cile", "cilye", "cixe", "cixye"}},
{"しょ", new string[8] {"syo", "sho", "silyo", "sixyo", "shilyo", "shixyo", "cilyo", "cixyo"}},
{"じゃ", new string[7] {"ja", "jya", "zya", "jilya", "jixya", "zilya", "zixya"}},
{"じぃ", new string[10] {"jyi", "zyi", "jili", "jixi", "jilyi", "jixyi", "zili", "zixi", "zilyi", "zixyi"}},
...

同じように「ん」+ひらがな1、2文字、「っ」+ひらがな1、2文字のパターンも全部書いてしまいましょう。

...
{"んあ", new string[2] {"nna", "xna"}},
{"んい", new string[4] {"nni", "xni", "nnyi", "xnyi"}},
{"んう", new string[6] {"nnu", "xnu", "nnwu", "xnwu", "nnwhu", "xnwhu"}},
{"んえ", new string[2] {"nne", "xne"}},
{"んお", new string[2] {"nno", "xno"}},
{"んか", new string[6] {"nka", "nca", "nnka", "nnca", "xnka", "xnca"}},
{"んき", new string[3] {"nki", "nnki", "xnki"}},
{"んく", new string[9] {"nku", "ncu", "nqu", "nnku", "nncu", "nnqu", "xnku", "xncu", "xnqu"}},
{"んけ", new string[3] {"nke", "nnke", "xnke"}},
{"んこ", new string[6] {"nko", "nco", "nnko", "nnco", "xnko", "xnco"}},
{"んさ", new string[3] {"nsa", "nnsa", "xnsa"}},
{"んし", new string[9] {"nsi", "nshi", "nci", "nnsi", "nnshi", "nnci", "xnsi", "xnshi", "xnci"}},
{"んす", new string[3] {"nsu", "nnsu", "xnsu"}},
{"んせ", new string[6] {"nse", "nce", "nnse", "nnce", "xnse", "xnce"}},
{"んそ", new string[3] {"nso", "nnso", "xnso"}},
{"んた", new string[3] {"nta", "nnta", "xnta"}},
{"んち", new string[6] {"nti", "nchi", "nnti", "nnchi", "xnti", "xnchi"}},
{"んつ", new string[6] {"ntu", "ntsu", "nntu", "nntsu", "xntu", "xntsu"}},
{"んて", new string[3] {"nte", "nnte", "xnte"}},
{"んと", new string[3] {"nto", "nnto", "xnto"}},
{"んな", new string[2] {"nnna", "xnna"}},
{"んに", new string[2] {"nnni", "xnni"}},
{"んぬ", new string[2] {"nnnu", "xnnu"}},
{"んね", new string[2] {"nnne", "xnne"}},
{"んの", new string[2] {"nnno", "xnno"}},
...
{"んきゃ", new string[9] {"nkya", "nkilya", "nkixya", "nnkya", "nnkilya", "nnkixya", "xnkya", "xnkilya", "xnkixya"}},
{"んきぃ", new string[15] {"nkyi", "nkili", "nkilyi", "nkixi", "nkixyi", "nnkyi", "nnkili", "nnkilyi", "nnkixi", "nnkixyi", "xnkyi", "xnkili", "xnkilyi", "xnkixi", "xnkixyi"}},
{"んきゅ", new string[9] {"nkyu", "nkilyu", "nkixyu", "nnkyu", "nnkilyu", "nnkixyu", "xnkyu", "xnkilyu", "xnkixyu"}},
{"んきぇ", new string[15] {"nkye", "nkile", "nkilye", "nkixe", "nkixye", "nnkye", "nnkile", "nnkilye", "nnkixe", "nnkixye", "xnkye", "xnkile", "xnkilye", "xnkixe", "xnkixye"}},
{"んきょ", new string[9] {"nkyo", "nkilyo", "nkixyo", "nnkyo", "nnkilyo", "nnkixyo", "xnkyo", "xnkilyo", "xnkixyo"}},
{"んぎゃ", new string[9] {"ngya", "ngilya", "ngixya", "nngya", "nngilya", "nngixya", "xngya", "xngilya", "xngixya"}},
{"んぎぃ", new string[15] {"ngyi", "ngili", "ngilyi", "ngixi", "ngixyi", "nngyi", "nngili", "nngilyi", "nngixi", "nngixyi", "xngyi", "xngili", "xngilyi", "xngixi", "xngixyi"}},
{"んぎゅ", new string[9] {"ngyu", "ngilyu", "ngixyu", "nngyu", "nngilyu", "nngixyu", "xngyu", "xngilyu", "xngixyu"}},
{"んぎぇ", new string[15] {"ngye", "ngile", "ngilye", "ngixe", "ngixye", "nngye", "nngile", "nngilye", "nngixe", "nngixye", "xngye", "xngile", "xngilye", "xngixe", "xngixye"}},
{"んぎょ", new string[9] {"ngyo", "ngilyo", "ngixyo", "nngyo", "nngilyo", "nngixyo", "xngyo", "xngilyo", "xngixyo"}},
...
{"っか", new string[10] {"kka", "cca", "ltuka", "xtuka", "ltsuka", "xtsuka", "ltuca", "xtuca", "ltsuca", "xtsuca"}},
{"っき", new string[5] {"kki", "ltuki", "xtuki", "ltsuki", "xtsuki"}},
{"っく", new string[10] {"kku", "ccu", "ltuku", "xtuku", "ltsuku", "xtsuku", "ltucu", "xtucu", "ltsucu", "xtsucu"}},
{"っけ", new string[5] {"kke", "ltuke", "xtuke", "ltsuke", "xtsuke"}},
{"っこ", new string[10] {"kko", "cco", "ltuko", "xtuko", "ltsuko", "xtsuko", "ltuco", "xtuco", "ltsuco", "xtsuco"}},
{"っさ", new string[5] {"ssa", "ltusa", "xtusa", "ltsusa", "xtsusa"}},
{"っし", new string[15] {"ssi", "cci", "sshi", "ltusi", "xtsusi", "ltsusi", "xtsusi", "ltuci", "xtuci", "ltsuci", "xtsuci", "ltushi", "xtushi", "ltsushi", "xtsushi"}},
{"っす", new string[5] {"ssu", "ltusu", "xtusu", "ltsusu", "xtsusu"}},
{"っせ", new string[10] {"sse", "cce", "ltuse", "xtuse", "ltsuse", "xtsuse", "ltuce", "xtuce", "ltsuce", "xtsuce"}},
{"っそ", new string[5] {"sso", "ltuso", "xtuso", "ltsuso", "xtsuso"}},
{"った", new string[5] {"tta", "ltuta", "xtuta", "ltsuta", "xtsuta"}},
{"っち", new string[10] {"tti", "cchi", "ltuti", "xtuti", "ltsuti", "xtsuti", "ltuchi", "xtuchi", "ltsuchi", "xtsuchi"}},
{"っつ", new string[10] {"ttu", "ttsu", "ltutu", "xtutu", "ltsutu", "xtsutu", "ltutsu", "xtutsu", "ltsutsu", "xtsutsu"}},
{"って", new string[5] {"tte", "ltute", "xtute", "ltsute", "xtsute"}},
{"っと", new string[5] {"tto", "ltuto", "xtuto", "ltsuto", "xtsuto"}},
...
{"っきゃ", new string[19] {"kkya", "kkilya", "kkixya", "ltukya", "ltukilya", "ltukixya", "ltukya", "xtukilya", "xtukixya", "xtukya", "xtukilya",
                                                            "ltsukixya", "ltsukya", "ltsukilya", "ltsukixya", "xtsukixya", "xtsukya", "xtsukilya", "xtsukixya"}},
{"っきぃ", new string[25] {"kkyi", "kkili", "kkilyi", "kkixi", "kkixyi", "ltukyi", "ltukili", "ltukilyi", "ltukixi", "ltukixyi", "xtukyi", "xtukili", "xtukilyi", "xtukixi", "xtukixyi",
                                                        "ltsukyi", "ltsukili", "ltsukilyi", "ltsukixi", "ltsukixyi", "ltsukyi", "xtsukili", "xtsukilyi", "xtsukixi", "xtsukixyi"}},
{"っきゅ", new string[15] {"kkyu", "kkilyu", "kkixyu", "ltukyu", "ltukilyu", "ltukixyu", "xtukyu", "xtukilyu", "xtukixyu", "ltsukyu", "ltsukilyu", "ltsukixyu", "xtsukyu", "xtsukilyu", "xtsukixyu"}},
{"っきょ", new string[15] {"kkyo", "kkilyo", "kkixyo", "ltukyo", "xtukyo", "ltsukyo", "xtsukyo", "ltukilyo", "xtukilyo", "ltsukilyo", "xtsukilyo", "ltukixyo", "xtukixyo", "ltsukixyo", "xtsukixyo"}},
{"っぎゃ", new string[15] {"ggya", "ggilya", "ggixya", "ltugya", "xtugya", "ltsugya", "xtsugya", "ltugilya", "xtugilya", "ltsugilya", "xtsugilya", "ltugixya", "xtugixya", "ltsugixya", "xtsugixya"}},
{"っぎゅ", new string[15] {"ggyu", "ggilyu", "ggixyu", "ltugyu", "xtugyu", "ltsugyu", "xtsugyu", "ltugilyu", "xtugilyu", "ltsugilyu", "xtsugilyu", "ltugixyu", "xtugixyu", "ltsugixyu", "xtsugixyu"}},
{"っぎょ", new string[15] {"ggyo", "ggilyo", "ggixyo", "ltugyo", "xtugyo", "ltsugyo", "xtsugyo", "ltugilyo", "xtugilyo", "ltsugilyo", "xtsugilyo", "ltugixyo", "xtugixyo", "ltsugixyo", "xtsugixyo"}},
{"っしゃ", new string[40] {"ssya", "ssha", "ssilya", "ssixya", "sshilya", "sshixya", "ccilya", "ccixya", "ltusya", "xtusya", "ltsusya", "xtsusya",
                                                            "ltusha", "xtusha", "ltsusha", "xtsusha", "ltusilya", "xtusilya", "ltsusilya", "xtsusilya", "ltusixya", "xtusixya", "ltsusixya", "xtsusixya",
                                                            "ltushilya", "xtushilya", "ltsushilya", "xtsushilya", "ltushixya", "xtushixya", "ltsushixya", "xtsushixya",
                                                            "ltucilya", "xtucilya", "ltsucilya", "xtsucilya", "ltucixya", "xtucixya", "ltsucixya", "xtsucixya"}},
{"っしぃ", new string[65] {"ssyi", "ssili", "ssixi", "ssilyi", "ssixyi", "sshili", "sshixi", "sshilyi", "sshixyi", "ccili", "ccixi", "ccilyi", "ccixyi",
                                                            "ltusyi", "xtusyi", "ltsusyi", "xtsusyi",
                                                            "ltusili", "xtusili", "ltsusili", "xtsusili",
                                                            "ltusixi", "xtusixi", "ltsusixi", "xtsusixi",
                                                            "ltusilyi", "xtusilyi", "ltsusilyi", "xtsusilyi",
                                                            "ltusixyi", "xtusixyi", "ltsusixyi", "xtsusixyi",
                                                            "ltushili", "xtushili", "ltsushili", "xtsushili",
                                                            "ltushixi", "xtushixi", "ltsushixi", "xtsushixi",
                                                            "ltushilyi", "xtushilyi", "ltsushilyi", "xtsushilyi",
                                                            "ltushixyi", "xtushixyi", "ltsushixyi", "xtsushixyi",
                                                            "ltucili", "xtucili", "ltsucili", "xtsucili",
                                                            "ltucixi", "xtucixi", "ltsucixi", "xtsucixi",
                                                            "ltucilyi", "xtucilyi", "ltsucilyi", "xtsucilyi",
                                                            "ltucixyi", "xtucixyi", "ltsucixyi", "xtsucixyi"}},
{"っしゅ", new string[40] {"ssyu", "sshu", "ssilyu", "ssixyu", "sshilyu", "sshixyu", "ccilyu", "ccixyu", "ltusyu", "xtusyu", "ltsusyu", "xtsusyu",
                                                            "ltushu", "xtushu", "ltsushu", "xtsushu",
                                                            "ltusilyu", "xtusilyu", "ltsusilyu", "xtsusilyu",
                                                            "ltusixyu", "xtusixyu", "ltsusixyu", "xtsusixyu",
                                                            "ltushilyu", "xtushilyu", "ltsushilyu", "xtsushilyu",
                                                            "ltushixyu", "xtushixyu", "ltsushixyu", "xtsushixyu",
                                                            "ltucilyu", "xtucilyu", "ltsucilyu", "xtsucilyu",
                                                            "ltucixyu", "xtucixyu", "ltsucixyu", "xtsucixyu"}},
{"っしぇ", new string[70] {"ssye", "sshe", "ssile", "ssilye", "ssixe", "ssixye", "sshile", "sshilye", "sshixe", "sshixye", "ccile", "ccilye", "ccixe", "ccixye", "ltusye", "xtusye", "ltsusye", "xtsusye",
                                                            "ltushe", "xtushe", "ltsushe", "xtsushe",
                                                            "ltusile", "xtusile", "ltsusile", "xtsusile",
                                                            "ltusilye", "xtusilye", "ltsusilye", "xtsusilye",
                                                            "ltusixe", "xtusixe", "ltsusixe", "xtsusixe",
                                                            "ltusixye", "xtusixye", "ltsusixye", "xtsusixye",
                                                            "ltushile", "xtushile", "ltsushile", "xtsushile",
                                                            "ltushilye", "xtushilye", "ltsushilye", "xtsushilye",
                                                            "ltushixe", "xtushixe", "ltsushixe", "xtsushixe",
                                                            "ltushixye", "xtushixye", "ltsushixye", "xtsushixye",
                                                            "ltucile", "xtucile", "ltsucile", "xtsucile",
                                                            "ltucilye", "xtucilye", "ltsucilye", "xtsucilye",
                                                            "ltucixe", "xtucixe", "ltsucixe", "xtsucixe",
                                                            "ltucixye", "xtucixye", "ltsucixye", "xtsucixye"}},
{"っしょ", new string[40] {"ssyo", "ssho", "ssilyo", "ssixyo", "sshilyo", "sshixyo", "ccilyo", "ccixyo", "ltusyo", "xtusyo", "ltsusyo", "xtsusyo",
                                                            "ltusho", "xtusho", "ltsusho", "xtsusho",
                                                            "ltusilyo", "xtusilyo", "ltsusilyo", "xtsusilyo",
                                                            "ltusixyo", "xtusixyo", "ltsusixyo", "xtsusixyo",
                                                            "ltushilyo", "xtushilyo", "ltsushilyo", "xtsushilyo",
                                                            "ltushixyo", "xtushixyo", "ltsushixyo", "xtsushixyo",
                                                            "ltucilyo", "xtucilyo", "ltsucilyo", "xtsucilyo",
                                                            "ltucixyo", "xtucixyo", "ltsucixyo", "xtsucixyo"}},

このように最初から全てのパターンを列挙してしまい、3文字で検索、2文字で検索、1文字で検索という風に文字数が多い方からマッチングするようにしてしまえば例外コードを書く必要がなくなります。

例えば「くれっしぇんど」は く/れ/っしぇ/んど 、「しゃかんきょり」は しゃ/か/んきょ/り と区切られます。

コードは以下のようになります。

  /// <summary>
  /// ひらがな文をパースして、判定を作成
  /// <param name="sentenceHiragana">パースされるひらがな文字列</param>
  /// <returns>判定器</returns>
  /// </summary>
  private static List<List<string>> ConstructTypeSentence(string sentenceHiragana)
  {
    int i = 0;
    string uni = "";
    string bi = "";
    string tri = "";
    var judge = new List<List<string>>();
    while (i < sentenceHiragana.Length)
    {
      var validTypeList = new List<string>();
      uni = sentenceHiragana[i].ToString();
      bi = (i + 1 < sentenceHiragana.Length) ? sentenceHiragana.Substring(i, 2) : "";
      tri = (i + 2 < sentenceHiragana.Length) ? sentenceHiragana.Substring(i, 3) : "";
      // ひらがな3文字でマッチ
      if (romanTypeMap.ContainsKey(tri))
      {
        validTypeList = romanTypeMap[tri].ToList();
        i += 3;
      }
      // ひらがな2文字でマッチ
      else if (romanTypeMap.ContainsKey(bi))
      {
        validTypeList = romanTypeMap[bi].ToList();
        i += 2;
      }
      // ひらがな1文字でマッチ
      else
      {
        validTypeList = romanTypeMap[uni].ToList();
        // 文末「ん」の処理
        if (uni.Equals("ん") && sentenceHiragana.Length - 1 == i)
        {
          validTypeList.Remove("n");
        }
        i++;
      }
      judge.Add(validTypeList);
    }
    return judge;
  }

この方法の利点は先ほどから述べているようにローマ字入力での例外を最初からマッピング部分に入れられるのでひらがな文をパースする部分のコードがすっきりすることです。

大変な部分としては単純にマッピングを全て入れるのが結構データ量が多くて大変であるということです。

5: 展望

せっかくここまで実装したのでもうちょっといろいろ機能つけてもいいかなって思っています。例えば、

  • 結果ツイート機能
  • ユーザ認証システム
  • イロレーティングなど実用で用いられているレーティングシステムの導入(特に上級者にウケそう)
  • ランキング機能
  • ゴースト機能
  • 自然言語処理の技術を用いて Twitter などの文章から例文自動生成
  • 初心者向けのモード(ホームポジションから入るみたいなモード)

とかあってもいいと思ってはいます。頑張って作っていきたいと思います。

6: 実装した感想

ローマ字入力のタイピングゲームは、アルゴリズムとデータ構造、自然言語処理の基礎知識が必要で、さらにそこそこ実装も重ためなので、ちゃんと動くようになるまでにかなり時間がかかりました。タイピングゲームで遊んでいるので、「ん」や「っ」の例外パターンなどは熟知していたため、その辺の考慮はよくできていましたが、それにしてもバグらせずに実装するのは難しかったです。

かな入力や英文タイピングだと文章をパースする必要もなさそうですし、判定部分もチェックが楽そうなのでこちらの方が実装ははるかに易しいなのかなとは思います。

機械学習のモデル構築みたいに、ライブラリのドキュメントを漁って、サンプルコードにしたがって実装してパラメータ調整したらとりあえず実装はできてあとはパラメータ調整...みたいな実装もしたことがあるのですが、それよりずっと理論的に考えることがいっぱいありました。

大変でしたが、実装していくうちにちゃんと想定した通りに動いていくのが見ていけたので実装していてとても楽しかったです。


長い記事でしたが、お付き合いありがとうございました!!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
18
Help us understand the problem. What are the problem?