🌲

はじめてのWebアプリ開発

2024/06/30に公開

何を作る?

ヌメロンを作ります。4つの数字を当てるゲームです。
このサイトに沿って進めていきます。
デザイン性は無視します。

完成図

アプリケーション構成

Vue.js
Flask
Ajax

フロントエンド

環境構築

Node.jsのバージョンを比較的新しいものに変更しました。

$ nodenv versions
system
* 12.20.1
  16.14.0
  16.20.0
  17.6.0

$ node local 17.6.0

Vue CLIをインストールします。

$ npm install -g @vue/cli

Vue.jsのプロジェクトを作成

vueコマンドのパスが通っていなかったので通しました。

$ vue create fronend
-zsh: vue: command not found

$ export PATH=$PATH:`npm bin -g` 

参考にしているサイトに従ってVue2を選択。

$ vue create frontend                             
Vue CLI v5.0.8
? Please pick a preset: 
  Default ([Vue 3] babel, eslint) 
❯ Default ([Vue 2] babel, eslint) 
  Manually select features 

(Enter押す)

Vue CLI v5.0.8
✨  Creating project in /Users/user/myApp/numer0n/frontend.
⚙️  Installing CLI plugins. This might take a while...

(略)

🎉  Successfully created project frontend.
👉  Get started with the following commands:

 $ cd frontend
 $ npm run serve

指示通りにコマンドを打ちます。

$ cd frontend
$ npm i axios vue-axios
$ vue add vuetify
$ npm run serve

> frontend@0.1.0 serve
> vue-cli-service serve

 INFO  Starting development server...


 DONE  Compiled successfully in 4487ms     

ブラウザでhttp://localhost:8080/にアクセスすると無事にチュートリアル的な画面が表示されました。

スクリーンショット 2024-06-29 16.43.00.png

GUI作成

Vue.js内でAxiosを使うためmain.jsに追記

main.js
import axios from 'axios'
import VueAxios from 'vue-axios'

Vue.use(VueAxios, axios)

テンプレート部分です。

App.vue
<template>
  <v-app>
    <v-app-bar
      app
      color="primary"
      dark
    >
      <span class="text-h5">numer0n (数字列当てゲーム)</span>
    </v-app-bar>

    <v-main>
      <v-container>
        <div>
          相異なる4つの数字(0〜9)を入力してください。<br>
          正解の数字列と比べて、数字と位置が同じものの数をEAT、数字は同じで位置が違うものの数をBITEとします。<br>
          {{ maxGuessCount }} 回以内に 4 EAT、 0 BITE を達成すれば成功です。
        </div>
        <v-container>
          <v-row>
            <v-col>
              <v-text-field v-model="input"></v-text-field>
            </v-col>
            <v-col>
              <v-btn @click="guess" :disabled="!isValidInput || isFinished || isCorrect">決定</v-btn>
            </v-col>
          </v-row>
        </v-container>
      </v-container>
      <v-container>
        <div v-for="(result, index) in history" :key="index">
          <span>【{{ index + 1 }}回目】 {{ result.input }} は {{ result.eat }} EAT、 {{ result.bite }} BITE です。</span>
        </div>
      </v-container>
      <v-container>
        <v-alert v-if="!success" type="error">{{ this.message }}</v-alert>
      </v-container>
      <v-container v-if="isFinished || isCorrect">
        <v-row>
          <v-col>
            <v-alert v-if="isCorrect" type="success">成功🥳 おめでとうございます!</v-alert>
            <v-alert v-else type="error">失敗😔 答えは {{ history.at(-1).answer }} です!</v-alert>
          </v-col>
          <v-col>
            <v-btn @click="retry">もう一回やる</v-btn>
          </v-col>
        </v-row>
      </v-container>
    </v-main>
  </v-app>
</template>

以下はスクリプト部分です。

回答を送信するguess()関数では、POSTリクエストで入力値と乱数シード値を送っています。サーバ側でランダムに生成する答えが回答送信で更新されないように、適当にシード値を設定しました。ただしリロードするとdataが初期化されてシード値が変わり、答えも変わります。

App.vue
<script>

