Edited at

Python+Flask+Herokuで麻雀アプリを作りたい(作った)

これまで作っていた麻雀ゲームをステートレスになるように

改良できたので早速Web上にアップしたいな〜と思っていろいろやりました。

一応公開まで出来たのでURLも置いときます。

良ければ触ってみてください!!

https://lonlymahjong.herokuapp.com/

↓こんな感じのを作りました〜。

スクリーンショット 2019-06-12 22.49.00.png

アプリケーションプラットフォーム(サーバー)はHerokuを利用しました。

AWSなら現職でも使う機会があっていいかなーとも思ったんですが、

お金かかるから辞めました。(そのくらい払えよって話ですが…)

せっかくなのでALL0円でやります!


使用技術

一旦開発にあたり使用した技術を整理します。

◇開発言語

 Python3

◇サーバーサイドフレームワーク

 Flask

◇フロントエンドフレームワーク

 なし

◇開発OS

 mac

◇IDE

 pycharm

◇ソース管理

 GitHub

◇アプリケーションプラットフォーム

 Heroku

◇グラフィックコンポーネント

 irasutoya

フロントエンドフレームワーク「なし」ってのが時代に逆行していて

我ながら潔いです…。


Herokuのアカウント作成

Herokuのアカウント作成は0円です。

AWSみたいにクレジットカードの登録とかもいりません。

Herokuなら無料でも個人の趣味で行う分には不満に思うことはなさそうです。

参考)山崎屋の技術メモさん

では早速登録の方法を。

といっても難しいことは何もなくHerokuのサイト

に行ってポチポチ必要な情報を入力するだけです。

先程も言いましたがクレジットカードの登録とかは不要なので、

気軽にできますね♪

スクリーンショット 2019-06-08 15.45.21.png

スクリーンショット 2019-06-08 15.45.30.png

スクリーンショット 2019-06-08 15.48.12.png

アカウント作成できましたー!


Herokuにアプリをアップロード

早速Herokuに作成したアプリをアップロードします。

アップロードの方法ですが、2種類あります。

 ①Heroku CLIをインストールして作業端末からアプリをアップロード

 ②GitHubで管理しているソースからHerokuにアップロード

僕が作ったアプリはGitHubにてソース管理しているので

②の方法で行きたいと思います。

ブラウザをぽちぽち押してるだけでできますしね。

先程のアカウント作成後のページ(ダッシュボード)にて

「新しいアプリを作成する」(Create new app)を押して

作成するアプリの情報を入力する画面を出します。

スクリーンショット 2019-06-10 3.07.16.png

といってもここではまだ

アプリの名前とサーバーのリージョン選択だけですが。

ここで入力したアプリ名がのちのちURLにもなります。

今回はひとりで永遠に麻雀するだけなので「lonlymahjong」にしました。

今流行りのぼっちですね。

んで、「Create app」を押してアプリの登録を行います。

スクリーンショット 2019-06-10 3.16.08.png

「Deploy」タグを押すと見慣れたGitHubのアイコンがあるのでこれを押して

GitHubのアカウントと紐づけます。

スクリーンショット 2019-06-08 15.58.30.png

いろいろうにゃうにゃやって(すいません、どうやるか忘れました…。)

スクリーンショット 2019-06-10 3.29.13.png

自身のGitHubアカウントが表示されればOKです。

んで、後は「Search」を押して

スクリーンショット 2019-06-10 3.31.23.png

アップロードしたいプロジェクトと「Connect」!

スクリーンショット 2019-06-10 3.34.35.png

うまくいくと「Automatic deploys」「Manual deploy」が出てきます。

とりあえず「Manual deploy」しましょう。

Manual deployの「Deploy Branch」を押すと…

スクリーンショット 2019-06-10 3.44.10.png

はい!失敗しました!!


requirements.txtをつくろう

失敗した原因はrequirements.txtを用意していなかったからです。

requirements.txtは、アプリ環境の設定ファイルです。

こいつが無いとさすがのHerokuも何をしたらいいかわからないです。

ということでこのファイルをpycharmから作ります。

pycharmの「ターミナル」を押してターミナルを起動。

