4
5

More than 1 year has passed since last update.

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

Posted at

はじめに

以前,プログラマのプログラマによるプログラマのためのタイピング練習アプリにてタイピングアプリを作りました.
が,お世辞にもいい出来ではなく以下のような問題が残りました.

  • グローバル変数に頼りすぎ
  • 戻れない
  • スコアが分かりづらい

今回はそれらを修正したプログラムを作成します.
前回とはガラッと変わってPython使います.

フォルダ構成

Flaskを使うので以下のようになってます.

.
├── static
│   ├── base.css
│   ├── game.js
│   ├── questions.js
│   ├── script.js
│   ├── select_language.css
│   └── title_screen.css
└── templates
│   ├── base.html
│   ├── game.html
│   ├── select_language.html
│   ├── select_mode.html
│   ├── test.html
│   └── title_screen.html
└── app.py

Python

何から説明するか迷いましたが,いつもの流れでは分かりづらいと思ったのでまず全体から大雑把に解説することにします.

トップページはただのタイトル表示画面です.アクセスしたらまず表示されるのがこれです.
その後言語選択画面へ移動します.言語を選択したら,GETメソッドでその言語を指定しモード選択画面へ移動します.
モードを選択したら,言語とモードをGETメソッドで指定した状態で実際のゲーム画面へ移ります.
Pythonプログラムはけっこうシンプルです.

なお,言語選択とモード選択はほぼ同じような画面になるので同じCSSファイルを参照します.

from flask import Flask, render_template, request

app = Flask(__name__)

@app.route("/")
def index():
    return render_template(
        "title_screen.html",
        css_file_name="title_screen.css"
    )

@app.route("/select_language/")
def select_language():
    return render_template(
        "select_language.html",
        css_file_name="select_language.css"
    )

@app.route("/select_mode", methods=["GET"])
def select_mode():
    if (lang := request.args.get("lang", None)) is None:
        return "error"
    return render_template(
        "select_mode.html",
        css_file_name="select_language.css",
        lang=lang
    )

@app.route("/game", methods=["GET"])
def game():
    if (lang := request.args.get("lang", None)) is None:
        return "error"
    if (mode := request.args.get("mode", None)) is None:
        return "error"
    return render_template(
        "game.html",
        css_file_name="select_language.css",
        lang=lang,
        mode=mode
    )

app.run(debug=True)

HTML

HTMLファイルはボタンとドロップダウンにbootstrapを導入しています.

base.html

親ファイルです.
navから移動することで直接ゲーム画面へ移動することもできます.
言語やモードは後で増えることもあるので,その部分はJavaScriptに任せます.

<!--
  - @param css_file_name Name of css file for child's html file.
-->

<!DOCTYPE html>

<html>
    <head>
        <title>typing app</title>

        <link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
        <link rel="stylesheet" href="{{ url_for('static', filename=css_file_name) }}">
        <script type="text/javascript" src="{{ url_for('static', filename='questions.js') }}"></script>
        <script type="text/javascript" src="{{ url_for('static', filename='script.js') }}"></script>
        <script type="text/javascript" src="{{ url_for('static', filename='game.js') }}"></script>

        <!-- bootstrap -->
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
        integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    </head>

    <body>
        <nav class="navbar navbar-expand-sm navbar-dark bg-dark">
            <a class="btn btn-dark" href="/">typing app</a>
            <script>
                navDropdown();
            </script>
        </nav>

        {% block content %}
        {% endblock %}

        <!-- jQuery -->
        <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"
            integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj"
            crossorigin="anonymous"></script>
        <!-- popper -->
        <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"
            integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN"
            crossorigin="anonymous"></script>
        <!-- Bootstrap -->
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"
            integrity="sha384-B4gt1jrGC7Jh4AgTPSdUtOBvfO8shuf57BaghqFfPlYxofvL8/KUEfYiJOMMV+rV"
            crossorigin="anonymous"></script>
    </body>
</html>

title_screen.html

タイトル画面.
アプリ名とスタートボタンを用意しています.

<!--
  - @param css_file_name Name of css file for child's html file.
-->

{% extends "base.html" %}

{% block content %}
<div class="screen">
    <h2 class="title">typing app</h2>
    <div class="button-wrapper">
        <input type="button" value="start" class="btn btn-primary btn-lg" onclick="location.href='/select_language/'">
        <input type="button" value="end" class="btn btn-light btn-lg">
    </div><!-- .button-wrapper -->
</div><!-- .screen -->
{% endblock %}

select_language.html

言語選択画面.
こちらも肝心なところはJavaScriptに丸投げしています.

