6
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Hello Worldの次はマインスイーパを作ろう(React+FastAPI)

Last updated at Posted at 2021-04-16

はじめに

言語やフレームワークのサンプルやHello Worldを動かすことが出来た新人プログラマの皆さんは学習の次ステップで何をすればいいか悩むと思います。

世間一般ではブラックジャックがメジャーなようですが、
私はマインスイーパを作ることで理解を深める機雷処理言語学習法を実践しています。

この記事ではReact+FastAPIでマイスイーパを作成する流れを記載します。

demo.gif

Windows 10にはマインスイーパが初期インストールされていないので、新入社員や学生さんにマインスイーパを知らない層がいそうなのが怖いですが、気にしないことにします。

開発環境

前回、学習用のReact+FastAPI環境を作成したので、こちらを利用していきます。

FastAPIは新規のソースを用意するので、uvicornコマンドが動作中の場合はCtrl+Cで停止してください。

ReactはApp.jsをそのまま編集していくので、yarn startしたままでOKです。

バックエンド(Python+FastAPI)

APIの仕様

以下のAPIで実装します。

API メソッド パラメータ 説明
/ms GET なし session IDの一覧取得
/ms POST width : int
height : int
mine : int
seed : int
sessionの作成
/ms/{session} GET session : str 画面に表示する情報の取得
/ms/{session} POST session : str
x : int
y : int
セルを開く
/ms/{session}/flag POST session : str
x : int
y : int
flag : int
(1=ON,0=OFF)
フラッグの設定

DELETEメソッドでsessionを削除するAPIも作成したほうがよいが、今回は不採用。

モデル

セルの状態を管理するモデルクラスを実装します。

getPublicInfo()というjsonに変換可能なdictで情報を返すメソッドを用意しています。

このdictをFastAPIで返すとjson型の応答になります。

ロジックの説明は以前に作成した仕組みを参照。

ms_model.py

import random
import json


class pointOffset:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    @staticmethod
    def rounds():
        return [pointOffset(-1, -1), pointOffset(0, -1), pointOffset(1, -1),
                pointOffset(-1, 0),                      pointOffset(1, 0),
                pointOffset(-1, 1),  pointOffset(0, 1),  pointOffset(1, 1)]


class Cell:
    def __init__(self, isOpen, isFlag, isMine):
        self.isOpen = isOpen
        self.isFlag = isFlag
        self.isMine = isMine

    def __repr__(self):
        return json.dumps(self.getPublicInfo())

    def getPublicInfo(self):
        result = {
            'open': int(self.isOpen),
            'flag': int(self.isFlag),
        }
        if self.isOpen:
            result['mine'] = int(self.isMine)
        return result


class Field:

    def __init__(self, width, height, mine):
        self.cells = list()
        self.width = width
        self.height = height
        self.mine = mine
        for i in range(self.width * self.height):
            cellMine = True if i < mine else False
            self.cells.append(Cell(False, False, cellMine))
        random.shuffle(self.cells)

    def __repr__(self):
        return json.dumps(self.getPublicInfo())

    def getPublicInfo(self):
        rest = self.mine
        publicCells = list()
        for i, cell in enumerate(self.cells):
            cellInfo = cell.getPublicInfo()
            if cellInfo["open"] == 1 and cellInfo["mine"] == 0:
                cellInfo["number"] = self.roundNum(
                    i % self.width, int(i / self.width))
            if cellInfo["open"] == 0 and cellInfo["flag"] == 1 :
                rest = rest - 1
            publicCells.append(cellInfo)

        status = "continue"
        if self.isOver():
            status = "over"
        elif self.isClear():
            status = "cleared"

        result = {
            'cells': publicCells,
            'width': self.width,
            'height': self.height,
            'mine': self.mine,
            'status': status,
            'rest' : rest
        }
        return result

    def cell(self, x, y):
        if 0 > x or 0 > y or self.width <= x or self.height <= y:
            return None
        return self.cells[y * self.width + x]

    def open(self, x, y):
        cell = self.cell(x, y)
        if cell is None:
            return
        if cell.isOpen:
            return
        cell.isOpen = True
        if not cell.isMine:
            if self.roundNum(x, y) == 0:  # 0なら隣接cellをOpenする
                for offset in pointOffset.rounds():
                    self.open(x + offset.x, y + offset.y)

    def flag(self, x, y, isFlag):
        cell = self.cell(x, y)
        if cell is None:
            return
        cell.isFlag = isFlag

    def roundNum(self, x, y):
        round = 0
        for offset in pointOffset.rounds():
            if self.isMine(x + offset.x, y + offset.y):
                round += 1
        return round

    # 指定セルが存在してmine状態の場合にTrueを返す
    def isMine(self, x, y):
        cell = self.cell(x, y)
        if cell is None:
            return False
        return cell.isMine

    def isOver(self):
        for y in range(self.height):
            for x in range(self.width):
                if self.cell(x, y).isOpen and self.cell(x, y).isMine:
                    return True
        return False

    def isClear(self):
        closeCount = 0
        for y in range(self.height):
            for x in range(self.width):
                if not self.cell(x, y).isOpen:
                    closeCount += 1
        return True if closeCount == self.mine else False