起動されたターミナルで

pip freeze > requirements.txt

と入力するだけです。

スクリーンショット 2019-06-10 3.54.12.png

無事、requirements.txtが作成できましたー。

早速GitHubにrequirements.txtをアップしてもっかいデプロイします!

スクリーンショット 2019-06-10 4.02.11.png

おお、なんか上手く行きそう。

スクリーンショット 2019-06-10 4.03.36.png

Your app was successfully deployed. って出てます。

成功したみたいです!

早速「View」を押して動作確認しましょう!

スクリーンショット 2019-06-10 4.05.39.png

失敗だ!

なんでじゃ…。なにがあかんのや…。


Herokuのログを見よう

Webサーバーは動いてるみたいなのでエラーログを確認します。

Herokuのアプリログは自分の作業端末のターミナルからの確認となります。


CLIをインストール

まずは、Heroku CLIをインストールします。

いろいろなやり方があるみたいですが、僕はインストーラーでインストールしました。


CLIからログイン

インストールできたらターミナルで、

$ heroku login

を入力してログインします。そうすると

heroku: Press any key to open up the browser to login or q to exit:

と帰ってくるので何も入力せずEnter

ブラウザが起動するのでそこで「Log in」ボタンを押せばログインとなります。

スクリーンショット 2019-06-10 4.22.15.png

スクリーンショット 2019-06-10 4.26.39.png


CLIからログを見る

ログインできたら早速ログを見ます。

ログを見るコマンドは $ heroku logs です。

ただし、ここで注意です。

今回Herokuへのアップロードは②GitHubで管理しているソースからHerokuにアップロード

を採用しているので、heroku logsだけではだめです。

--appオプションをつけて、どのアプリかを指定する必要があります。

参考)@ynunokawaさん

今回作成したアプリ名はlonlymahjong なので、

$ heroku logs --app lonlymahjong

というコマンドを打てばOKです。

スクリーンショット 2019-06-10 4.42.24.png

ログを確認できました!

desc="No web processes running"

って出てますね。

web processesがNo runningのようですね。

なのでrunningしてもらうようにしましょう。


Procfileをつくろう

失敗した原因はProcfileを用意していなかったからです。

Procfileは、アプリ起動コマンドを定義するファイルです。

こいつが無いとさすがのHerokuも何をしたらいいかわからないです。

ということでこのファイルをpycharmから作ります。

スクリーンショット 2019-06-10 4.49.47.png

っと言ってもrequirements.txtみたいに特にコマンドがあるわけでもなく

Procfileというファイルをルートディレクトリ直下に作成し、

web processesの実行コマンドを書くだけです。なので、

web: python main.py

と書くだけでOK!

早速GitHubにProcfileをアップしてもっかいデプロイします!

スクリーンショット 2019-06-10 4.05.39.png

失敗だ!\(^◯^)/

スクリーンショット 2019-06-10 5.01.56.png

めげずにログを見ると

Web process failed to bind to $PORT within 60 seconds of launch

となってます。

つまり どういうことだってばよ…!?


ポートを指定しよう

いろいろ調べてみましたが、アプリケーションで設定しているポートがHerokuが設定するポート(5000)

と異なるとこのエラーが出るみたいですね。

なので、アプリケーションで設定しているポートを変更します。

この変更は、flaskフレームワークのapp起動を行っているpythonのプログラムになります。


main.py

if __name__ == '__main__':

#app.run(port=8080)  下記に変更
port = int(os.environ.get("PORT", 5000))
app.run(host='0.0.0.0', port=port)


参考)@Masa79さん

   @ekzemplaroさん

早速GitHubに修正したファイルをアップしてもっかいデプロイします!

スクリーンショット 2019-06-10 5.20.39.png

できた…。

三度目?の正直でようやくできました…。

疲れた…。


感想

なんとか公開までできました☺

Herokuすごいですね〜。ここまで出来て無料でいいのでしょうか…。

あとirasutoya

こっちはいまさら僕がなにか言うまでもないですね(笑)

でも、牌を捨てるたびにサーバーまで処理が行くのはあまり良くないですね。

