上海麻雀は積み上げられた麻雀牌の中から同じ種類の牌を2個ずつ取り、画面上の全ての牌を取ればクリアとなる思考型パズルゲームです。取ることができるのは積まれた麻雀牌の左右のどちらかが空いている牌で、かつ上に牌が積まれていないものでなければなりません。
Contents
HTML部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>鳩でもわかる上海まーじゃん</title> <meta name = "viewport" content = "width=device-width, initial-scale = 1.0"> <link rel="stylesheet" href="./style.css"> </head> <body> <div id = "container"> <p><span id = "how-to-play"> 同じ牌を選択して消してください。ただし選択できるのは各段の端にあるものだけです。 [取り消し / もとに戻す] ボタンでひとつ前の状態に戻すことができます。 手詰まりになってもあきらめない限りゲームオーバーにはなりません。 あきらめたらそこで試合終了ですよ(安西先生)。 頑張ってトライしよう。 </span></p> <div id = "field"></div> <div id = "start-buttons"> <p>プレイヤー名:<input id = "player-name" maxlength="32"></p> <button id = "start">開始</button> <p><a href="./ranking.html" id="go-ranking">スコアランキングをみる</a> <button id="tw-button">結果を X にポストする</button> </p> </div> <div id = "control-buttons"> <button id = "cancel">取り消し / もとに戻す</button> <button id = "search">探す</button> </div> <div id = "volume"></div> </div> <script src="./app.js"></script> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </body> </html> |
style.css
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
body { background-color: #008b8b; color:#fff; } #container { width: 490px; overflow: hidden; margin: 0 auto 0 auto; } #field { position: relative; } #control-buttons, #start-buttons { margin-top: 10px; margin-bottom: 10px; } #control-buttons { display: none; } #start, #search { width: 140px; height: 40px; } #cancel { width: 180px; height: 40px; } #player-name { width: 200px; } a { font-weight: bold; text-decoration: none; } #tw-button, #go-ranking { font-weight: bold; text-align: end; border: none; font-size: 16px; background-color: #1DA1F2; background-color: #000; padding: 6px 16px; border-radius: 100vh; color: white; cursor: pointer; } #how-to-play { white-space: nowrap; font-weight: bold; } |
グローバル変数と定数
グローバル変数と定数を示します。
app.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
const TILE_WIDTH = 37; // 牌の表示サイズ const TILE_HEIGHT = 51; const ROW_COUNT = 8; // 牌は8行12列4段 const COL_COUNT = 12; const DEPTH_COUNT = 4; const marginLeft = 20; // 左上角の牌の左上部分を表示させる座標 const marginTop = 60; // ボタン類 const $start = document.getElementById('start'); const $cancel = document.getElementById('cancel'); const $search = document.getElementById('search'); let $selectButtons = []; // その他のDOM要素 const $field = document.getElementById('field'); $field.style.position = 'relative'; const $canvas = document.createElement('canvas'); $canvas.style.position = 'absolute'; const ctx = $canvas.getContext('2d'); const $startButtons = document.getElementById('start-buttons'); const $controlButtons = document.getElementById('control-buttons'); const $playerName = document.getElementById('player-name'); const $howToPlay = document.getElementById('how-to-play'); let tiles3x3 = []; // Tileオブジェクトを格納する三次元配列 let selected = null; // 選択されている牌 let sugested = []; // 「探す」コマンドで提案されたふたつの牌 let undo = []; // 「元に戻す」コマンドで戻せるように取り除いたTileオブジェクトを格納する let isPlaying = false; // 現在プレイ中かどうか? let isStalemate = false; // 現在手詰まりの状態かどうか? let isGiveuped = false; // 「探す」コマンドを使用したらギブアップしたものとする let startTime = Date.now(); // プレイ開始時間(プレイ時間の計測に使う) let playMilliseconds = 0; // プレイ時間 // ページ上部で説明文を横スクロールで表示させたい。 // その左マージン(500 ~ -2300のレンジを移動させる) let howToPlayLeft = 500; // 効果音 const selectSound = new Audio('./sounds/select.mp3'); const ngSound = new Audio('./sounds/ng.mp3'); const stalemateSound = new Audio('./sounds/stalemate.mp3'); const gameclearSound = new Audio('./sounds/gameclear.mp3'); const sounds = [gameclearSound, selectSound, ngSound, stalemateSound,]; let volume = 0.3; |
Tileクラスの定義
牌を表示させたり取り除けるかの判定をするためにTileクラスを定義します。
コンストラクタ
コンストラクタを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class Tile { constructor(num){ this.Number = num; // 牌の種類を整数で表現する this.Row = 0; // 牌の表示位置(上から何行目?) this.Col = 0; //(左から何列目?) this.Depth = 0; //(何段目?) this.Left = 0; // 牌の左側のX座標(Row, Col, Depthから計算する) this.Right = 0; // 牌の右側のX座標(同上) this.Top = 0; // 牌の上側のY座標(同上) this.Bottom = 0; // 牌の下側のY座標(同上) this.Dx = 6; // 段違いの牌の表示座標(X座標)をどれだけズラすか? this.Dy = 6; //(Y座標) const path = `./images/${num}.png`; const image = new Image(); image.src = path; this.Image = image; // 描画に使うイメージ } } |
牌の設置
三次元配列 tiles3x3 にTileオブジェクトが格納されている場合、その牌は後述のDraw関数によって描画されます。SetPosition関数はtiles3x3に自分自身を格納して、引数の位置に牌が描画されるようにするためのものです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
class Tile { SetPosition(depth, row, col){ tiles3x3[depth][row][col] = this; this.Depth = depth; this.Row = row; this.Col = col; // depth, row, colから描画位置を計算する this.Left = TILE_WIDTH * this.Col - ((DEPTH_COUNT - this.Depth) * this.Dx) + marginLeft; this.Right = this.Left + TILE_WIDTH; this.Top = TILE_HEIGHT * this.Row - ((DEPTH_COUNT - this.Depth) * this.Dy) + marginTop; this.Bottom = this.Top + TILE_HEIGHT; } } |
牌は選択できるか?
CanSelect関数はその牌が選択可能か(積まれた麻雀牌の左右のどちらかが空いている牌で、かつ上に牌が積まれていないという条件を満たすかどうか?)を返します。IsTop関数は自分自身の上に他の牌が積まれていないかどうかを返す関数です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class Tile { CanSelect(){ if(!this.IsTop()) return false; else if(this.Col == 0 || tiles3x3[this.Depth][this.Row][this.Col - 1] == null) return true; else if(this.Col == COL_COUNT - 1 || tiles3x3[this.Depth][this.Row][this.Col + 1] == null) return true; else return false; } IsTop(){ if(this.Depth == 0) return true; if(tiles3x3[this.Depth - 1][this.Row][this.Col] == null) return true; else return false; } } |
牌の描画
Draw関数は牌をcanvasに描画します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
class Tile { Draw(){ // 下の段にある牌を覆い隠す ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.moveTo(this.Left, this.Top); ctx.lineTo(this.Right, this.Top); ctx.lineTo(this.Right + this.Dx, this.Top + this.Dy); ctx.lineTo(this.Right + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Left + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Left, this.Bottom); ctx.fill(); // 牌の側面部(右側)に色をつけて影ができているようにする ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.beginPath(); ctx.moveTo(this.Right, this.Top); ctx.lineTo(this.Right + this.Dx, this.Top + this.Dy); ctx.lineTo(this.Right + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Right, this.Bottom); ctx.fill(); // 牌の側面部(下側)に色をつけて影ができているようにする ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; ctx.beginPath(); ctx.moveTo(this.Left, this.Bottom); ctx.lineTo(this.Right, this.Bottom); ctx.lineTo(this.Right + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Left + this.Dx, this.Bottom + this.Dy); ctx.fill(); // 牌の各辺にあたる部分に黒い線を描画する ctx.strokeStyle = '#000'; ctx.beginPath(); ctx.moveTo(this.Right + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Right + this.Dx, this.Top + this.Dy); ctx.lineTo(this.Right, this.Top); ctx.moveTo(this.Right + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Left + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Left, this.Bottom); ctx.moveTo(this.Right + this.Dx, this.Bottom + this.Dy); ctx.lineTo(this.Right, this.Bottom); ctx.stroke(); ctx.strokeRect(this.Left, this.Top, TILE_WIDTH, TILE_HEIGHT); // 表面に牌のイメージを描画する ctx.drawImage(this.Image, this.Left, this.Top, TILE_WIDTH, TILE_HEIGHT); // 選択可能な牌に着色する if(this.CanSelect()){ let magnification = 1; if(selected == this) magnification = 2; if(this.Depth == 0) ctx.fillStyle = `rgba(255, 0, 0, ${0.2 * magnification})`; if(this.Depth == 1) ctx.fillStyle = `rgba(255, 255, 0, ${0.2 * magnification})`; if(this.Depth == 2) ctx.fillStyle = `rgba(0, 255, 0, ${0.2 * magnification})`; if(this.Depth == 3) ctx.fillStyle = `rgba(0, 0, 255, ${0.1 * magnification})`; ctx.fillRect(this.Left, this.Top, TILE_WIDTH, TILE_HEIGHT); } // 探すコマンドで提案された牌を赤い枠で囲む if(sugested[0] == this || sugested[1] == this){ ctx.strokeStyle = '#f00'; ctx.lineWidth = 4; ctx?.strokeRect(this.Left + 2, this.Top + 2, TILE_WIDTH - 4, TILE_HEIGHT - 4); ctx.lineWidth = 1; } } } |
アンドゥ機能
Undo関数は一度取り除いた牌を再度tiles3x3に格納して表示されるようにします。SetPosition関数実行時に設定されたRow, Leftなどの値は変化しないのでそのまま使います。
1 2 3 4 5 |
class Tile { Undo(){ tiles3x3[this.Depth][this.Row][this.Col] = this; } } |
ページが読み込まれたときの処理
ページが読み込まれたときに行われる処理は以下のとおりです。
牌選択用のボタンを生成する(createSelectButtons関数)
$fieldのサイズ変更
問題の生成(createProblem関数)
更新処理の開始(update関数)
レンジスライダーでボリューム調整ができるようにする(initVolume関数)
SNSでの拡散を期待してXにポストするボタンを表示する(ご協力をお願いします m(_)m )
initVolume関数はここで解説しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
window.onload = () => { const savedName = localStorage.getItem('hatodemowakaru-player-name'); if(savedName) $playerName.value = savedName; $selectButtons = createSelectButtons(); // 後述 $field?.appendChild($canvas); // フィールド全体の幅と高さを計算する const fieldWidth = TILE_WIDTH * COL_COUNT + marginLeft * 2; const fieldHeight = TILE_HEIGHT * ROW_COUNT + marginTop * 1.2; $field.style.width = fieldWidth + 'px'; $field.style.height = fieldHeight + 'px'; $canvas.width = fieldWidth; $canvas.height = fieldHeight; createProblem(); // 後述 update(); // 後述 initVolume('volume'); document.getElementById('tw-button').onclick = () => { let text = `鳩でもわかる上海麻雀 みんなで挑戦しよう!`; const hashtags = '鳩でもわかる上海麻雀'; const url = encodeURIComponent(location.href); // URLを生成して遷移 window.open('https://twitter.com/intent/tweet?text=' + text + '&hashtags=' + hashtags + '&url=' + url); } } |
牌を選択できるようにするために牌が描画されている位置に見えないボタンを設置します。createSelectButtons関数はそのボタンを生成する関数です(生成するだけでここでは追加はしない)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function createSelectButtons(){ const $btns = []; for(let i=0; i<50; i++){ const $btn = document.createElement('button'); $btn.style.border = '0px #000 solid'; $btn.style.backgroundColor = 'transparent'; $btn.style.width = TILE_WIDTH + 'px'; $btn.style.height = TILE_HEIGHT + 'px'; $btn.style.position = 'absolute'; $btn.style.zIndex = '1'; $btns.push($btn); } return $btns; } |
問題の生成
問題を生成する処理を示します。言うまでもなく解法が存在する問題を出題しなければなりません。ただ困ったことに四川省の場合とちがってランダムに配置するだけでは解ける問題が生成される確率が低くなってしまうのです。
初期配置は以下のようになっています(春夏秋冬牌をいれるとややこしくなるので入れていません)。
これを三次元配列で表現すると以下のようになります。
初期配置を決めるときは同じ種類の牌をふたつずつセットしていきます。セットする場所は各行の外側から、そして見える部分からです。下の段は上の段にある牌が設置されないと設置されません。このようにしておけば設置していった順にとっていけば必ずゲームクリアすることができます。
initTiles関数で問題を生成するのですが、乱数を使った処理だとうまくできない場合があるので問題として成立しているかチェックしてチェックが通ったら牌選択用のボタンを設置してゲームを開始できるようにしています。
1 2 3 4 |
function createProblem(){ while(!initTiles()){} setSelectButtons(); } |
牌に番号をつけます。11~19は一萬から九萬、21~29は一筒から九筒、31~39は一索から九索、41~47は字牌(東南西北白発中)です。この値を乱数とセットにして配列 pairs に格納して乱数でソートしています。そしてペアになる牌を2つずつ生成して tiles に格納しています。
三次元配列を定義して牌の初期位置にあたる部分を undefined に、牌は存在しない部分を null で初期化します。そして三次元配列の各行を両外側から検索し、undefined の部分を探します。見つかったらそのなかから2
箇所ランダムに選んで同じ種類の牌をセットします。こうすることで解法が存在する問題が生成されます。ただときどき失敗するので最後に三次元配列のなかに undefined がないことを確認します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 |
function initTiles(){ const pairs = []; { const numbers = []; for(let i = 11; i <= 47; i++){ if(i % 10 == 0) continue; numbers.push(i); numbers.push(i); } numbers.forEach(_ => { pairs.push({value: _, random:Math.random()}); }); pairs.sort((a, b) => a.random - b.random); } // 同じ種類の牌を2つずつ生成して tiles に格納する const tiles = []; pairs.forEach(_ => { for(let i = 0; i < 2; i++){ const tile = new Tile(_.value); tiles.push(tile); } }); // 3次元配列 tiles3x3 を定義する tiles3x3 = []; for(let depth = 0; depth < DEPTH_COUNT; depth++){ const arr1 = []; for(let row = 0; row < ROW_COUNT; row++){ const arr2 = []; for(let col = 0; col < COL_COUNT; col++) arr2.push(null); arr1.push(arr2); } tiles3x3.push(arr1); } // 3次元配列 tiles3x3 の牌がある位置に undefined をセットする // 牌がない位置は null をセット let depth; depth = 0; for(let row = 0; row < ROW_COUNT; row++){ if(row != 3 && row != 4){ for(let col = 0; col < COL_COUNT; col++) tiles3x3[depth][row][col] = null; } else { for(let col = 0; col < COL_COUNT; col++){ if(col != 5 && col != 6) tiles3x3[depth][row][col] = null; else tiles3x3[depth][row][col] = undefined; } } } depth = 1; for(let row = 0; row < ROW_COUNT; row++){ if(row <= 1 || row >= 6){ for(let col = 0; col < COL_COUNT; col++) tiles3x3[depth][row][col] = null; } else { for(let col = 0; col < COL_COUNT; col++){ if(col <= 3 || col >= 8) tiles3x3[depth][row][col] = null; else tiles3x3[depth][row][col] = undefined; } } } depth = 2; for(let row = 0; row < ROW_COUNT; row++){ if(row == 0 || row == 7){ for(let col = 0; col < COL_COUNT; col++) tiles3x3[depth][row][col] = null; } else { for(let col = 0; col < COL_COUNT; col++){ if(col <= 2 || col >= 9) tiles3x3[depth][row][col] = null; else tiles3x3[depth][row][col] = undefined; } } } depth = 3; for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++) tiles3x3[depth][row][col] = undefined; } const rows1 = [0, 2, 5, 7]; rows1.forEach(_ => { tiles3x3[depth][_][0] = null; tiles3x3[depth][_][11] = null; }) const rows2 = [1, 6]; rows2.forEach(_ => { tiles3x3[depth][_][0] = null; tiles3x3[depth][_][1] = null; tiles3x3[depth][_][10] = null; tiles3x3[depth][_][11] = null; }) while(true){ const rets = getEdges(tiles3x3); // 三次元配列の各行の一番外側にある undefined を検索 if(rets.length < 2) break; // 0以上(rets.length - 1)以下の異なる2つの乱数を生成する const pair = getRandom2(rets.length - 1); const first = rets[pair.first]; const second = rets[pair.second]; // 取得された位置に2つの牌をセットする const tile1 = tiles.shift(); const tile2 = tiles.shift(); tile1.SetPosition(first.depth, first.row, first.col); tile2.SetPosition(second.depth, second.row, second.col); } // 生成された問題をチェックする(undefinedがなければOKなのだが・・・) for(let depth = 0; depth < DEPTH_COUNT; depth++){ for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ if(tiles3x3[depth][row][col] === undefined){ console.log('やりなおし'); return false; } } } } return true; } |
getEdges関数は三次元配列の各行を外から検索して最初にみつかったundefinedの位置のリストを返します。ただしその上の段もundefinedである場合はその位置に先に牌を配置しなければならないので無視します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
function getEdges(arr3x3){ const rets = []; for(let depth = 0; depth < DEPTH_COUNT; depth++){ for(let row = 0; row < ROW_COUNT; row++){ let left = -1; let right = -1; for(let col = 0; col < COL_COUNT; col++){ if(arr3x3[depth][row][col] === undefined){ if(depth == 0 || arr3x3[depth-1][row][col] !== undefined) left = col; break; } } for(let col = COL_COUNT - 1; col >= 0; col--){ if(arr3x3[depth][row][col] === undefined){ if(depth == 0 || arr3x3[depth-1][row][col] !== undefined) right = col; break; } } if(left != right){ if(left != -1) rets.push({depth:depth, row:row, col:left}); if(right != -1) rets.push({depth:depth, row:row, col:right}); } else { if(left != -1) rets.push({depth:depth, row:row, col:left}); } } } return rets; } |
getRandom2関数は 0以上でmax以下の異なる整数の乱数のペアを返します。返される整数はmaxも含みます。やっていることは2回乱数を生成して異なる整数であればそのままペアにして返し、同じものが生成された場合はその前後の整数をペアにして返しているだけです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function getRandom2(max){ if(max <= 0) return null; const r1 = Math.floor(Math.random() * (max + 1)); const r2 = Math.floor(Math.random() * (max + 1)); if(r1 != r2) return {first:Math.min(r1, r2), second:Math.max(r1, r2)}; else { if(r1 > 0) return {first:r1-1, second:r1}; else return {first:0, second:1}; } } |
牌の選択用のボタンを設置する
setSelectButtons関数は選択することができる牌が描画されている位置に見えないボタンを設置するためのものです。このボタンをクリックすることで牌を選択する処理がおこなわれるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function setSelectButtons(){ $selectButtons.forEach(_ => _.remove()); let idx = 0; for(let depth = DEPTH_COUNT - 1; depth >= 0; depth--){ for(let row = 0; row < ROW_COUNT; row++){ for(let col=0; col<COL_COUNT; col++){ const tile = tiles3x3[depth][row][col]; if(tile != null && tile.CanSelect()){ const $btn = $selectButtons[idx]; idx++; $field?.appendChild($btn); $btn.style.left = tile.Left + 'px'; $btn.style.top = tile.Top + 'px'; // ボタンがクリックされたときのイベントリスナを追加 $btn.onclick = () => onClick(depth, row, col); } } } } } |
ゲーム開始時の処理
ゲーム開始時におこなわれる処理は以下のとおりです。
問題の生成
selected = null とすることでどの牌も選択されていない状態にする
sugested = [] とすることでどの牌も提案されていない状態にする
やりなおし操作に関する情報をクリアする
isPlaying, isStalemate, isGiveuped の各フラグのクリア
プレイ開始時刻を現在の時刻とする
プレイヤー名に入力されている名前をローカルストレージに保存する
スタートボタンの非表示、ゲームをプレイするうえで必要な操作ボタンの表示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
$start.addEventListener('click', () => { selectSound.currentTime = 0; selectSound.play(); createProblem(); selected = null; sugested = []; undo = []; isPlaying = true; isStalemate = false; isGiveuped = false; startTime = Date.now(); const $playerName = document.getElementById('player-name'); localStorage.setItem('hatodemowakaru-player-name', $playerName.value); $startButtons.style.display = 'none'; $controlButtons.style.display = 'block'; }); |
牌が選択されたときの処理
牌が選択されたときにおこなわれる処理は以下のとおりです。
探すコマンドでペアの提案がされている場合は解除
ペアになる片方の牌がまだ選択されていないときは現在選択されたものを selected に格納する
ペアになる片方の牌がすでに選択されている現在選択されたものと同じ種類の牌か調べる
同じ種類であれば消去し、異なる牌であれば警告音を鳴らす。選択状態にある牌も選択解除する
牌を消す処理は消える牌のある位置の tiles3x3[depth][row][col] を null にするともに selected = null にして選択状態を解除するのですが、その前にあとで元に戻せるように undo に tiles3x3[depth][row][col] を格納しておきます。そして牌が消えることで選択可能な牌も変わるので setSelectButtons関数を実行してボタンの位置を変更します。
また牌が消えたときにゲームクリアになっているかもしれないので確認の処理をおこないます。ゲームクリアでない場合はつぎに取ることができる牌のペアがあるか調べます。存在しない場合は手詰まりなので効果音を鳴らしてユーザーに知らせます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
function onClick(depth, row, col){ if(!isPlaying){ ngSound.currentTime = 0; ngSound.play(); return; } sugested = []; if(selected == null){ selected = tiles3x3[depth][row][col]; selectSound.currentTime = 0; selectSound.play(); } else { if(selected != tiles3x3[depth][row][col] && selected.Number == tiles3x3[depth][row][col].Number){ undo.push(tiles3x3[selected.Depth][selected.Row][selected.Col]); undo.push(tiles3x3[depth][row][col]); tiles3x3[selected.Depth][selected.Row][selected.Col] = null; tiles3x3[depth][row][col] = null; selected = null; setSelectButtons(); selectSound.currentTime = 0; selectSound.play(); if(!checkClear()){ const rets = search(); if(rets == null){ isStalemate = true; stalemateSound.currentTime = 0; stalemateSound.play(); } } } else { selected = null; ngSound.currentTime = 0; ngSound.play(); } } } |
ゲームクリア判定
ゲームクリアかどうかを調べる処理を示します。
3次元配列 tiles3x3 がすべてnullならすべての牌が取り除かれたことになるのでゲームクリアです。この場合はXへ結果をポストするボタンを表示させるとともに効果音の再生、isPlayingフラグのクリア、操作ボタンの非表示、スタートボタンの再表示をおこないます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
function checkClear(){ for(let depth = 0; depth < DEPTH_COUNT; depth++){ for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ if(tiles3x3[depth][row][col] != null) return false; } } } document.getElementById('tw-button').onclick = () => { let text = '鳩でもわかる上海麻雀 みんなで挑戦しよう!'; if(playMilliseconds > 0) text = `鳩でもわかる上海麻雀 記録 ${getTimeText()} みんなで挑戦しよう!`; const hashtags = '鳩でもわかる上海麻雀'; const url = encodeURIComponent(location.href); // URLを生成して遷移 window.open('https://twitter.com/intent/tweet?text=' + text + '&hashtags=' + hashtags + '&url=' + url); } setTimeout(() => { gameclearSound.currentTime = 0; gameclearSound.play(); $startButtons.style.display = 'block'; }, 500); isPlaying = false; $controlButtons.style.display = 'none'; return true; } |
手詰まり判定
つぎに取ることができる牌のペアがあるか調べる処理を示します。
Mapに選択することができる牌を牌の番号をキーとして格納していき、同じキーが存在する場合はペアができるということなのでそのTileオブジェクトのペアを返します。存在しない場合はnullを返します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function search(){ const tiles = []; for(let depth = 0; depth < DEPTH_COUNT; depth++){ for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ const tile = tiles3x3[depth][row][col]; if(tile != null && tile.CanSelect()) tiles.push(tile); } } } const map = new Map(); for(let i = 0; i < tiles.length; i++){ const tile = tiles[i]; const num = tiles[i].Number; if(map.has(num)) return {first:map.get(num), second:tiles[i]}; map.set(num, tiles[i]); } return null; } |
キャンセルボタンがクリックされたときの処理
キャンセルボタンがクリックされたときは牌が選択状態にあるときや探すコマンドによって牌のペアが提案されているときはこれらを解除します。
そうではない場合は undo.length が 2 以上のときは消した牌を元に戻す処理をおこないます。undoの最後尾をふたつ取り出し、Tile.Undo関数を実行して、元の位置に戻します。そして選択可能な牌の位置が変わるので setSelectButtons関数を実行します。また手詰まりになっていた場合もこの操作で手詰まり状態が解除になるので isStalemate = false とします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
$cancel.addEventListener('click', () => { if(!isPlaying) return; if(selected != null || sugested.length > 0){ selected = null; sugested = []; selectSound.currentTime = 0; selectSound.play(); } else { if(undo.length < 2){ ngSound.currentTime = 0; ngSound.play(); return; } const tile1 = undo.pop(); const tile2 = undo.pop(); tile1.Undo(); tile2.Undo(); setSelectButtons(); isStalemate = false; selectSound.currentTime = 0; selectSound.play(); } }); |
選択できない牌や牌がない部分をクリックした場合は警告音を鳴らすとともに、選択状態にあった牌を非選択の状態に変更します。
1 2 3 4 5 6 |
$canvas.onclick = () => { selected = null; sugested = []; ngSound.currentTime = 0; ngSound.play(); } |
探すコマンドを選択したときの処理
探すコマンドを選択したときは消すことができる牌のペアを探して最初に見つかったものを sugested 配列に格納して強調表示させます。そして isGiveuped フラグを true に変更します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
$search.addEventListener('click', () => { if(!isPlaying) return; selectSound.currentTime = 0; selectSound.play(); const rets = search(); if(rets == null) return; sugested[0] = rets.first; sugested[1] = rets.second; isGiveuped = true; }); |
更新と描画処理
更新処理を示します。
更新のたびにプレイ開始時刻からの経過時間を計算して playMilliseconds に格納します。また上部の説明文をゆっくり左にスライドさせます(howToPlayLeft を変化させることで文章の表示開始座標が変わる)。そのあとdraw関数で牌の描画処理をおこないます。
1 2 3 4 5 6 7 8 9 10 |
function update(){ requestAnimationFrame(() => update()); if(isPlaying) playMilliseconds = Date.now() - startTime; howToPlayLeft--; if(howToPlayLeft < -2300) howToPlayLeft = 500; draw(); } |
描画処理
描画処理は三次元配列 tiles3x3 を調べて nullでなければそのオブジェクトを描画します。このとき上の段にある牌をあとから描画したいので depth は大きい順に調べています。そのあとプレイ開始からの経過時間を描画します。このとき手詰まりならその旨も描画します。また探すコマンドを選択したあとは赤字で経過時間が描画されるようにしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
function draw(){ ctx.fillStyle = '#008b8b'; ctx?.fillRect(0, 0, $canvas.width, $canvas.height); for(let depth = DEPTH_COUNT - 1; depth >= 0; depth--){ for(let row = 0; row < ROW_COUNT; row++){ for(let col = 0; col < COL_COUNT; col++){ const tile = tiles3x3[depth][row][col]; if(tile != null) tile.Draw(); } } } ctx.font = '20px Arial'; ctx.textBaseline = 'top'; if(!isGiveuped) ctx.fillStyle = '#fff'; else ctx.fillStyle = '#f00'; ctx.fillText(getTimeText(),10,10); if(isStalemate){ ctx.fillStyle = '#f00'; ctx.fillText('手詰まりです',200,10); } $howToPlay.style.marginLeft = `${howToPlayLeft}px`; } |
getTimeText関数はミリ秒を◯分◯秒◯の形式で文字列に変換します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function getTimeText(){ const ms = playMilliseconds % 1000; const totalsec = Math.floor(playMilliseconds / 1000); const sec = totalsec % 60; const min = Math.floor(totalsec / 60); let msText = ms.toString(); if(ms < 10) msText = '00' + msText; else if(ms < 100) msText = '0' + msText; let secText = sec.toString(); if(sec < 10) secText = '0' + secText; return `${min} 分 ${secText} 秒 ${msText}`; } |