モデルとAPIを繋ぐ管理クラスの実装

シングルトンで保持する動作にします。

本来はsessionをキーにしてモデルの状態をDBに格納するべきですが、今回は簡易実装にしました。

ms_manager.py

from ms_model import Field
import uuid
import random


class ms_manager:
    singleton_instance = None
    sessions = dict()

    def __new__(cls, *args, **kwargs):
        # シングルトン
        if cls.singleton_instance == None:
            cls.singleton_instance = super().__new__(cls)
        return cls.singleton_instance

    def create(self, width, hight, mine, seed_value):
        random.seed(seed_value)
        hash = str(uuid.uuid4()).replace("-","")
        self.sessions[hash] = {
            "session": hash,
            "status": "new",
            "field": Field(width, hight, mine)
        }
        return {'session': hash}

    def get_list(self):
        session_list = list()
        for session in self.sessions.keys():
            session_list.append(session)
        return session_list

    def get_sessison(self, session):
        if not session in self.sessions:
            return {"error": "session is not found"}
        return self.sessions[session]["field"].getPublicInfo()

    def open(self, session, x, y):
        if not session in self.sessions:
            return {"error": "session is not found"}
        self.sessions[session]["field"].open(x, y)
        return self.sessions[session]["field"].getPublicInfo()

    def flag(self, session, x, y, flag):
        if not session in self.sessions:
            return {"error": "session is not found"}
        self.sessions[session]["field"].flag(x, y, bool(flag))
        return self.sessions[session]["field"].getPublicInfo()



pythonでシングルトンは初めて作りました。不思議な仕様ですね。

APIの実装

FastAPIで、APIの定義を行います。
APIが呼ばれたらmanagerにそのまま引数を渡します。
レスポンスもそのままmanagerの戻り値の辞書を返します。

ms_main.py

from fastapi import FastAPI
from ms_manager import ms_manager
from starlette.middleware.cors import CORSMiddleware
from pydantic import BaseModel

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"]
)

class CreateParam(BaseModel):
    width: int
    hight: int
    mine: int
    seed: int

class OpenParam(BaseModel):
    x: int
    y: int

class FlagParam(BaseModel):
    x: int
    y: int
    flag: int


@app.post("/ms")
def ms_post_root(param : CreateParam):
    return ms_manager().create(param.width, param.hight, param.mine, param.seed)


@app.get("/ms")
def ms_get_root():
    return {"session": ms_manager().get_list()}


@app.get("/ms/{session}")
def ms_get_session(session: str):
    return ms_manager().get_sessison(session)


@app.post("/ms/{session}")
def ms_post_session(session: str, param : OpenParam):
    return ms_manager().open(session, param.x, param.y)


@app.post("/ms/{session}/flag")
def ms_post_flag(session: str, param:FlagParam):
    return ms_manager().flag(session, param.x, param.y, param.flag)


以下のコマンドでFastAPIを起動してください。

uvicorn ms_main:app --reload --host 0.0.0.0

APIのテスト用のスクリプトも補足に記載しておきます。*

フロントエンド(React)

実装するイベントは下表とします。

操作 説明 API
リセットボタン 状態をリセットします。 POST /ms
描画 セルの状態を取得します。 GET /ms/{session}
クリック セルを開きます。 POST /ms/{session}
Ctrl+クリック フラッグを設定/解除します。 POST /ms/{session}/flag

各種操作でバックエンドへの要求し、取得した情報の表示を行います。

画像ファイル類はpublicフォルダに格納して参照します。*

Table.js

import './Table.css';

import React from "react";
import axios from "axios";