HerokuのリージョンがUSAなので、毎回アメリカまで通信が行くことになりますし…。

なので今度はフロントエンドフレームワークを学んでレスポンスがすくなるようにVerUpしていきたいです。

ReactかVueかAngularか…。

どれにしようかな〜。


おまけ

参考までに今回作成したプログラムものっけときます。

何かのお役に立てれば幸いです。

mahjong/

 ├static/
 │ ├css/
 │ │└main.css
 │ └pic/
 │  └https://www.irasutoya.com/
 ├templates/
 │ └main.html
 ├Procfile
 ├requirements.txt
 ├main.py
 └mahjong.py


main.py  サーバー処理

from flask import Flask, render_template, request

import mahjong
import os

app = Flask(__name__)

# 配牌
@app.route('/')
def main():
yamahai = mahjong.create_yamahai() # 山牌
tehai = [yamahai.pop(0) for i in range(13)] # 配牌

tehai.sort(key=lambda hai: hai.sort_info()) # 理牌
tehai.append(yamahai.pop(0)) # 自摸

return render_template('main.html', tehai=tehai, win=mahjong.judge(tehai))

# 自摸
@app.route('/change', methods=['POST'])
def change():
dahai = mahjong.tile_from_pic(request.form['dahai']) # 打牌
sutehai = [mahjong.tile_from_pic(pic) for pic in request.form.getlist('sutehai')] # 捨て牌
tehai = [mahjong.tile_from_pic(pic) for pic in request.form.getlist('tehai')] # 手牌

tehai.remove(dahai)# 手牌から打牌を削除
sutehai.append(dahai)# 捨て牌に打牌を追加

yamahai = mahjong.create_yamahai() # 山牌再作成
for tile in sutehai + tehai: # 山牌から捨て牌と手牌を削除
yamahai.remove(tile)

tehai.sort(key=lambda hai: hai.sort_info()) # 理牌
tehai.append(yamahai.pop(0)) # 自摸

return render_template('main.html', tehai=tehai, sutehai=sutehai, win=mahjong.judge(tehai))

if __name__ == '__main__':
port = int(os.environ.get("PORT", 5000))
app.run(host='0.0.0.0', port=port)



mahjong.py 麻雀部分の処理 あがり判定とか

import random

import copy
import re

# 麻雀牌のクラス
class Tile:
SUUPAI = 'pinzu', 'manzu', 'souzu'
JIHAI = 'sufonpai', 'sangenpai'
WINDS = '東南西北'
COLORS = '白發中'

def __init__(self, kind, value):
self.kind = kind # 麻雀牌の種類(萬子・筒子・索子・四風牌・三元牌)
self.value = value # 麻雀牌の値(1~9 東南西北白発中)
self.pic = f'{kind}_{value}.png' # 画像ファイル名

def __repr__(self):
return self.pic

def __eq__(self, other):
if not isinstance(other, Tile):
return False
return self.pic == other.pic

def __hash__(self):
return hash(self.pic)

def sort_info(self):
if Tile.SUUPAI[0] == self.kind:
return f'0_{self.value}'
elif Tile.SUUPAI[1] == self.kind:
return f'1_{self.value}'
elif Tile.SUUPAI[2] == self.kind:
return f'2_{self.value}'
elif Tile.JIHAI[0] == self.kind:
return f'3_{self.value}'
elif Tile.JIHAI[1] == self.kind:
return f'4_{self.value}'

# 山牌 シャッフルされた136個のTileオブジェクトリストを返却
def create_yamahai():
tiles = [Tile(kind, str(value))
for kind in Tile.SUUPAI
for value in range(1, 1 + 9)]
tiles += [Tile(Tile.JIHAI[0], value)
for value, label in enumerate(Tile.WINDS, 1)]
tiles += [Tile(Tile.JIHAI[1], value)
for value, label in enumerate(Tile.COLORS, 1)]
tiles *= 4

random.shuffle(tiles)
random.shuffle(tiles)
random.shuffle(tiles)
return tiles