<!--
  - @param css_file_name Name of css file for child's html file.
  - @param languages Names of each programing language.
-->

{% extends "base.html" %}

{% block content %}
<div class="screen">
    <h2 class="title">select language</h2>
    <div class="button-wrapper">
        <script>
            writeLanguages();
        </script>
    </div><!-- .button-wrapper -->
</div><!-- .screen -->
{% endblock %}

select_mode.html

モード選択.
同上.

<!--
  - @param css_file_name Name of css file for child's html file.
  - @param lang Selected language name.
-->

{% extends "base.html" %}

{% block content %}
<div class="screen">
    <h2 class="title">select mode</h2>
    <div class="button-wrapper">
        <script>
            writeModes("{{ lang }}");
        </script>
    </div><!-- .button-wrapper -->
</div><!-- .screen -->
{% endblock %}

game.html

ゲーム画面です.
といっても,肝心のゲームはJavaScriptで動くのでページが開いた瞬間の状態を書く形です.
実際のゲームプレイはplay関数によって行われます.

<!--
  - @param css_file_name Name of css file for child's html file.
  - @param lang Selected language name.
  - @param mode Selected mode name.
-->

{% extends "base.html" %}

{% block content %}
<div class="screen" id="screen">
    <h2 id="question">start with space key</h2>
    <h2 id="answer"></h2>

    <script>
        play("{{ lang }}", "{{ mode }}");
    </script>
</div><!-- .screen -->
{% endblock %}

JavaScript

questions.js

ゲーム問題を保存しておくだけのファイルです.
問題はMapの中にMapが入っている二次元Map(?)になっています.
今回はPythonに加え,C言語の問題も作ってみました.