class Table extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
          table : '' ,
          message:''
        };
      this.base_url = "http://localhost:8000/ms";
      this.session = '';
      this.width = 30;
      this.hight = 16;
      this.mine = 99;
      this.fields = null;
    }
   
    componentDidMount = () => {
        this.updateTable();
    }

    handleClick = () => {
        this.updateTable();
    }
    
    updateTable = () => {
        axios
            .post(this.base_url , {
                    "width": this.width,
                    "hight": this.hight,
                    "mine": this.mine,
                    "seed" : Date.now()})
            .then(res => {
                var data = res.data;
                this.get_fields(data.session);
                })
            .catch(err =>{
                console.log(err);
            });
    }

    get_fields = (session) => {   
        this.session = session;     
        const session_url = this.base_url + "/" + this.session;
        axios
            .get(session_url)
            .then(res => {
                this.showTable(res.data);
                })
            .catch(err =>{
                console.log(err);
            });
    }
    
    showTable = (fields) => {

        this.fields = fields;
        var items = []
        for(var y = 0 ; y < fields.height ; y++){
            for(var x = 0 ; x < fields.width ; x++){
                var i = y * fields.width + x;
                var c = fields.cells[i];
                var filename = '';
                if(c.open === 0)
                    if(c.flag === 1)
                        filename = `${process.env.PUBLIC_URL}/flag.png`;
                    else
                        filename = `${process.env.PUBLIC_URL}/close.png`;
                else
                    if(c.mine === 1)
                        filename = `${process.env.PUBLIC_URL}/mine.png`
                    else if(c.number)
                        filename = `${process.env.PUBLIC_URL}/` + c.number + `.png`
                    else
                        filename = `${process.env.PUBLIC_URL}/open.png`;
                items.push(<img name={i} src={filename} onClick={(e)=>{ this.choiceDiv(e) } } />);
            }
            items.push(<br/>);
        }

        this.setState({table : (<dev>{items}</dev>)});

        if(fields.status === "over") 
            alert("over");
        else if(fields.status === "cleared") 
            alert("cleared");

        var rest_label = "[Rest:" + fields.rest + "]" ;
        this.setState({message : rest_label});
    }

    choiceDiv = (e) => {
        var num = parseInt(e.currentTarget.name);
        var x = num % this.width;    
        var y = num / this.width;

        if( this.fields.status === "over" || 
            this.fields.status === "cleared") {
            return;
        }
        
        if (e.ctrlKey){
            if( this.fields.cells[num].open === 0){
                var flag = 0;
                if(this.fields.cells[num].flag === 0){
                    flag = 1
                }
                this.setFlag(x, y, flag)
            }
        }
        else{
            if( this.fields.cells[num].open === 0 && 
                this.fields.cells[num].flag === 0){
                this.openCell(x,y)
            }
        }
    }

    setFlag = (x,y,flag) => {
        const flag_url = this.base_url + "/" + this.session + "/flag";
        axios
            .post(flag_url , { "x": x, "y": y, flag: flag})
            .then(res => {
                this.showTable(res.data);
                })
            .catch(err =>{
                console.log(err);
            });
    }

    openCell = (x,y) =>{
        
        const session_url = this.base_url + "/" + this.session;
        axios
            .post(session_url , { "x": x, "y": y})
            .then(res => {
                this.showTable(res.data);
                })
            .catch(err =>{
                console.log(err);
            });
    }

    render() {
        return (
            <div>
                <button onClick={() => this.handleClick()}>[リセット]</button>
                <div>{this.state.message}</div>                
                <div className="Table-fields">{this.state.table}</div>                
            </div>
        );
    }
  }
  
  export default Table;

セルの配置がずれないようにcssを用意します。

Table.css

.Table-fields {
    font-size: 0;  
  }

デフォルトのApp.jsから不要な物を削除し、Tableのみを表示させます。

App.js

import './App.css';
import Table from "./Table";

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <Table />
      </header>
    </div>
  );
}

export default App;


動作確認

ブラウザでReactのページを表示します。
ボタンやセルをクリックしてみてください。

cap_top.png

おわりに

モデルがバックエンドにあるので、Reactは操作のイベントと表示だけの実装となりシンプルになったように感じました。

Reactでは描画更新のためにstateに設定するのが重要ということは理解しましたが、ちゃんと画面設計できるようになるには慣れが必要そう。

Reactの勉強というよりは、Web開発の練習にちょうどいい難易度だと思いますので、皆さんも自分で使用とロジックを考えて作ってみてください。