# pic(画像ファイル名)からTileオブジェクトを作成
def tile_from_pic(pic):
s = re.search(r'^(.+)_(.+)\.png$', pic).groups()
return Tile(s[0], s[1])

# 通常あがり牌
class Agari:
def __init__(self, janto, mentsu1, mentsu2, mentsu3, mentsu4):
self.janto = janto
self.mentsu1 = mentsu1
self.mentsu2 = mentsu2
self.mentsu3 = mentsu3
self.mentsu4 = mentsu4

def __repr__(self):
return f'[{repr(self.janto[0])},{repr(self.janto[1])}],' \
f'[{repr(self.mentsu1.tiles[0])},{repr(self.mentsu1.tiles[1])},{repr(self.mentsu1.tiles[2])}],' \
f'[{repr(self.mentsu2.tiles[0])},{repr(self.mentsu2.tiles[1])},{repr(self.mentsu2.tiles[2])}],' \
f'[{repr(self.mentsu3.tiles[0])},{repr(self.mentsu3.tiles[1])},{repr(self.mentsu3.tiles[2])}],' \
f'[{repr(self.mentsu4.tiles[0])},{repr(self.mentsu4.tiles[1])},{repr(self.mentsu4.tiles[2])}]'

class Janto:

def __init__(self, tiles):
self.tiles = tiles

class Mentsu:
KIND = 'syuntsu', 'koutsu'

def __init__(self, kind, tiles):
self.kind = kind
self.tiles = tiles

class NoMentsu(Exception):
pass

# 七対子
class Titoitsu:
def __init__(self, l_toitsu):
self.l_toitsu = l_toitsu

def __repr__(self):
return f'[{repr(self.l_toitsu[0])},{repr(self.l_toitsu[0])}],' \
f'[{repr(self.l_toitsu[1])},{repr(self.l_toitsu[1])}],' \
f'[{repr(self.l_toitsu[2])},{repr(self.l_toitsu[2])}],' \
f'[{repr(self.l_toitsu[3])},{repr(self.l_toitsu[3])}],' \
f'[{repr(self.l_toitsu[4])},{repr(self.l_toitsu[4])}],' \
f'[{repr(self.l_toitsu[5])},{repr(self.l_toitsu[5])}],' \
f'[{repr(self.l_toitsu[6])},{repr(self.l_toitsu[6])}]'

# 国士無双
class Kokushimusou:
def __init__(self, l_tile):
self.l_tile = l_tile

def __repr__(self):
return f'{self.l_tile}'

# あがり判定
def judge(tehai):
agari_hai = []

# 雀頭の種類
l_janto = sorted([x for x in set(tehai) if tehai.count(x) >= 2], key=lambda hai: f'{hai.kind}{hai.value}')
if len(l_janto) == 0:
return agari_hai

# 国士無双
if check_kokushimusou(tehai, l_janto):
return Kokushimusou(tehai)

# 七対子
if len(l_janto) == 7:
agari_hai.append(Titoitsu(l_janto))

# 通常役
for janto in l_janto:
mentsu_kouho = copy.deepcopy(tehai)
mentsu_kouho.remove(janto)
mentsu_kouho.remove(janto)

mentsu_kouho.sort(key=lambda hai: f'{hai.kind}{hai.value}')

# 刻子の種類
l_koutsu = sorted([x for x in set(mentsu_kouho) if mentsu_kouho.count(x) >= 3],
key=lambda hai: f'{hai.kind}{hai.value}')

# 刻子が0個のパターン
agari_hai.extend(agari_koutsu0(mentsu_kouho, janto))

# 刻子が1個のパターン
agari_hai.extend(agari_koutsu1(mentsu_kouho, janto, l_koutsu))

# 刻子が2個のパターン
agari_hai.extend(agari_koutsu2(mentsu_kouho, janto, l_koutsu))

# 刻子が3個のパターン
agari_hai.extend(agari_koutsu3(mentsu_kouho, janto, l_koutsu))

# 刻子が4個のパターン
agari_hai.extend(agari_koutsu4(janto, l_koutsu))

return len(agari_hai) > 0