var questions = new Map([
    [
        "Python",
        new Map([
            [
                "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",
        new Map([
            [
                "関数",
                [
                    "printf",
                    "fprintf",
                    "fopen",
                    "fclose",
                    "fgetc",
                    "fgets",
                    "fputc",
                    "fputs",

                    "abs",
                    "pow",
                    "sqrt",
                    "sin",
                    "cos",
                    "tan",
                    "log",

                    "atoi",
                    "atol",
                    "atof",

                    "srand",
                    "rand",

                    "exit",
                    "assert",

                    "malloc",
                    "free",
                    "memset",

                    "time"
                ]
            ]
        ])
    ]
]);

script.js

たびたびHTMLファイル内にあった,具体的なところを書き込む関数たちを定義しています.
また,Markdownでレポートを書きたい2でも活躍したformat関数を定義しています.この関数の使い方は下のプログラムを見ればなんとなくわかると思います.

const test = () => {
    console.log(questions);
};

const format = (template, ...args) => {
    var isDecimal = new RegExp(/^\d+$/);
    var rtn = "";
    var isChange = false;

    template.split(/({|})/).forEach( value => {
        if (value == "{") {
            isChange = !isChange;
            if (!isChange) {
                rtn = rtn + value;
            }
        } else if (isChange && isDecimal.test(value)) {
            rtn = rtn + args[value];
        } else if (isChange && value == "}") {
            isChange = false;
        } else if (!isChange && value == "}") {
            isChange = true;
            rtn = rtn + value;
        } else {
            rtn = rtn + value;
        }
    });

    return rtn;
};

const writeLanguages = () => {
    for (const key of questions.keys()) {
        document.write(format(
            "<input type='button' value='{0}' class='btn btn-primary btn-lg' onclick=\"{1}\">",
            key,
            format("location.href='/select_mode?lang={0}'", key)
        ));
    }
};

const writeModes = lang => {
    for (const key of questions.get(lang).keys()) {
        document.write(format(
            "<input type='button' value='{0}' class='btn btn-primary btn-lg' onclick=\"{1}\">",
            key,
            format(
                "location.href='/game?lang={0}&mode={1}'",
                lang,
                key
            )
        ));
    }
};

const navDropdown = () => {
    for (const lang of questions.keys()) {
        document.write(format(
            "<div class='dropdown'>\n"
            + "<a class='btn btn-dark dropdown-toggle' id='navbarDropdownMenuLink' role='button' data-toggle='dropdown' aria-haspopup='true' aria-expanded='false'>{0}</a>\n"
            + "<div class='dropdown-menu' aria-labelledby='navbarDropdownMenuLink'>\n",
            lang
        ));
        for (const mode of questions.get(lang).keys()) {
            document.write(format(
                "<a class='dropdown-item' href='/game?lang={0}&mode={1}'>{1}</a>\n",
                lang,
                mode
            ))
        };
        document.write("</div></div>");
    }
}

game.js

ゲームを行う関数であるplayを定義しています.
基本的にはkeyPress関数がメインで動き,その補助をplay以外の関数が行っていくようなイメージです.
ゲームが終わると,一秒ごとの打鍵数と精度が出力されます.

var qOrigin;
var allTypeNum;
var missTypeNum;
var questionNowNum;
var charNum;
var started;
var q;
var startTime;

const play = (lang, mode) => {
    const QUESTIONS_NUM = 10;

    qOrigin = questions.get(lang).get(mode);
    allTypeNum = 0;
    missTypeNum = 0;
    questionNowNum = -1;
    charNum = 0;
    started = false;

    q = getQuestions(QUESTIONS_NUM);

    window.addEventListener("keyup", KeyPress);
}

const getQuestions = (questionsNum) => {
    var sortedQ = JSON.parse(JSON.stringify(qOrigin));

    for (let i = sortedQ.length-1; i >= 0; i--) {
        const j = Math.floor(Math.random() * (i+1));
        [sortedQ[i], sortedQ[j]] = [sortedQ[j], sortedQ[i]];
    }

    return sortedQ.slice(0, questionsNum);
}

const updateQuestion = () => {
    questionNowNum++;
    if (questionNowNum >= q.length) {
        gameEnd();
    } else {
        document.getElementById("question").innerHTML = q[questionNowNum];
        document.getElementById("answer").innerHTML = "";
        charNum = 0;
    }
}

const gameEnd = () => {
    var endTime = new Date();
    var time = (endTime - startTime) / 1000;
    document.getElementById("question").innerHTML = format(
        "your score: {0}/s",
        ((allTypeNum - missTypeNum) / time).toFixed(2)
    );
    document.getElementById("answer").innerHTML = format(
        "accuracy: {0}%",
        ((allTypeNum - missTypeNum) / allTypeNum * 100).toFixed(2)
    );
}

const KeyPress = (event) => {
    var key = event.key;
    if (key == "Shift") return;
    if (started) {
        allTypeNum++;
        if (key == q[questionNowNum][charNum]) {
            document.getElementById("screen").style.backgroundColor = "white";
            charNum++;
            document.getElementById("answer").innerHTML += key;
            if (charNum >= q[questionNowNum].length) {
                updateQuestion();
            }
        } else {
            document.getElementById("screen").style.backgroundColor = "deeppink";
            missTypeNum++;
        }
    } else {
        if (key == " ") {
            started = true;
            startTime = new Date();
            updateQuestion();
        }
    }
}

CSS

装飾はbootstrapに頼りきりでほとんどしておらず,位置合わせ程度です.

base.css

全体で共通するCSSファイルです.
screenクラスは画面いっぱいに領域をとるようなイメージです.

* {
    margin: 0;
    padding: 0;
    --nav-height: 2.5rem;
}

div.screen {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;

    position: absolute;
    top: var(--nav-height);
    bottom: 0;
    left: 0;
    right: 0;
}

nav {
    height: var(--nav-height);
}

title_screen.css

タイトル画面.
タイトルの下に横並びでボタンを配置してます.

div.button-wrapper {
    width: 50%;

    display: flex;
    justify-content: center;
}
div.button-wrapper > input {
    margin: 0 auto;
    width: 40%;
}

h2.title {
    font-family: "SimSun";
    font-size: 3rem;
}

select_language.css

言語選択画面.ですが,モード選択でも同じファイルを使っています.

div.button-wrapper {
    width: 50%;

    display: flex;
    justify-content: center;
    flex-direction: column;
}
div.button-wrapper > input {
    margin: 0.5rem auto;
    width: 40%;
}

h2.title {
    font-family: "SimSun";
    font-size: 3rem;
}

実際にやってみた

タイトル画面.
image.png
startを押すと言語選択画面へ移動します.
image.png
言語の次はモード選択です.
image.png
これでやっとゲーム画面へ移ります.
image.png
まあ,そんなことしなくても左上のこれを押せば一発ですが.
image.png

スペースキーを押せばゲームが始まります.
image.png
タイプミスすると背景色が変わります.
image.png

まとめ

  • グローバル変数に頼りすぎ => 頼ってない
  • 戻れない => ブラウザ上で動くので左上の「戻る」ボタンで戻れる
  • スコアが分かりづらい => 変更した

これで問題点を解消できました.
FlaskやJavaScriptなど,今までやってきたことを合体させての制作だったので楽しかったです.
bootstrapは初めてですが便利すぎますね.

4
5
0

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
4
5