補足

アイコンの作成

使用したアイコンのPNG画像はpythonで作成しました。
生成スクリプトも置いておきます。

generate_icons.py

from PIL import Image, ImageDraw, ImageFont

w = 24
h = 24
lineWidth = 2
font_path = 'C:/WINDOWS/Fonts/impact.ttf'  # for Windows 太目のフォント
font_size = 20
font_y_offset = -2  # 計算で中央に配置してもいまいちなので微調整
OpenBgColor = (220, 220, 220)
OpenLineColor = (255, 255, 255)

param = [
    {"filename": "1.png", "text": "1",
        "textColor": (0, 0, 255),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "2.png", "text": "2",
        "textColor": (0, 100, 0),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "3.png", "text": "3",
        "textColor": (255, 0, 0),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "4.png", "text": "4",
        "textColor": (0, 0, 100),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "5.png", "text": "5",
        "textColor": (100, 25, 25),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "6.png", "text": "6",
        "textColor": (0, 100, 100),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "7.png", "text": "7",
        "textColor": (0, 0, 0),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "8.png", "text": "8",
        "textColor": (100, 100, 100),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "open.png", "text": "",    # 0の場合は数字表示しない
        "textColor": (0, 0, 0),
        "bgColor": OpenBgColor,
        "lineColor": OpenLineColor},
    {"filename": "mine.png", "text": "",
        "textColor": (0, 0, 0),
        "bgColor": (255, 0, 0),
        "lineColor": OpenLineColor},
    # 開く前のはLineの色が違う
    {"filename": "close.png", "text": "",
        "textColor": (0, 0, 0),
        "bgColor": (128, 128, 128),
        "lineColor": (64, 64, 64)},
    {"filename": "flag.png", "text": "",    # TODO 旗のデザインにしたいが単色
        "textColor": (0, 0, 0),
        "bgColor": (0, 0, 255),
        "lineColor": (64, 64, 64)},
]

font = ImageFont.truetype(font_path, font_size)

for item in param:
    image = Image.new("RGB", (w, h), item["bgColor"])
    draw = ImageDraw.Draw(image)

    if item["text"]:
        text_w, text_h = draw.textsize(item["text"], font=font)
        draw.text(((w - text_w) / 2, (h - text_h) / 2 + font_y_offset),
                  item["text"],
                  item["textColor"],
                  font=font)

    draw.line(((0, h-lineWidth), (w, h-lineWidth)),
              item["lineColor"], width=lineWidth)
    draw.line(((w-lineWidth, 0), (w-lineWidth, h)),
              item["lineColor"], width=lineWidth)

    image.save(item["filename"])


APIのテスト

APIを動作確認するためのPythonスクリプトです。

dockerコンテナで別なターミナルを起動して実行してください。

Python環境があれば、ホストPCで実行してもよいです。

ms_client.py

import requests
import json
import time

base_url = "http://127.0.0.1:8000/ms"


def printField(response):
    cells = response["cells"]
    width = response["width"]
    hight = response["height"]
    status = response["status"]

    print("[{}*{}]".format(width, hight))

    for i, cell in enumerate(cells):
        if i % width == 0 and i != 0:
            print("")  # 改行

        if cell["open"] == 0 and cell["flag"] == 1:
            print("F", end="")
        elif cell["open"] == 0 and cell["flag"] == 0:
            print("/", end="")
        elif cell["open"] == 1:
            if "mine" in cell and cell["mine"] == 1:
                print("*", end="")
            elif "number" in cell:
                print("{}".format(cell["number"]), end="")

    print("\nstatus={}\n".format(status))


def main():
    response = requests.post(base_url,
                             json={'seed': int(time.time()),
                                     'width': 5,
                                     'hight': 6,
                                     'mine': 2,
                                     })
    res_create = response.json()
    session_id = res_create['session']

    session_url = "{}/{}".format(base_url, session_id)
    response = requests.get(session_url)
    printField(response.json())

    loop = True
    while loop:
        print("input [x y]")
        # "1 0"のようにスペースで区切ってxとyの座標を入力しEnterキー
        input_x, input_y = map(int, input().split())

        response = requests.post(session_url,
                                 json={'x': input_x, 'y': input_y})
        res_open = response.json()
        printField(res_open)

        if res_open["status"] != "continue":
            loop = False


if __name__ == '__main__':
    main()



6
11
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
6
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?