# 刻子が0個のあがりパターン
def agari_koutsu0(mentsu_kouho, janto):
try:
hanteiyou = copy.deepcopy(mentsu_kouho)

first = find_one_syuntu(hanteiyou)
hanteiyou.remove(first.tiles[0])
hanteiyou.remove(first.tiles[1])
hanteiyou.remove(first.tiles[2])

second = find_one_syuntu(hanteiyou)
hanteiyou.remove(second.tiles[0])
hanteiyou.remove(second.tiles[1])
hanteiyou.remove(second.tiles[2])

third = find_one_syuntu(hanteiyou)
hanteiyou.remove(third.tiles[0])
hanteiyou.remove(third.tiles[1])
hanteiyou.remove(third.tiles[2])

fourth = find_one_syuntu(hanteiyou)

return [Agari([janto for x in range(2)], first, second, third, fourth)]

except NoMentsu:
return []

# 刻子が1個のあがりパターン
def agari_koutsu1(mentsu_kouho, janto, l_koutsu):
if len(l_koutsu) < 1:
return []

result = []
for koutsu in l_koutsu:
try:
hanteiyou = copy.deepcopy(mentsu_kouho)

first = Mentsu(Mentsu.KIND[1], [koutsu for x in range(3)])
hanteiyou.remove(first.tiles[0])
hanteiyou.remove(first.tiles[1])
hanteiyou.remove(first.tiles[2])

second = find_one_syuntu(hanteiyou)
hanteiyou.remove(second.tiles[0])
hanteiyou.remove(second.tiles[1])
hanteiyou.remove(second.tiles[2])

third = find_one_syuntu(hanteiyou)
hanteiyou.remove(third.tiles[0])
hanteiyou.remove(third.tiles[1])
hanteiyou.remove(third.tiles[2])

fourth = find_one_syuntu(hanteiyou)

result.append(Agari([janto for x in range(2)], first, second, third, fourth))

except NoMentsu:
continue

return result

# 刻子が2個のあがりパターン
def agari_koutsu2(mentsu_kouho, janto, l_koutsu):
if len(l_koutsu) < 2:
return []

result = []
for i in range(len(l_koutsu) - 1):
for j in range(i + 1, len(l_koutsu)):
try:
hanteiyou = copy.deepcopy(mentsu_kouho)

first = Mentsu(Mentsu.KIND[1], [l_koutsu[i] for x in range(3)])
hanteiyou.remove(first.tiles[0])
hanteiyou.remove(first.tiles[1])
hanteiyou.remove(first.tiles[2])

second = Mentsu(Mentsu.KIND[1], [l_koutsu[j] for x in range(3)])
hanteiyou.remove(second.tiles[0])
hanteiyou.remove(second.tiles[1])
hanteiyou.remove(second.tiles[2])

third = find_one_syuntu(hanteiyou)
hanteiyou.remove(third.tiles[0])
hanteiyou.remove(third.tiles[1])
hanteiyou.remove(third.tiles[2])

fourth = find_one_syuntu(hanteiyou)

result.append(Agari([janto for x in range(2)], first, second, third, fourth))

except NoMentsu:
continue

return result

# 刻子が3個のあがりパターン
def agari_koutsu3(mentsu_kouho, janto, l_koutsu):
if len(l_koutsu) != 3:
return []

try:
hanteiyou = copy.deepcopy(mentsu_kouho)

first = Mentsu(Mentsu.KIND[1], [l_koutsu[0] for x in range(3)])
hanteiyou.remove(first.tiles[0])
hanteiyou.remove(first.tiles[1])
hanteiyou.remove(first.tiles[2])

second = Mentsu(Mentsu.KIND[1], [l_koutsu[1] for x in range(3)])
hanteiyou.remove(second.tiles[0])
hanteiyou.remove(second.tiles[1])
hanteiyou.remove(second.tiles[2])

third = Mentsu(Mentsu.KIND[1], [l_koutsu[2] for x in range(3)])
hanteiyou.remove(third.tiles[0])
hanteiyou.remove(third.tiles[1])
hanteiyou.remove(third.tiles[2])

fourth = find_one_syuntu(hanteiyou)

