こんにちは。
C#を勉強して数年になります。
ふとC#でタイピングゲームを作ってみたいなぁと思ったのですが、
そもそもローマ字入力どうするんだ、という壁にすぐにぶち当たり、
便利なクラスがないものかとGitHubの海を潜ること数ヶ月、
結局C#で実装されたよさげなものが無かったので自作することにしました。
##1.ローマ字入力について
最初は気楽に考えてました。
「しゃ」って「si」とか「sixya」とか色々あるよね、パターン化してリストアップしとけばいいよね、と。
でも「しゃ」という1フレーズをパターン化するのは簡単ですが、
「しゃしゃ」となると、「shasha」もあれば「sixyasya」もあるし「syasha」だったあるので
1フレーズに対するパターン化以外に、次の入力候補のことも考えたデータ構造にしておかないと、
入力確認するときに大変そうです。
さらには、やはり「しゃしゃ」の場合に「sha」とタイプした人には「sya」より「sha」を
入力候補に表示するようなこともしたいです。
そういった欲望を満たせば、昔ハマったハウス・オブ・ザ・○ッドのタイピングゲームだって
作れますね(え?知らない?知らない人は・・・若いな)。
そのためにも、爆速で入力チェックや候補の選定を行うために
事前の解析、データ構造設計が大事そうです。
##2.データ構造
「しゃしゃ」だけでも、「si→xya→si→xya」でもいいし、「si→xya→sha」でもいい。
1文字目の候補に対し、2文字目の候補が存在する。
つまりはこうだ。
1文字目 | 2文字目 | 3文字目 | 4文字目 |
---|---|---|---|
si→xya(2) or sha(3) | xya→si(3) or sha(3) | si→xya(4) | xya→null |
sha→si(3) or sha(3) | sha→null |
→の次が入力候補の文字でカッコの中は何文字目かを示す。
つまり、1文字目が「sha」の次は、3文字目の「si」か「sha」が次の入力候補、となる。
「しゃ」は他に「cixya」などもあるので、実際にはもっと巨大な表となるのだろう。
しかしながら、データの持ち方はこれでよさそうだ。
さぁ、実装だ。
##3.実装
考えたクラスは下記の通り。
class | 概要 |
---|---|
TypeChar | 「し」などの平仮名1文字を管理するクラス |
TypeWord | 「しゃしゃ」などの入力文章1つを管理するクラス |
TypeSentence | タイピングゲームを作りたいので、問題文を管理するクラス |
TypeGame | タイピングゲーム本体。 |
ゲームを作る側からすれば、タイピングゲームエンジンのインスタンスを作成して、
問題文と共に「後ヨロシク」って言いたいよね。
僕は言いたい。
そのためのTypeGameクラスです。
一番頑張ってるのはTypeWord。
このクラスでひらがなをローマ字に変換させます。
##4.ローマ字解析
ひらがなの1文を1文字ずつローマ字へ変換する必要がある。
ローマ字のパターンは有限なので地道に変換するだけだ。
1 「しゃしゃ」を「し」「ゃ」「し」「ゃ」と分割し、ローマ字化。
まずは一番長いものを作る。
作ったものはリスト化する。
リスト化する、というのは、「し」を管理するTypeCharクラス自身が、
次に来る「ゃ」のインスタンスを保持している、ということ。
この時点で「しゃしゃ」は以下のようになる。
1文字目 | 2文字目 | 3文字目 | 4文字目 |
---|---|---|---|
si→xya(2) | xya→si(3) | si→xya(4) | xya→null |
2 「し」「ゃ」のように合体するリンキング文字について新しく文字を作り、
リストに加える。
この時点で「しゃしゃ」は以下のようになる。
1文字目 | 2文字目 | 3文字目 | 4文字目 |
---|---|---|---|
si→xya(2) or sha(3) | xya→si(3) or sha(3) | si→xya(4) | xya→null |
sha→si(3) or sha(3) | sha→null |
3 「ん」の対処
「ん」は続く文字が「や」「な」「あ」行でなければ「n」一個で「ん」とみなせる可能性がありますので、
その場合に応じたリンクを生成します。
4 「っ」の対処
「っ」は続く文字が2つ重なるので、そういうリンクを生成します。
##5.コード抜粋
完成版はGitHubにあります。
一部だけ紹介。
class TypeChar{
/// <summary>
/// 入力を許可する文字コード
/// </summary>
public List<char> InputChars { get; set; } = new List<char>();
/// <summary>
/// 次入力を受け付ける文字コードのインデックス。
/// </summary>
public int NextCharIndex { get; set; } = -1;
/// <summary>
/// 次の入力候補
/// </summary>
public char NextWant { get { return this.InputChars[this.NextCharIndex]; } }
/// <summary>
/// 残りの文字
/// </summary>
public IEnumerable<char> RemainChars {
get
{
if (this.NextCharIndex == -1)
{
for (int i = 0; i < this.InputChars.Count; i++)
{
yield return this.InputChars[i];
}
}
else
{
for (int i = NextCharIndex; i < this.InputChars.Count; i++)
{
yield return this.InputChars[i];
}
}
}
}
/// <summary>
/// 次の文字への参照
/// </summary>
public List<TypeChar> Next { get; set; } = new List<TypeChar>();
///// <summary>
///// 前の文字への参照
///// </summary>
public List<TypeChar> Prev { get; set; } = new List<TypeChar>();
}
TypeCharクラスはこんな感じになりました。
「し」一文字だとしても、入力は「s」と「i」のように2文字以上あるので
それらをリストで記憶し、
さらには次の入力候補位置もこのクラスで管理。
前後のインスタンスとはNext, Prevプロパティでつながっています。
ひらがなをローマ字にする、今回一番頑張ったのが次のTypeWordクラス。
class TypeWord{
/// <summary>
/// 先頭の文字インスタンスを指す絶対的なヘッド
/// 先頭の文字インスタンスにするとList<>で管理せなあかんので唯一のものを用意した。
/// </summary>
public TypeChar HeadChar { get; set; }
/// <summary>
/// 前回入力が確定した文字。
/// 最初はHeadCharと同じ
/// </summary>
public TypeChar NextChar { get; set; }
/// <summary>
/// 全部のTypeChar要素
/// </summary>
protected List<TypeChar> AllChars { get; set; } = new List<TypeChar>();
/// <summary>
/// 「しゃ」などのリンキングワードの設定
/// </summary>
private class Linking
{
private string _text = "";
public string First { get; set; }
public string Second { get; set; }
public string Text {
set
{
this._text = value;
this.First = $"{value[0]}";
this.Second = $"{value[1]}";
}
get
{
return this._text;
}
}
public string[] Inputs;
}
//「し」「ゃ」とかを「しゃ」に連結したパターンを生成する
private List<Linking> linkings = new List<Linking>() {
new Linking(){ Text="いぇ", Inputs=new []{ "ye" } },
new Linking(){ Text="きゃ", Inputs=new []{ "kya" } },
new Linking(){ Text="きゅ", Inputs=new []{ "kyu" } },
new Linking(){ Text="きょ", Inputs=new []{ "kyo" } },
new Linking(){ Text="ぎゃ", Inputs=new []{ "gya" } },
new Linking(){ Text="ぎぃ", Inputs=new []{ "gyi" } },
new Linking(){ Text="ぎゅ", Inputs=new []{ "gyu" } },
new Linking(){ Text="ぎぇ", Inputs=new []{ "gye" } },
new Linking(){ Text="ぎょ", Inputs=new []{ "gyo" } },
new Linking(){ Text="くぁ", Inputs=new []{ "qya", "qwa", "qa", "kwa" } },
:
長過ぎるので割愛
};
/// <summary>
/// 1つの文章をローマ字に変換する
/// </summary>
/// <param name="word">ひらがな表記された文章</param>
private void Parse(string word)
{
this.HeadChar = new TypeChar()
{
IndexOfWord = -1,
IndexOfSentence = -1,
Game = this.Sentence.Game
};
this.AllChars.Clear();
List<TypeChar> currents = new List<TypeChar>
{
this.HeadChar
};
List<TypeChar> lasts = new List<TypeChar>();
//1文字ずつ解析していく
//「しゃ」とかも「し」「ゃ」に分割される
int index = 0;
foreach (var c in word)
{
//1文字で表現できるものを取得
//ローマ字は、文字は同じでも入力方法は複数ある
var simpleChars = SimpleChars(c).ToList();
simpleChars.ForEach(s => s.Game = this.Sentence.Game);
this.AllChars.AddRange(simpleChars);
//前回からリストをつなげる
foreach (var prevNode in currents)
{
foreach (var sc in simpleChars)
{
sc.IndexOfWord = index;
sc.IndexOfSentence = this.IndexOfSentence;
sc.Prev.AddRange(currents);
prevNode.AddNext(sc);
}
}
lasts.Clear();
lasts.AddRange(simpleChars);
index++;
//前回値を今回取得したやつにする
currents.Clear();
currents.AddRange(simpleChars);
}
//インデクサをクリア
currents.Clear();
//最後に追加した要素
currents.AddRange(lasts);
while (true)
{
List<TypeChar> prevs = new List<TypeChar>();
foreach (var at in currents)
{
foreach (var linking in linkings)
{
TypeChar first = at.Text.Equals(linking.First) ? at : null;
if (first == null)
continue;
var second = first.GetNext(linking.Second);
if (second == null)
continue;
foreach (var input in linking.Inputs)
{
var linkingWord = this.AllChars.Find(c =>
{
if (c.IndexOfSentence != this.IndexOfSentence)
return false;
if (c.IndexOfWord != at.IndexOfWord)
return false;
if (!c.Text.Equals(linking.Text))
return false;
if (!c.InputCharString.Equals(input))
return false;
return true;
});
if (linkingWord == null)
{
linkingWord = new TypeChar(linking.Text, input)
{
IndexOfWord = at.IndexOfWord,
IndexOfSentence = this.IndexOfSentence,
Game = this.Sentence.Game,
};
}
at.Prev.ForEach(p => p.AddNext(linkingWord));
linkingWord.AddNext(second.Next);
this.AllChars.Add(linkingWord);
}
}
//「ん」の「n」だけでいいパターンを生成する
if (at.Text.Equals("ん"))
{
if ((at.Next.Count > 0) && !at.NextStartWith("あ,い,う,え,お,な,に,ぬ,ね,の,や,ゆ,よ".Split(new char[] { ',' })))
{
//次が「や」「な」「あ」行でないなら”n”だけでOK
var linkingWord = new TypeChar("ん", "n")
{
IndexOfWord = at.IndexOfWord,
IndexOfSentence = this.IndexOfSentence,
Game = this.Sentence.Game,
};
at.Prev.ForEach(p => p.AddNext(linkingWord));
linkingWord.AddNext(at.Next);
this.AllChars.Add(linkingWord);
}
}
//「っ」のパターンを生成する
if (at.Text.Equals("っ"))
{
if (at.Next.Count != 0)
{
at.Next.ForEach(next =>
{
var dup = next.InputChars[0];
var dupChar = this.PickupByIndex(at.IndexOfWord).Where(p =>
{
if ((p.InputChars[0] == dup) && (p.InputChars.Count == 1))
return true;
return false;
}).ToList();
if (dupChar.Count == 0)
{
var linkingWord = new TypeChar("っ", next.InputChars[0])
{
IndexOfWord = at.IndexOfWord,
IndexOfSentence = this.IndexOfSentence,
Game = this.Sentence.Game,
};
at.Prev.ForEach(p => p.AddNext(linkingWord));
linkingWord.AddNext(at.Next);
this.AllChars.Add(linkingWord);
}
else
{
dupChar.ForEach(d => at.Prev.ForEach(p => p.AddNext(d)));
}
});
}
}
//前の要素(候補)
prevs.AddRange(at.Prev);
}
//前の要素は重複している可能性があるのでダイエット
prevs = prevs.Distinct(new TypeCharEquality()).ToList();
if (prevs[0] == this.HeadChar)
{
//先頭まで戻ってきたら終わり
break;
}
currents.Clear();
currents.AddRange(prevs);
prevs = null;
}
this.NextChar = this.HeadChar;
}
}
Parse()メソッドで平仮名をローマ字に変換しています。
処理の手順は上に記載の通りで、
まず単純に一文字ずつローマ字化。
その後でリンキングワードについて処理し、「ん」と「っ」を処理しています。
「しゃ」は「sha」と「sya」とがあるよ、というのはTypeWordクラス内にハードコーディングしてあります。
数が有限なのでそれでいいんじゃないかな。
コード上、見かけないメソッドがあれば、それは拡張メソッドです。
すべてをここに乗せるのは冗長すぎるかと思い、やめました。
##6.動作検証
コードの説明はおいといて、動作検証。
リンキングワードや「ん」の対応もできてそうです。
うん。できてそう。
[ci」が候補から外れて青くなりましたね。
「shaon」と入力してみた結果、
「ん」を「nn」と入力するための2つ目の「n」や、
「n」一回だけで次の「か」入力になるケースが考慮されているのがわかります。
- 入力候補
次は「ふつふつ」で試してみます。
「ふ」は「hu」や「fu」がありますよね。
真ん中らんの「ローマ字例」には、「hu tu hu tu」と表示されています。
残りのローマ字例として「tu fu tu」と「fu」が優先されたことがわかります。
優先順位付けもできてそうです。
当然、「fu」に対して「hu」と入力しても受け付けられます。
あくまで有力なローマ字例を表示するときに、
ユーザのクセに応じた表示がしたい、というだけです。
この実現は、コード例としては示してませんが、
入力が確定した時点で、「fu」が出てきた回数をインクリメントしているだけです。
使用頻度、使用回数を覚えておき、その頻度で優先順位付けしているだけです。
##7.まとめ
ローマ字って結構大変でしたが、技術的にテクニカルなことは何もしてません。
ただただ最初に想定したデータ構造になるよう処理を組んだだけです。
ソフトウェア設計、やはり大事です。
今回のソースコードは下記にあります。
ご自由に改変して使ってください。
https://github.com/kinumellowlife/TypingGame
ゲームとしても動作済みです。
タイマーまわしてるだけなので割愛。
スコアは、タイピングゲーム界では標準(?)の
「一定時間あたりの入力数 × 正解率」の3乗となっています。
##8.最後に
文章だらけの記事にお付き合いくださり、ありがとうございました。
コードは、メモリ効率とかあまり考えてません。
処理速度をあげるためのノウハウもあまりないので
ここ、もっとこうしたら、とかあれば教えてください。
とりあえず、作ってて楽しかったです!
普段何気にタイプしているローマ字も、変換する側の立場に立って見ると結構面倒なことしてるんだなぁと
感慨深かったです。