プログラマのプログラマによるプログラマのためのタイピング練習アプリ

# 作ろうと思ったきっかけ
読み飛ばしOK

世の中にはたくさんのタイピング練習アプリがあります．例えば[マイタイピング](https://typing.twi1.me/)や[寿司打](http://typingx0.net/sushida/)，[イータイピング](https://www.e-typing.ne.jp/)などが有名です．
しかし，これらは日本語のタイピング練習です．また英語のタイピング練習も，プログラミングに直接役に立つわけではないです．
そこで「プログラミングしてるとよく出てくるよね」なタイピングを集め，それを練習できるアプリを作ってみることにしました．
`console.log()`や`for (int i = 0; i < num; i++)`など，慣れている人は一瞬で書いてしまうのではないでしょうか．そういうやつです．

# 全体の構成
様々なゲームモードを用意するため，現在の状態を保存するグローバル変数を用意し，メインプログラムからそれを参照し適宜適切なウィンドウを表示させる形にします．
また，様々な言語とゲームモード(と言っても「関数」などざっくりした問題のくくり)を用意します．

## questions.json
問題を保存するファイルです．
新しい問題を追加したいときはこのファイルをいじるだけでプログラム自体に変更を加える必要はありません．
今の状態だと言語がPythonとCのふたつあり，Cには問題が用意されていません．Pythonには「import文」というモードが用意されており，よく使うimport文をいくつか入れています．

```json
{
    "questions":
    {
        "Python":
        {
            "import文":
            [
                "import os",
                "import re",
                "import sys",
                "from pathlib import Path",
                "import copy",

                "import numpy as np",
                "import matplotlib.pyplot as plt",
                "import pandas as pd",

                "import tensorflow as tf",
                "import keras",
                "import sklearn",

                "from flask import Flask",
                "import django",

                "import PySimpleGUI as sg",
                "import tkinter as tk",

                "import PyPDF2"
            ]
        },
        "C":
        {

        }
    }
}
```

## GlobalVariables.cs
状態を保存するためのグローバル変数を保持します．
また，タイピングの問題をjsonファイルから読み出すメソッドも用意します．

```cs
using System;
using System.Collections.Generic;

using System.IO;
using Newtonsoft.Json;

namespace TypingApp
{
    public enum States
    {
        TitleScreen,
        SelectLanguage,
        SelectMode,
        Game,
        Exit
    }

    public class Question
    {
        public Dictionary<string, Dictionary<string, string[]>> questions
        {
            get; set;
        }
    }

    public class GV
    {
        public static States state = States.TitleScreen;
        public static string language;
        public static string mode;

        public static Question q;
        public static int languagesNum = -1;
        public static int modesNum = -1;

        public static void setup()
        {
            GV.q = null;

            try
            {
                using (StreamReader file = File.OpenText("question.json"))
                {
                    JsonSerializer s = new JsonSerializer();

                    GV.q = (Question)s.Deserialize(file, typeof(Question));
                }
            }
            catch
            {
                Environment.Exit(0);
            }

            GV.languagesNum = GV.q.questions.Count;
            int maxModeNum = -1;
            foreach (var value in GV.q.questions.Values)
                if (value.Count > maxModeNum)
                    maxModeNum = value.Count;
            GV.modesNum = maxModeNum;
        }
    }
}
```

## TitleScreen.cs
タイトル画面を提供します．
スタートボタンと終了ボタンのみ用意しています．
スタートボタンを押すと練習する言語を選択する画面へ移動し，終了ボタンを押すとプログラムが終了します．

私が昔参考にしていたサイトではボタンやラベルをクラス変数にしていたのでなんとなくマネしましたが，今考えると正直その必要はない気がしますね．

```cs
using System;
using System.Drawing;
using System.Windows.Forms;

namespace TypingApp
{
    public class TitleScreen : Form
    {
        private Button start, end;
        private Label title;

        public TitleScreen()
        {
            this.Text = "title";
            this.Width = 750;
            this.Height = 400;

            this.title = new Label();
            // this.title.BorderStyle = BorderStyle.Fixed3D;
            this.title.Font = new Font(this.title.Font.FontFamily, 50);
            this.title.Text = "typing app";
            this.title.Location = new Point(225, 80);
            this.title.Size = new Size(400, 100);
            this.Controls.Add(this.title);

            this.start = new Button();
            this.start.Location = new Point(100, 200);
            this.start.Size = new Size(200, 100);
            this.start.Text = "start";
            this.start.Click += new EventHandler(this.Start);
            this.Controls.Add(this.start);

            this.end = new Button();
            this.end.Location = new Point(450, 200);
            this.end.Size = new Size(200, 100);
            this.end.Text = "end";
            this.end.Click += new EventHandler(this.End);
            this.Controls.Add(this.end);
        }

        private void Start(object sender, EventArgs e)
        {
            GV.state = States.SelectLanguage;
            this.Close();
        }

        private void End(object sender, EventArgs e)
        {
            GV.state = States.Exit;
            this.Close();
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/cccba664-0bd9-0a5f-cf5e-144103b41a60.png)

## SelectLanguage.cs
練習する言語を選択する画面です．
jsonファイルのkeyを見て言語選択のボタンを作ります．
ウィンドウの縦の長さはボタンの数に応じて変更します．

```cs
using System;
using System.Drawing;
using System.Windows.Forms;

namespace TypingApp
{
    public class SelectLanguage : Form
    {
        private Label text;
        private Button button;

        // private string[] languages = GV.q.questions.Keys;
        private string[] languages = new string[GV.languagesNum];

        public SelectLanguage()
        {
            GV.q.questions.Keys.CopyTo(this.languages, 0);

            this.Text = "select language";
            this.Width = 750;
            this.Height = 300 + 100*this.languages.Length;

            this.text = new Label();
            this.text.Font = new Font(this.text.Font.FontFamily, 25);
            this.text.Text = "select language";
            this.text.Location = new Point(250, 100);
            this.text.Size = new Size(550, 100);
            this.Controls.Add(this.text);

            for (int i = 0; i < this.languages.Length; i++)
            {
                int num = i;
                this.button = new Button();
                this.button.Location = new Point(200, 200 + 100*i);
                this.button.Size = new Size(350, 100);
                this.button.Text = this.languages[i];
                this.button.Click += (sender, e) =>
                {
                    GV.language = this.languages[num];
                    GV.state = States.SelectMode;
                    this.Close();
                };
                this.Controls.Add(this.button);
            }
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/8fe7fa0c-0e4e-6d71-d5a4-6f5362c66e04.png)

## SelectMode.cs
練習するモードを選択する画面です．
こちらもjsonファイルの選択した言語のkeyを見ることでモード選択用のボタンを作成しています．また，ウィンドウの縦の長さはボタンの数に応じて変更します．

```cs
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Collections.Generic;

namespace TypingApp
{
    public class SelectMode : Form
    {
        private Label text;
        private Button button;

        private string[] modes = new string[GV.modesNum];

        public SelectMode()
        {
            GV.q.questions[GV.language].Keys.CopyTo(this.modes, 0);

            this.Text = "select mode";
            this.Width = 750;
            this.Height = 300 + 100*this.modes.Length;

            this.text = new Label();
            this.text.Font = new Font(this.text.Font.FontFamily, 25);
            this.text.Text = "select mode";
            this.text.Location = new Point(250, 100);
            this.text.Size = new Size(550, 100);
            this.Controls.Add(this.text);

            for (int i = 0; i < this.modes.Length; i++)
            {
                int num = i;
                this.button = new Button();
                this.button.Location = new Point(200, 200 + 100*i);
                this.button.Size = new Size(350, 100);
                this.button.Text = this.modes[i];
                this.button.Click += (sender, e) =>
                {
                    GV.mode = this.modes[num];
                    GV.state = States.Game;
                    this.Close();
                };
                this.Controls.Add(this.button);
            }
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/1a37392a-b946-3350-844b-a418c74f8e97.png)

## Game.cs
ゲーム画面です．
初期状態では「start with space key」の文字だけが表示されており，スペースキーを押すことでゲームがスタートします．
画面上側に問題文が映し出され，その通りに打ち込むことで画面下側に書き込めます．設定では10問解くことでゲームが終了し，スコアが表示されます．
スコアは以下の式で計算しています．「正答率[%]の二乗割るプレイ時間[s]」

```math
\left(\frac{(総タイプ数-ミスタイプ数)\times 100}{総タイプ数}\right)^2\div プレイ時間
```

```cs
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Input;

namespace TypingApp
{
    public class Game : Form
    {
        private Label qLabel, aLabel;

        private int allTypeNum = 0, missTypeNum = 0;
        private bool started = false;
        private DateTime startTime;

        private readonly int QUESTION_NUM = 10;
        private int questionNowNum = -1;
        private Random rand = new Random();
        private string[] questions = null;
        private int charNum = 0;

        public Game()
        {
            this.Text = "game";
            this.Width = 750;
            this.Height = 750;

            this.qLabel = new Label();
            this.qLabel.Font = new Font(this.qLabel.Font.FontFamily, 25);
            this.qLabel.Text = "start with space key";
            this.qLabel.Location = new Point(100, 200);
            this.qLabel.Size = new Size(550, 100);
            this.Controls.Add(this.qLabel);

            this.aLabel = new Label();
            this.aLabel.Font = new Font(this.aLabel.Font.FontFamily, 25);
            this.aLabel.Text = "";
            this.aLabel.Location = new Point(100, 400);
            this.aLabel.Size = new Size(550, 100);
            this.Controls.Add(this.aLabel);

            this.KeyUp += new KeyEventHandler(this.KeyEvent);
        }

        public string[] getQuestions()
        {
            string[] q = GV.q.questions[GV.language][GV.mode];
            return q.OrderBy(i => this.rand.Next()).ToArray();
        }

        private void updateQuestion()
        {
            if (!this.started)
            {
                this.questions = this.getQuestions();
                this.started = true;
            }
            else
            {
                if (this.QUESTION_NUM-1 == this.questionNowNum)
                {
                    this.gameEnd();
                    return;
                }
            }

            this.questionNowNum++;
            this.qLabel.Text = this.questions[this.questionNowNum];
            this.charNum = 0;
            this.aLabel.Text = "";
        }

        private void KeyEvent(object sender, KeyEventArgs e)
        {
            if (!this.started)
            {
                if (e.KeyCode == Keys.Space)
                {
                    this.startTime = DateTime.Now;
                    this.updateQuestion();
                }
            }
            else
            {
                char nowC = this.questions[this.questionNowNum][this.charNum];
                if (
                    (char.IsLower(nowC) && char.ToLower(e.KeyCode.ToString()[0]) == nowC && Control.ModifierKeys != Keys.Shift)
                    || (e.KeyCode == Keys.Space && nowC == ' ')
                    || (Control.ModifierKeys == Keys.Shift && char.IsUpper(nowC) && e.KeyCode.ToString()[0] == nowC)
                    || (e.KeyValue == 190 && nowC == '.') // 190は'.'の文字コード
                    || this.numIsCorrect(nowC, e))
                {
                    this.charNum++;
                    this.aLabel.Text += nowC;
                    if (this.charNum == this.questions[this.questionNowNum].Length)
                    {
                        this.updateQuestion();
                    }
                    this.BackColor = Color.Empty;
                }
                else if (e.KeyValue == 16) // shift key
                {
                    ;
                }
                else
                {
                    this.BackColor = Color.DeepPink;
                    this.missTypeNum++;
                }
                this.allTypeNum++;
            }
        }

        private bool numIsCorrect(char nowC, KeyEventArgs e)
        {
            if (e.KeyValue >= 48 && e.KeyValue <= 57)
            {
                char typed = (e.KeyValue - 48).ToString()[0];
                if (nowC == typed) return true;
            }
            if (e.KeyValue >= 96 && e.KeyValue <= 105)
            {
                char typed = (e.KeyValue - 96).ToString()[0];
                if (nowC == typed) return true;
            }
            return false;
        }

        private void gameEnd()
        {
            DateTime endTime = DateTime.Now;
            TimeSpan playTime = endTime - this.startTime;

            int score = (int)(Math.Pow(
                (this.allTypeNum - this.missTypeNum) * 100 / this.allTypeNum,
                2
            ) / playTime.TotalSeconds);

            this.qLabel.Text = string.Format("your score: {0}", score);
            this.aLabel.Text = "";
        }
    }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/a7a8f2ed-04bc-48d2-1178-926878963721.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/4b58db70-b65f-072b-ea38-b0e942fd9957.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1925706/973af032-1f21-c27e-e38d-b411a7ec7a9d.png)

## Main.cs
上で作成したクラスたちを実際に使っていくプログラムです．
流れが一方向なのでMain.csの中身をこのようにする必要はなかったのですが，例によってのちのち拡張したくなるかもしれないことを考えこのような設計にしています．

```cs
using System;
using System.Windows.Forms;

using TypingApp;

namespace Main
{
    class MainClass
    {
        public static void Main()
        {
            GV.setup();

            States previousState;
            while (GV.state != States.Exit)
            {
                previousState = GV.state;

                switch (GV.state)
                {
                case States.TitleScreen:
                    Application.Run(new TitleScreen());
                    break;
                case States.SelectLanguage:
                    Application.Run(new SelectLanguage());
                    break;
                case States.SelectMode:
                    Application.Run(new SelectMode());
                    break;
                case States.Game:
                    Application.Run(new Game());
                    break;
                default:
                    break;
                }

                if (GV.state == previousState)
                    break;
            }
        }
    }
}
```

# まとめ
慣れないC#に苦戦しました．
Javaでもよかったんじゃないでしょうか．

# 問題点
このプログラムには問題点が多々あります．
まず，複数のグローバル変数を参照していること．確かにこれはそこまで大規模なプログラムではないですが，LaTeXのエディタを作った時も自分は似たようなことをしていました．悪癖を直すという意味でも早急に改善しなければなりません．

次に，戻るボタンがないことです．間違えて違う言語を選んだ時などは，一度バツボタンを押すなどして最初からやり直さなければなりません．これは非常に面倒です．

そして，友人に遊んでもらったときに言われたのは「スコアが分かりにくい」です．「一秒に何回，正確にタイピングできたか」などの分かりやすい指標が欲しいと言われました．

これらの問題点を改善したアプリを作ろうと思います．