return [Agari([janto for x in range(2)], first, second, third, fourth)]

except NoMentsu:
return []

# 刻子が4個のあがりパターン
def agari_koutsu4(janto, l_koutsu):
if len(l_koutsu) != 4:
return []

return [Agari([janto for x in range(2)], Mentsu(Mentsu.KIND[1], [l_koutsu[0] for x in range(3)]),
Mentsu(Mentsu.KIND[1], [l_koutsu[1] for x in range(3)]),
Mentsu(Mentsu.KIND[1], [l_koutsu[2] for x in range(3)]),
Mentsu(Mentsu.KIND[1], [l_koutsu[3] for x in range(3)]))]

# 国士無双のチェック(前提として雀頭があること)
def check_kokushimusou(tehai, l_koutsu):
if len(l_koutsu) != 1:
return []

if Tile(Tile.SUUPAI[0], '1') in tehai \
and Tile(Tile.SUUPAI[0], '9') in tehai \
and Tile(Tile.SUUPAI[1], '1') in tehai \
and Tile(Tile.SUUPAI[1], '9') in tehai \
and Tile(Tile.SUUPAI[2], '1') in tehai \
and Tile(Tile.SUUPAI[2], '9') in tehai \
and Tile(Tile.JIHAI[0], Tile.WINDS[0]) in tehai \
and Tile(Tile.JIHAI[0], Tile.WINDS[1]) in tehai \
and Tile(Tile.JIHAI[0], Tile.WINDS[2]) in tehai \
and Tile(Tile.JIHAI[0], Tile.WINDS[3]) in tehai \
and Tile(Tile.JIHAI[1], Tile.COLORS[0]) in tehai \
and Tile(Tile.JIHAI[1], Tile.COLORS[1]) in tehai \
and Tile(Tile.JIHAI[1], Tile.COLORS[2]) in tehai:
return True

# 順子をひとつ見つける
def find_one_syuntu(hanteiyou):
hanteiyou.sort(key=lambda hai: f'{hai.kind}{hai.value}')
for hanteiyou_one_tile in hanteiyou:
syuntsu_kouho = create_syuntsu(hanteiyou_one_tile)
if syuntsu_kouho is None:
continue
if syuntsu_kouho[1] in hanteiyou and syuntsu_kouho[2] in hanteiyou:
return Mentsu(Mentsu.KIND[0], syuntsu_kouho)
raise NoMentsu()

# 自身を一番最初とした順子を返却
def create_syuntsu(tile):
if tile.kind in Tile.SUUPAI and int(tile.value) <= 7:
return [Tile(tile.kind, str(value))
for value in range(int(tile.value), int(tile.value) + 3)]



main.html

<!doctype html>

<html lang="ja">
<head>
<meta charset="UTF-8">
<link rel="stylesheet"href="/static/css/main.css">
<title>ひとりまーじゃん</title>
</head>

<body>
{% if win %}
<div class="win">
<a href="/"><img src=/static/pic/win.png></a>
</div>
{% else %}
<div class="sutehai">
{% for tile in sutehai %}{% if ((loop.index - 1) % 9) == 0 %}<br>{% endif %}<img src=/static/pic/{{tile.pic}}>{% endfor %}
</div>
{% endif %}
<div class="tehai">{% for tile in tehai %}{% if loop.last %}&nbsp;{% endif %}<a onclick="change.dahai.value = '{{tile.pic}}';change.submit();"><img src=/static/pic/{{tile.pic}}></a>{% endfor %}</div>
<form name="change" action="{{ url_for('change') }}" method=post>
{% for tile in sutehai %}
<input name="sutehai" type="hidden" value="{{tile.pic}}">
{% endfor %}
{% for tile in tehai %}
<input name="tehai" type="hidden" value="{{tile.pic}}">
{% endfor %}
<input name="dahai" type="hidden" value="">
</form>
</body>
</html>



main.css

body{

background-color: mediumaquamarine;
}
.sutehai{
text-align : left;
margin-left : 100px;
margin-top : 100px;
}
.tehai{
position : absolute;
bottom : 0;
margin-bottom : 30px;
}