export default {
  name: 'App',

  data: () => ({
    // 最大回答回数
    maxGuessCount: 7,
    // テキストフォームの入力値
    input: null,
    // 通信に成功したか
    success: true,
    //通信に失敗した場合のメッセージ
    message: null,
    // 回答履歴
    history: [],
    // ランダムに生成される正解のシード
    seed: Math.floor(Math.random() * 100),
  }),

  computed: {
    // 入力をばらばらの数字にしたリスト
    // 入力が重複なしの数字4桁でない場合は空配列を返す
    isValidInput() {
      // 入力がstring型かチェック
      if (typeof this.input !== "string") {
        return false;
      }
      // 入力が重複なしの数字4桁かチェック
      const inputList = this.input.split('');
      if (inputList.length !== 4 ||
          inputList.some((char) => Number.isNaN(Number(char))) || 
          inputList.length !== new Set(inputList).size
      ) {
        return false;
      }
      return true;
    },

    // 最大回答回数に達しているか
    isFinished() {
      return this.history.length >= this.maxGuessCount;
    },

    // 最後の回答が正解か
    isCorrect() {
      return this.history.length > 0 && this.history.at(-1).eat === 4;
    },
  },

  methods: {
    // 回答を送信する
    guess() {
      const req = JSON.stringify({
        input: this.input,
        seed: this.seed,
        isFinalGuess: this.history.length === this.maxGuessCount - 1,
      });

      this.axios
        .post(`http://127.0.0.1:5000/api/guess`, req)
        .then((res) => {
          this.history.push(res.data);
        })
        .catch(() => {
           // 通信に失敗した場合
          this.success = false;
          this.message = "通信中にエラーが発生しました。";
        });
      
      this.input = null;
    },

    // リロードする
    retry() {
      window.location.reload();
    }
  }
};
</script>

バックエンド

プロジェクトルート直下(frontendがあるディレクトリ)にmain.pyを作成。

numer0n
 ┣ main.py
 ┗ frontend

Flaskによるサーバ構築

Flaskをインストール

$ pip install flask flask-cors

Vue.jsアプリケーションをビルドした際、numer0n/distに諸々のファイルが作成されるようにします。参考にしているサイト通りにやるとnumer0n/data/distに作られてしまうので注意。

vue.config.js
const path = require("path");
const { defineConfig } = require('@vue/cli-service')

module.exports = defineConfig({
   transpileDependencies: [
       'vuetify'
   ],
   assetsDir: "static",
   outputDir: path.resolve(__dirname, "../dist"),
})

フロントエンド側をビルドします。

$ npm run build --prefix frontend

予定通り、numer0n/dist以下にいろいろなものが作成されます。次にmain.pyでFlaskオブジェクトを作成します。

main.py
import sys
import webbrowser
from pathlib import Path
import random

from flask import Flask, render_template, request
from flask_cors import CORS


def base_dir():
    if hasattr(sys, "_MEIPASS"):
        # 実行ファイルで起動した場合、展開先ディレクトリを基点とする。
        return Path(sys._MEIPASS)
    else:
        # python コマンドで起動した場合、プロジェクトディレクトリを基点とする。
        return Path(".")


app = Flask(
    __name__,
    template_folder=base_dir() / "dist",
    static_folder=base_dir() / "dist/static",
)
CORS(app)

API作成

<hostname>/にアクセスしたとき、フロントエンド側のindex.htmlを返すようにルーティングします。

main.py
@app.route("/")
def index():
    """フロントエンド側のページを表示する。

    Returns:
        str: HTML
    """
    return render_template("index.html")

<hostname>/api/guessにPOSTリクエストが来たとき、EATとBITEを計算して返すAPIを作成します。

レスポンスには入力値を結合した文字列とEAT, BITEを入れました。最後の回答のときは答えも一緒に返します。

main.py
@app.route("/api/guess", methods=["POST"])
def guess():
    """与えられた4つの数字からeat, biteを返す API

    Returns:
        dict: レスポンス
    """
    data = request.get_json(force=True)

    # シード値から答えを生成
    random.seed(data["seed"])
    target = random.sample(list(range(10)), 4)

    # EAT, BITEを計算
    input = data["input"]
    eat, bite = 0, 0
    for inputIndex in range(4):
        for targetIndex in range(4):
            if int(input[inputIndex]) == target[targetIndex]:
                if inputIndex == targetIndex:
                    eat += 1
                else:
                    bite += 1

    # 最後の回答の場合、答えを返す
    answer = "".join([str(num) for num in target]) if data["isFinalGuess"] else "";
    return {"input": input, "eat": eat, "bite": bite, "answer": answer}

サーバ起動

最後にサーバ起動部分です。

main.py
def main():
    app.run(debug=True, host="0.0.0.0", port=5000)

if __name__ == "__main__":
    main()

実行

サーバを起動します。

$ python main.py

localhost:8080にアクセスするとアプリケーションが起動しています。

遊んでみましょう。

少し運がよかったですね。

また、以下のコマンドを打つとPyinstallerでビルドできることがわかりました。

$ pip install pyinstaller
$ pyinstaller main.py --add-data "dist:dist" --onefile --name numer0n

最後に

問題なく遊べるので、以上で完成ということにします。

言葉の意味など分からないところは、今後アプリを作りながら調べていこうと思います。
ちなみに、次は画像認識か自然言語処理を取り入れて何か作る予定です。

ありがとうございました。

Discussion