Python+Flask+Herokuで麻雀アプリを作りたい(作った)の記事に
上げたように、Pythonを使って麻雀(もどき)アプリを作成することは出来ました。
ですが、やはり牌を捨てるたびにサーバーにアクセスしに行くのは無駄ですね。
電車の中とかですと、全然通信出来ないタイミングもありますし…。
なので、JavaScriptだけで出来ないかやってみました!
JavaScriptだけって言いながらVue.jsも使いましたが(笑)
まぁJavaScriptフレームワークなのでセーフってことで。
【完成品】
https://lonlymahjong.herokuapp.com/vue
#はじめに
Pythonを使って麻雀アプリを作ることは出来ているので、
そのロジックをほぼそのままJavaScriptで書くだけですね!
簡単そうです!
・・・どうやればいいんだろう?
3日ほど悩みましたが、妖精さんが夜中にコードを書いてくれることはなかったので
取り敢えず作業を以下に分けて実施して行こうと思います。
①麻雀牌のクラスを作る
②山牌を作る
③初期配牌する
④牌を捨てて積もる
⑤あがり判定
こうやってタスクを切り分けると
なんだかできそうな気がしきますね!
#①麻雀牌のクラスを作る
まずはじめに牌のクラスを作っていきたいと思います。
・・・でもJavaScriptでクラスってどう作ればいいんだ?
プログラミングの先生もJavaとJavaScriptは違うって言ってたし…。
と3日ほど悩みましたが、オブジェクト指向が役に立った話を読んでみると
どうやらほとんどJavaと同じような感じに書けるらしい。まじか。
きっと自分の知らない新技術をこっそり使ってるんだろうなーと思ってたけど
どうやら最近のJavaScriptはES6って言うバージョンで何もしなくても
ブラウザが対応してれば使えるらしい。まじでか。
//麻雀牌クラス
class Hai{
//コンストラクタ
constructor(kind, value){
this.kind = kind; //麻雀牌の種類(萬子・筒子・索子・四風牌・三元牌)
this.value = value; //麻雀牌の値(1~9 東南西北白発中)
this.pic = this.kind+'_'+String(this.value)+'.png'; //画像ファイル名
}
}
すごく簡単に出来た!
ES6すげぇ…。技術の進化バンザイ!
#②山牌を作る
麻雀牌クラスが作れたので、後は136牌インスタンスを作って
それをシャッフルすれば山牌が出来ますね。
Pythonではリスト内包表記で簡単に
インスタンスのListができたんですけど
ES6にはないみたいですね…。
せっかくなので、ジェネレーターを使って書いてみることにします。
const KINDS = {
SUUPAI : ['manzu', 'pinzu', 'souzu'],
JIHAI : ['sufonpai', 'sangenpai']
}
const SUUPAI_VALUE = [1,2,3,4,5,6,7,8,9];
const SUFONPAI_VALUE = [1,2,3,4];
const SANGENPAI_VALUE = [1,2,3];
//山牌作成
function createYamahai(){
// 配列ランダムソート(シャッフル)関数
let shuffleArray = (arr) => {
let n = arr.length;
let temp = 0, i = 0;
while (n) {
i = Math.floor(Math.random() * n--);
temp = arr[n];
arr[n] = arr[i];
arr[i] = temp;
}
return arr;
}
//山牌作成ジェネレーター
let yamahaiGenerator = function* (kinds, values){
for (let kind of kinds) {
for (let value of values){
for (let i = 0; i<4; i++ ) {
yield new Hai(kind,value);
}
}
}
};
//山牌返却
return shuffleArray([...yamahaiGenerator(KINDS.SUUPAI,SUUPAI_VALUE)
,...yamahaiGenerator([KINDS.JIHAI[0]],SUFONPAI_VALUE)
,...yamahaiGenerator([KINDS.JIHAI[1]],SANGENPAI_VALUE)]);
}
JavaScript - 配列をランダムソート(シャッフル)するを参考に、シャッフルする関数も自作。
createYamahai関数でしか山牌作成ジェネレーターもシャッフル関数も利用しないので
createYamahai関数内にオブジェクトとして作成しました。
このほうがスコープが絞れたりしてメリットになったりするのかな?
#③初期配牌する
山牌が出来たので、後はそこから14牌取ってきて画面に表示できれば
なんかそれっぽいものが一旦出来ます。
ここからはJavaScriptフレームワークとしてVue.jsを利用しました。
AngularとReact、Vue.jsどれを使うか迷ったのですが、
比較的スモールスタートできるとのことだったので。
とはいえ、vue-cliとかNode.jsとかNuxt.jsとか利用しないと使えないんだろなー
と思ってたんですが、HTMLファイルの中で普通にJsファイルを読み込むだけで使えるみたい。
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
まじでか。Vue.jsパネェ。
ということで早速実装♪
var app = new Vue({
el:'#app',
data:{
yamahai : [],
tehai : []
},
created: function() {
//山牌作成
this.yamahai = createYamahai();
//配牌作成(山牌から14牌取得する)
for (let i = 0; i<14; i++ ) {
this.tehai.push(this.yamahai.shift());
}
}
})
<div id="app">
<div>
<img v-for="hai in tehai" v-bind:src="`/static/pic/${hai.pic}`">
</div>
</div>
双方向データバインディングを使うと簡単にブラウザ上の表示を変えられて便利ですね!
#④牌を捨てて積もる
これもVue.jsを使えば簡単に出来ます。
var app = new Vue({
el:'#app',
data:{
yamahai : [],
tehai : []
},
created: function() {
//山牌作成
this.yamahai = createYamahai();
//配牌作成(山牌から14牌取得する)
for (let i = 0; i<14; i++ ) {
this.tehai.push(this.yamahai.shift());
}
},
methods: {
//牌の交換
change: function(index) {
//捨牌
this.tehai.splice(index, 1);
//自摸
let tsumo = this.yamahai.shift();
this.tehai.push(tsumo);
}
}
})
<div id="app">
<div>
<img v-for="(hai, index) in tehai" v-bind:src="`/static/pic/${hai.pic}`" v-on:click="change(index)">
</div>
</div>
v-forディレクティブの要素にIndexを取れるので、
それを利用してクリックした牌を捨てて新しく自摸ってきます。
#⑤あがり判定
あがり判定ロジックはPythonで作り込んだので、
それをJavaScriptとに書き直すだけなのでこれも簡単。
・・・と思ってたんですが、雀頭の候補取得が思いのほか難しかったです。
もっと形式化すると、「配列内の重複項目の取得」です。
Pythonでは以下のように書いていたのですが、
[x for x in set(tehai) if tehai.count(x) >= 2]
JavaScriptだとオブジェクトの配列に対してSetが使えないんですよね…。
おそらく、「===演算子が「同一のインスタンスかどうか」を判断するため」だと思うんですが…。
JabaScriptのClassオブジェクトもJavaのesualsメソッドや、Pythonの__eq__メソッドが
あればできるんだと思うんですが…。
なので、JavaScriptでは以下メソッドを自作して対応しました。
//麻雀牌クラス
class Hai{
//コンストラクタ
constructor(kind, value){
this.kind = kind; //麻雀牌の種類(萬子・筒子・索子・四風牌・三元牌)
this.value = value; //麻雀牌の値(1~9 東南西北白発中)
this.pic = this.kind+'_'+String(this.value)+'.png'; //画像ファイル名
}
//イコールメソッド
equals(hai){
return hai.kind == this.kind && hai.value == this.value
}
}
//重複要素取得
//duplicateCount:重複数
function findDuplicate(array,duplicateCount){
let setArray = array.filter((val, index, self) =>
self.findIndex(n => val.equals(n)) === index);
let result = setArray.filter(val =>
(array.filter(n => val.equals(n)).length >= duplicateCount));
return result;
}
setArray(重複のない配列)=
findIndexにて配列内で要素が最初に一致する位置が取得できるので、
それをループのindexとぶつけて一致するものだけを取得。
この処理で利用しているequalsメソッドは自作したものになります。
result=
setArray(重複のない配列)の一要素が、
もとの配列に何件あるか取得し、その件数がduplicateCountを上回る値だけを取得。
…プログラム処理を日本語にするのは難しいですね。
#完成!
上記の処理に加え、理牌や捨て牌の表示、あたりの際の画像表示を行えばとりあえず完成です。
思ったより長かった…。
でもJavaScriptだけですべての処理を行うことができるようになりました!
牌の画像さえキャッシュに残っていれば低レイテンシどころか0レイテンシです!
Pythonで書いたコードをJavaScriptで書き直すって言うのは結構発見があってよかったです。
一度書いたコードを別の言語で書き直すって言うのは学習の一貫としてありかもしれないですね。
それにしてもES6すごいな…。もう大抵のことはJavaScriptでできちゃいますね。
新人の研修とかもJavaScriptでいいんじゃないかな〜。
今後の改修案としては
①あがり役の表示と点数計算機能を実装する。
②サーバーサイドでもなにかやる。
(あがりまでの打牌数や点数をDBに記録してなんか出す。
グラフ化して上達具合を可視化するとか?)
③Vue.jsをもっと使いこなす。
(なんかソシャゲっぽくする。ソシャゲっぽいってなんだ…?)
④Firebaseを使って見る。
(Python(Flask)はルーティングしか使ってないですし、
Firebaseを使っても行けるんでないかなーと。
使ったことないけど。)
なんかを考えてます。
また時間を作ってやっていこうっと。
#おまけ
今回書いたコード。GitHubにも上げてます!
const KINDS = {
SUUPAI : ['manzu', 'pinzu', 'souzu'],
JIHAI : ['sufonpai', 'sangenpai']
}
const SUUPAI_VALUE = [1,2,3,4,5,6,7,8,9];
const SUFONPAI_VALUE = [1,2,3,4];
const SANGENPAI_VALUE = [1,2,3];
// const SUFONPAI_VALUE = {
// 1:'東',
// 2:'南',
// 3:'西',
// 4:'北'
// };
// const SANGENPAI_VALUE = {
// 1:'白',
// 2:'發',
// 3:'中'
// };
const MENTSU_KINDS = ['順子','刻子']
//麻雀牌クラス
class Hai{
//コンストラクタ
constructor(kind, value){
this.kind = kind; //麻雀牌の種類(萬子・筒子・索子・四風牌・三元牌)
this.value = value; //麻雀牌の値(1~9 東南西北白発中)
this.pic = this.kind+'_'+String(this.value)+'.png'; //画像ファイル名
}
//ソートキーを返却
getSortKey(){
switch( this.kind ) {
case KINDS.SUUPAI[0]:
return Number('1'+String(this.value));
case KINDS.SUUPAI[1]:
return Number('2'+String(this.value));
case KINDS.SUUPAI[2]:
return Number('3'+String(this.value));
case KINDS.JIHAI[0]:
return Number('4'+String(this.value));
case KINDS.JIHAI[1]:
return Number('5'+String(this.value));
default:
throw new TypeError('Hai.kind is not undefined');
}
}
//イコールメソッド
equals(hai){
return hai.kind == this.kind && hai.value == this.value
}
}
//あがり牌クラス
class Agari{
//コンストラクタ
constructor(yaku,arrayHai,janto,mentsu1,mentsu2,mentsu3,mentsu4){
this.yaku = yaku;
this.arrayHai = arrayHai;
this.janto = janto;
this.mentsu1 = mentsu1;
this.mentsu2 = mentsu2;
this.mentsu3 = mentsu3;
this.mentsu4 = mentsu4;
}
}
//雀頭
class Janto{
//コンストラクタ
constructor(arrayHai){
this.arrayHai = arrayHai;
}
}
//面子
class Mentsu{
//コンストラクタ
constructor(kind, arrayHai){
this.kind = kind;
this.arrayHai = arrayHai;
}
}
//麻雀牌オブジェとのソート用関数
function sortHai(a,b){
return a.getSortKey()-b.getSortKey();
}
//山牌作成
function createYamahai(){
// 配列ランダムソート(シャッフル)関数
let shuffleArray = (arr) => {
let n = arr.length;
let temp = 0, i = 0;
while (n) {
i = Math.floor(Math.random() * n--);
temp = arr[n];
arr[n] = arr[i];
arr[i] = temp;
}
return arr;
}
//山牌作成ジェネレーター
let yamahaiGenerator = function* (kinds, values){
for (let kind of kinds) {
for (let value of values){
for (let i = 0; i<4; i++ ) {
yield new Hai(kind,value);
}
}
}
};
//山牌返却
return shuffleArray([...yamahaiGenerator(KINDS.SUUPAI,SUUPAI_VALUE)
,...yamahaiGenerator([KINDS.JIHAI[0]],SUFONPAI_VALUE)
,...yamahaiGenerator([KINDS.JIHAI[1]],SANGENPAI_VALUE)]);
}
//あがり判定
function judge(tehai){
agari=[];
//雀頭
jantoArray = findDuplicate(tehai,2);
if(jantoArray.length === 0){
return false;
}
//国士無双
if(checkKokushimusou(tehai, jantoArray)){
//return new Agari('国士無双',tehai,null,null,null,null,null);
return true;
}
//七対子
if(jantoArray.length === 7){
agari= agari.concat(new Agari('七対子',tehai,null,null,null,null,null));
}
//通常役
for(let janto of jantoArray){
let copy = Object.assign([], tehai);
removeElement(copy,janto);
removeElement(copy,janto);
copy.sort(sortHai);
//刻子の種類
koutsuArray = findDuplicate(copy,3);
//刻子が0個のパターン
agari= agari.concat(agariKoutsu0(copy, janto))
//刻子が1個のパターン
agari= agari.concat(agariKoutsu1(copy, janto, koutsuArray))
//刻子が2個のパターン
agari= agari.concat(agariKoutsu2(copy, janto, koutsuArray))
//刻子が3個のパターン
agari= agari.concat(agariKoutsu3(copy, janto, koutsuArray))
//刻子が4個のパターン
agari= agari.concat(agariKoutsu4(janto, koutsuArray))
}
return agari.length > 0
}
//重複要素取得
//duplicateCount:重複数
function findDuplicate(array,duplicateCount){
let setArray = array.filter((val, index, self) =>
self.findIndex(n => val.equals(n)) === index);
let result = setArray.filter(val =>
(array.filter(n => val.equals(n)).length >= duplicateCount));
return result.sort(sortHai);
}
//配列に特定の要素があるか確認
//存在する場合:true
function checkAvailability(array, val) {
return array.some(arrVal => val.equals(arrVal));
}
//配列から特定の要素を削除
function removeElement(array, val){
let index = array.findIndex(arrVal => val.equals(arrVal));
array.splice(index, 1);
}
//順子をひとつ見つける
function findOneSyuntsu(arrayHai){
arrayHai.sort(sortHai);
for(let hai of arrayHai){
let syuntsu = createSyuntsu(hai);
if(syuntsu == null){
continue;
}
if(checkAvailability(arrayHai,syuntsu.arrayHai[0])
&& checkAvailability(arrayHai,syuntsu.arrayHai[1])
&& checkAvailability(arrayHai,syuntsu.arrayHai[2])){
return syuntsu;
}
}
throw new RangeError('No Mentsu');
}
//自身を一番最初とした順子を返却
function createSyuntsu(hai){
if(KINDS.SUUPAI.includes(hai.kind) && hai.value <= 7){
return new Mentsu(MENTSU_KINDS[0],
[new Hai(hai.kind,hai.value)
,new Hai(hai.kind,hai.value+1)
,new Hai(hai.kind,hai.value+2)]);
}
return null;
}
//刻子が0個のあがりパターン
function agariKoutsu0(arrayHai,janto){
try {
let copy = Object.assign([], arrayHai);
let first = findOneSyuntsu(copy);
removeElement(copy,first.arrayHai[0]);
removeElement(copy,first.arrayHai[1]);
removeElement(copy,first.arrayHai[2]);
let second = findOneSyuntsu(copy);
removeElement(copy,second.arrayHai[0]);
removeElement(copy,second.arrayHai[1]);
removeElement(copy,second.arrayHai[2]);
let third = findOneSyuntsu(copy);
removeElement(copy,third.arrayHai[0]);
removeElement(copy,third.arrayHai[1]);
removeElement(copy,third.arrayHai[2]);
let fourth = findOneSyuntsu(copy);
return [new Agari(new Janto([janto,janto]),
first, second, third, fourth)];
}catch (e) {
return [];
}
}
//刻子が1個のあがりパターン
function agariKoutsu1(arrayHai,janto,koutsuArray){
if(koutsuArray.length < 1){
return [];
}
let result = [];
for(let koutsu of koutsuArray){
try{
let copy = Object.assign([], arrayHai);
let first = new Mentsu(MENTSU_KINDS[1],[koutsu,koutsu,koutsu]);
removeElement(copy,first.arrayHai[0]);
removeElement(copy,first.arrayHai[1]);
removeElement(copy,first.arrayHai[2]);
let second = findOneSyuntsu(copy);
removeElement(copy,second.arrayHai[0]);
removeElement(copy,second.arrayHai[1]);
removeElement(copy,second.arrayHai[2]);
let third = findOneSyuntsu(copy);
removeElement(copy,third.arrayHai[0]);
removeElement(copy,third.arrayHai[1]);
removeElement(copy,third.arrayHai[2]);
let fourth = findOneSyuntsu(copy);
result.push([new Agari(new Janto([janto,janto]),
first, second, third, fourth)]);
}catch (e) {
continue;
}
}
return result;
}
//刻子が2個のあがりパターン
function agariKoutsu2(arrayHai,janto,koutsuArray){
if(koutsuArray.length < 2){
return [];
}
let result = [];
for(let i = 0; i < koutsuArray.length - 1; i++){
for(let j = i + 1; j < koutsuArray.length; j++){
try{
let copy = Object.assign([], arrayHai);
let first = new Mentsu(MENTSU_KINDS[1],[koutsuArray[i],koutsuArray[i],koutsuArray[i]]);
removeElement(copy,first.arrayHai[0]);
removeElement(copy,first.arrayHai[1]);
removeElement(copy,first.arrayHai[2]);
let second = new Mentsu(MENTSU_KINDS[1],[koutsuArray[j],koutsuArray[j],koutsuArray[j]]);
removeElement(copy,second.arrayHai[0]);
removeElement(copy,second.arrayHai[1]);
removeElement(copy,second.arrayHai[2]);
let third = findOneSyuntsu(copy);
removeElement(copy,third.arrayHai[0]);
removeElement(copy,third.arrayHai[1]);
removeElement(copy,third.arrayHai[2]);
let fourth = findOneSyuntsu(copy);
result.push([new Agari(new Janto([janto,janto]),
first, second, third, fourth)]);
}catch (e) {
continue;
}
}
}
return result;
}
//刻子が3個のあがりパターン
function agariKoutsu3(arrayHai,janto,koutsuArray){
if(koutsuArray.length != 3){
return [];
}
try {
let copy = Object.assign([], arrayHai);
let first = new Mentsu(MENTSU_KINDS[1],[koutsuArray[0],koutsuArray[0],koutsuArray[0]]);
removeElement(copy,first.arrayHai[0]);
removeElement(copy,first.arrayHai[1]);
removeElement(copy,first.arrayHai[2]);
let second = new Mentsu(MENTSU_KINDS[1],[koutsuArray[1],koutsuArray[1],koutsuArray[1]]);
removeElement(copy,second.arrayHai[0]);
removeElement(copy,second.arrayHai[1]);
removeElement(copy,second.arrayHai[2]);
let third = new Mentsu(MENTSU_KINDS[1],[koutsuArray[2],koutsuArray[2],koutsuArray[2]]);
removeElement(copy,third.arrayHai[0]);
removeElement(copy,third.arrayHai[1]);
removeElement(copy,third.arrayHai[2]);
let fourth = findOneSyuntsu(copy);
return [new Agari(new Janto([janto,janto]),
first, second, third, fourth)];
}catch (e) {
return [];
}
}
//刻子が4個のあがりパターン
function agariKoutsu4(janto,koutsuArray){
if(koutsuArray.length != 4){
return [];
}
return [new Agari(new Janto([janto,janto]),
new Mentsu(MENTSU_KINDS[1],[koutsuArray[0],koutsuArray[0],koutsuArray[0]]),
new Mentsu(MENTSU_KINDS[1],[koutsuArray[1],koutsuArray[1],koutsuArray[1]]),
new Mentsu(MENTSU_KINDS[1],[koutsuArray[2],koutsuArray[2],koutsuArray[2]]),
new Mentsu(MENTSU_KINDS[1],[koutsuArray[3],koutsuArray[3],koutsuArray[3]]))];
}
//国士無双のチェック(前提として雀頭があること)
function checkKokushimusou(tehai){
if(
checkAvailability(tehai,new Hai(KINDS.SUUPAI[0],SUUPAI_VALUE[0])) &&
checkAvailability(tehai,new Hai(KINDS.SUUPAI[0],SUUPAI_VALUE[8])) &&
checkAvailability(tehai,new Hai(KINDS.SUUPAI[1],SUUPAI_VALUE[0])) &&
checkAvailability(tehai,new Hai(KINDS.SUUPAI[1],SUUPAI_VALUE[8])) &&
checkAvailability(tehai,new Hai(KINDS.SUUPAI[2],SUUPAI_VALUE[0])) &&
checkAvailability(tehai,new Hai(KINDS.SUUPAI[2],SUUPAI_VALUE[8])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[0],SUFONPAI_VALUE[0])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[0],SUFONPAI_VALUE[1])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[0],SUFONPAI_VALUE[2])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[0],SUFONPAI_VALUE[3])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[1],SANGENPAI_VALUE[0])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[1],SANGENPAI_VALUE[1])) &&
checkAvailability(tehai,new Hai(KINDS.JIHAI[1],SANGENPAI_VALUE[2]))
){
return true;
}else{
return false;
}
}
const TEST_TEHAI = ['23333444556688', '22333456667788', '22344445556677', '11123334445577','22223344455677', '11555677788899'];
function createTestTehai(index){
return Array.from(TEST_TEHAI[index]).map(n => new Hai(KINDS.SUUPAI[0],Number(n)));
}
function checkTestTehai(){
for(let i =0;i<TEST_TEHAI.length;i++){
let tehai = createTestTehai(i);
// console.log(TEST_TEHAI[i]);
// console.log(judge(tehai));
if(!judge(tehai)){
console.log('失敗')
}
}
}
var app = new Vue({
el:'#app',
data:{
yamahai : [],
kawa : [],
tehai : [],
agari : false
},
created: function() {
//山牌作成
this.yamahai = createYamahai();
//配牌作成(山牌から14牌取得する)
for (let i = 0; i<14; i++ ) {
this.tehai.push(this.yamahai.shift());
}
//理牌
this.tehai.sort(sortHai)
//あがり判定
this.agari = judge(this.tehai);
},
methods: {
//牌の交換
change: function(index) {
//捨牌
sutehai = this.tehai[index];
this.tehai.splice(index, 1);
//河
this.kawa.push(sutehai);
//理牌
this.tehai.sort(sortHai);
//自摸
let tsumo = this.yamahai.shift();
this.tehai.push(tsumo);
//あがり判定
this.agari = judge(this.tehai);
}
}
})
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<link rel="stylesheet"href="/static/css/main.css">
<title>ひとりまーじゃん</title>
</head>
<body class="vue">
<div id="app">
<div class="win" v-if="agari" >
<a href="/vue"><img src="/static/pic/win.png"></a>
</div>
<div class="sutehai" v-if="!agari" >
<template v-for="(hai, index) in kawa"><br v-if="(index % 9) == 0" ><img v-bind:src="`/static/pic/${hai.pic}`"></template>
</div>
<div class="tehai">
<img v-for="(hai, index) in tehai" v-bind:src="`/static/pic/${hai.pic}`" v-on:click="change(index)">
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="/static/js/mahjong.js"></script>
<script src="/static/js/main.js"></script>
</body>
</html>