LoginSignup
33
27

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

Last updated at Posted at 2019-12-16

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

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

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

(Note: この記事はアドベントカレンダーで発表されて以降も何度か手を加えています。)

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

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

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

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

1: 実装する内容

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

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

Unity のコンポーネントの扱いなど、Unity に関連する話は今回はしません。

タイピングゲームのおおまかな流れは以下のようになっています。

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

2: 実際の実装について

2-1: UI 部分を作成

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

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

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

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 寄りの話になっていますが、他の言語であれば OnKeyDown といったキーボードの入力イベントを扱う関数が用意されているかと思いますので、そちらを使っていただければ問題ないです。

Unity でタイピングゲームを実装する場合、 Update() でキーボードの入力を取ってこようとするコードがいくつか見られますが、これでは、キーボードの入力を検知する際にフレームレートに依存しており、キーの入力抜け落ちが発生する可能性があります。タイピングゲームとしては致命的なバグの要因となります。

正しい実装としては以下の記事が参考になります。

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

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

Update() の代わりに OnGUI() を用いようというものです。これはキーが押されたりキーが離されたりすると呼び出されるのでタイピングゲームには適合します(※ `OnGUI() 自体古いので、何かより良いものが実装されたらそちらを使ってください)。

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

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

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

正誤判定

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

これは 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: ひらがな文のパースロジックを簡略化する

(2021/6/10追記、2023/05/04 変更)

「しゃ」を "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] {"nn", "xn"} とし、「ん」+ア行、「ん」+ナ行、「ん」+ヤ行はデータに含めないようにします。

...
{"んか", 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[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文字で検索という風に文字数が多い方からマッチングするようにしてしまえば例外的な処理をコードで行う必要がなくなります。

「ん」+ア行、「ん」+ナ行、「ん」+ヤ行については、「ん」を new string[2] {"nn", "xn"} と n 1打鍵のものを許容しないようにしておいたことと、2文字以上のパターンに「んあ」~「んお」「んな」~「んぬ」「んや」~「んよ」「んにゃ」~「んにょ」のパターンを含めなかったことにより、これらのパターンは「ん/あ」「ん/な」などのように1文字ごとの区切りでマッチングしてくれ、かつ「ん」がこの時だけ "nn" もしくは "xn" で打つように出力してくれます。

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

コードは以下のようになります。区切った文字列も判定オートマトンも基本的に Readonly であるので、C# であれば ImmutableList に入れてしまうのが安全でしょう。

    /// <summary>
    /// ひらがな文をパースして、判定を作成
    /// <param name="sentenceHiragana">パースされるひらがな文字列</param>
    /// <returns>parsedSentence は区切った文字列、judgeAutomaton は判定オートマトン</returns>
    /// </summary>
    public static (ImmutableList<string> parsedSentence, ImmutableList<ImmutableList<string>> judgeAutomaton) ConstructTypeSentence(string sentenceHiragana)
    {
        var idx = 0;
        var judge = new List<ImmutableList<string>>();
        var parsedStr = new List<string>();

        while (idx < sentenceHiragana.Length)
        {
            List<string> validTypeList;

            var uni = sentenceHiragana[idx].ToString();
            var bi = (idx + 1 < sentenceHiragana.Length) ? sentenceHiragana.Substring(idx, 2) : "";
            var tri = (idx + 2 < sentenceHiragana.Length) ? sentenceHiragana.Substring(idx, 3) : "";

            if (_mappingDictionary.ContainsKey(tri))
            {
                validTypeList = _mappingDictionary[tri].ToList();
                idx += 3;
                parsedStr.Add(tri);
            }
            else if (_mappingDictionary.ContainsKey(bi))
            {
                validTypeList = _mappingDictionary[bi].ToList();
                idx += 2;
                parsedStr.Add(bi);
            }
            else if (_mappingDictionary.ContainsKey(uni))
            {
                validTypeList = _mappingDictionary[uni].ToList();
                idx++;
                parsedStr.Add(uni);
            }
            else
            {
                throw new InvalidDataException($"Error: マッピングデータに入っていない文字列やパターンを検知しました / uni-gram => {uni}, bi-gram => {bi}, tri-gram => {tri}");
            }

            judge.Add(validTypeList.ToImmutableList());
        }

        return (parsedStr.ToImmutableList(), judge.ToImmutableList());
    }

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

欠点は考えられるパターンを大量に列挙しないといけないのでデータを作るのが大変であることです。

ということで、ひらがなからローマ字の入力パターンを列挙したデータとそれをチェックできるものをつくりました!
GitHub に .NET で作ったものを置いていますが、JSON データだけ抜き出してくれば別言語でも移植できると思います。

4: できたもの

FoxTyping

もう更新していませんが、基盤はこの記事で作成したものを利用しています。

5: 注意

5-1: 打ったところまで文字の色を変える実装に制限がある

この実装では、打ったところまでひらがな文の文字の色を変えたい、といった要件に対応できません。

例えば、「はんにゃしんぎょう」という例文があったとして、hannni まで打ったとしましょう。
本記事の実装で出力されるオートマトンは「は / ん / にゃ / し / んぎょ / う」という区切りで出力されるため、hannni とキーボードで入力したとしても、「はんにゃしんぎょう」とはならず、「はんにゃしんぎょう」となってしまうでしょう。

これに対応する場合は、オートマトンでの判定をさらに細かく区切る必要がありますが、本記事の趣旨からは外れるので自身で頑張って実装してみてください。

5-2: 文末の「ん」

本実装では、文末の「ん」は "n" を許容しません。
これは意図的で、タイピングゲームに限らず、ローマ字入力の際に n を1打鍵だけ打った場合は「ん」とは出ず、次の打鍵をみて判断されるからです。

5-3: 「ゔ」の取り扱い

環境によっては「ゔ」など、環境依存文字の取り扱いに注意が必要です。
自分の手元で試した限りですが、「va」と打った時の挙動は以下です。

  • Windows 11 : ヴぁ
  • MacOS : ゔぁ

私が作成したライブラリでは「ゔ」でも「ヴ」でもパースできるようにしてあります。

6: 実装した感想

ローマ字入力のタイピングゲームは、アルゴリズムとデータ構造、自然言語処理の基礎知識が必要で、さらにそこそこ実装も重ためなので、ちゃんと動くようになるまでにかなり時間がかかりました。

「ん」や「っ」の例外パターンなどは熟知していたため、その辺の考慮はよくできていましたが、それにしてもバグらせずに実装するのは難しかったです。

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


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

33
27
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
33
27