まとめてダウンロードするならこちらから。
Githubにも上げてありますが、
https://github.com/kmaebashi/jstetris
別段動かしたいわけでもないけれど、ソースをざっと眺めたい、という人は以下を参照してください。
生のES6で記述しています。jQueryとかReact.jsとかVue.jsとか一切使っていません。 画面描画にはCanvasを使用しています。
ここのテトリス風ゲームのプログラムは、検索で上位に出るせいか、割と多くの方に読んでいただいているようです。
せっかくなので、このページの説明を大幅に書き足しました。参考にしてください。
ライセンスについても追記しました。このプログラムはコピー、改変自由です。
なお、ゲームのプログラム自体書いたことがないとか、そもそもJavaScriptを書いたことがないとか、プログラミングは初めてだ、という方には、このページはちょっと難易度が高いのではないかと思います。難しいと思う方は、以下のページから始めることをおすすめします。
ゲームのステージになるCanvasと、Next表示用のCanvasを配置しています。
39~40行目がゲーム開始の起点となりますので、ここからtetris.jsを読んでいくとよいかと。
./tetris.html
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>テトリス風ゲーム</title> <meta name="viewport" content="width=device-width,initial-scale=1"> <link rel="stylesheet" type="text/css" href="css/tetris.css"> <script type="text/javascript" src="js/tetris.js"></script> </head> <body> <h1>テトリス風ゲーム</h1> <div class="wrapper-container"> <span class="tetris-container"> <canvas id="stage" width="250px" height="500px" style="background-color:black;"> </canvas> <span class="tetris-panel-container"> <p>Next:</p> <canvas id="next" width="150px" height="150px" style="background-color:black;"> </canvas> <p>LINES:<span id="lines">0</span></p> <p><span id="message"></span></p> <div class="tetris-panel-container-padding"></div> <table class="tetris-button-panel"> <tr> <td></td> <td id="tetris-rotate-button" class="tetris-button">↻</td> <td></td> </tr> <tr> <td id="tetris-move-left-button"class="tetris-button">←</td> <td id="tetris-fall-button"class="tetris-button">↓</td> <td id="tetris-move-right-button"class="tetris-button">→</td> </tr> </table> </span> </span> </div> <script> var tetris = new Tetris(); tetris.startGame(); </script> <hr/> <a href="./index.html">ひとつ上のページへ戻る</a> | <a href="../../index.html">トップページへ戻る</a> </body> </html>
CSSです。Flexboxを初めて使った。
./css/tetris.css
html { touch-action: manipulation; } .wrapper-container { display: inline-block; } .tetris-container { display: flex; flex-direction: row; margin: 10px; background-color: #333333; } .tetris-panel-container { display: flex; padding-left: 10px; padding-right: 10px; flex-direction: column; color: white; background-color: #333333; } .tetris-panel-container-padding { flex-grow: 1; } .tetris-panel-container p { margin: 0; padding: 0; } .tetris-button-panel { border-style: none; width: 100%; } .tetris-button { padding-top: 10px; padding-bottom: 10px; text-align: center; background: #444444; }
HTMLとCSSを合わせて見ると、下図のような構造になっていることがわかります(赤枠がFlexbox指定のdiv)。
これが本体です。
ソースを読むにあたり気を付けるところがあるとすれば、 ブロックの形状は色を含め41行目からのcreateBlocks()で定義していること(これの中身は後述します)、 および仮想画面virtualStageが現在のステージの状態を示していますが、 この2次元配列に入るのは最後まで落ち切ってfixBlock()が呼ばれたブロックのみである、 ということぐらいでしょうか。
./js/tetris.js
class Tetris { constructor() { this.stageWidth = 10; this.stageHeight = 20; this.stageCanvas = document.getElementById("stage"); this.nextCanvas = document.getElementById("next"); let cellWidth = this.stageCanvas.width / this.stageWidth; let cellHeight = this.stageCanvas.height / this.stageHeight; this.cellSize = cellWidth < cellHeight ? cellWidth : cellHeight; this.stageLeftPadding = (this.stageCanvas.width - this.cellSize * this.stageWidth) / 2; this.stageTopPadding = (this.stageCanvas.height - this.cellSize * this.stageHeight) / 2; this.blocks = this.createBlocks(); this.deletedLines = 0; window.onkeydown = (e) => { if (e.keyCode === 37) { this.moveLeft(); } else if (e.keyCode === 38) { this.rotate(); } else if (e.keyCode === 39) { this.moveRight(); } else if (e.keyCode === 40) { this.fall(); } } document.getElementById("tetris-move-left-button").onmousedown = (e) => { this.moveLeft(); } document.getElementById("tetris-rotate-button").onmousedown = (e) => { this.rotate(); } document.getElementById("tetris-move-right-button").onmousedown = (e) => { this.moveRight(); } document.getElementById("tetris-fall-button").onmousedown = (e) => { this.fall(); } } createBlocks() { let blocks = [ { shape: [[[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]], [[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]]], color: "rgb(0, 255, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 128)" }, { shape: [[[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]], [[0, 0], [1, 0], [0, 1], [1, 1]]], color: "rgb(255, 255, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 128, 0)" }, { shape: [[[0, 0], [1, 0], [-1, 1], [0, 1]], [[-1, -1], [-1, 0], [0, 0], [0, 1]], [[0, 0], [1, 0], [-1, 1], [0, 1]], [[-1, -1], [-1, 0], [0, 0], [0, 1]]], color: "rgb(0, 255, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 0)" }, { shape: [[[-1, 0], [0, 0], [0, 1], [1, 1]], [[0, -1], [-1, 0], [0, 0], [-1, 1]], [[-1, 0], [0, 0], [0, 1], [1, 1]], [[0, -1], [-1, 0], [0, 0], [-1, 1]]], color: "rgb(255, 0, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 0, 0)" }, { shape: [[[-1, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [1, -1], [0, 0], [0, 1]], [[-1, 0], [0, 0], [1, 0], [1, 1]], [[0, -1], [0, 0], [-1, 1], [0, 1]]], color: "rgb(0, 0, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 0, 128)" }, { shape: [[[1, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [0, 0], [0, 1], [1, 1]], [[-1, 0], [0, 0], [1, 0], [-1, 1]], [[-1, -1], [0, -1], [0, 0], [0, 1]]], color: "rgb(255, 165, 0)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 82, 0)" }, { shape: [[[0, -1], [-1, 0], [0, 0], [1, 0]], [[0, -1], [0, 0], [1, 0], [0, 1]], [[-1, 0], [0, 0], [1, 0], [0, 1]], [[0, -1], [-1, 0], [0, 0], [0, 1]]], color: "rgb(255, 0, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(128, 0, 128)" } ]; return blocks; } drawBlock(x, y, type, angle, canvas) { let context = canvas.getContext("2d"); let block = this.blocks[type]; for (let i = 0; i < block.shape[angle].length; i++) { this.drawCell(context, x + (block.shape[angle][i][0] * this.cellSize), y + (block.shape[angle][i][1] * this.cellSize), this.cellSize, type); } } drawCell(context, cellX, cellY, cellSize, type) { let block = this.blocks[type]; let adjustedX = cellX + 0.5; let adjustedY = cellY + 0.5; let adjustedSize = cellSize - 1; context.fillStyle = block.color; context.fillRect(adjustedX, adjustedY, adjustedSize, adjustedSize); context.strokeStyle = block.highlight; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX, adjustedY); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); context.strokeStyle = block.shadow; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); } startGame() { let virtualStage = new Array(this.stageWidth); for (let i = 0; i < this.stageWidth; i++) { virtualStage[i] = new Array(this.stageHeight).fill(null); } this.virtualStage = virtualStage; this.currentBlock = null; this.nextBlock = this.getRandomBlock(); this.mainLoop(); } mainLoop() { if (this.currentBlock == null) { if (!this.createNewBlock()) { return; } } else { this.fallBlock(); } this.drawStage(); if (this.currentBlock != null) { this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } setTimeout(this.mainLoop.bind(this), 500); } createNewBlock() { this.currentBlock = this.nextBlock; this.nextBlock = this.getRandomBlock(); this.blockX = Math.floor(this.stageWidth / 2 - 2); this.blockY = 0; this.blockAngle = 0; this.drawNextBlock(); if (!this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, this.blockAngle)) { let messageElem = document.getElementById("message"); messageElem.innerText = "GAME OVER"; return false; } return true; } drawNextBlock() { this.clear(this.nextCanvas); this.drawBlock(this.cellSize * 2, this.cellSize, this.nextBlock, 0, this.nextCanvas); } getRandomBlock() { return Math.floor(Math.random() * 7); } fallBlock() { if (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) { this.blockY++; } else { this.fixBlock(this.blockX, this.blockY, this.currentBlock, this.blockAngle); this.currentBlock = null; } } checkBlockMove(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellX < 0 || cellX > this.stageWidth - 1) { return false; } if (cellY > this.stageHeight - 1) { return false; } if (this.virtualStage[cellX][cellY] != null) { return false; } } return true; } fixBlock(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellY >= 0) { this.virtualStage[cellX][cellY] = type; } } for (let y = this.stageHeight - 1; y >= 0; ) { let filled = true; for (let x = 0; x < this.stageWidth; x++) { if (this.virtualStage[x][y] == null) { filled = false; break; } } if (filled) { for (let y2 = y; y2 > 0; y2--) { for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][y2] = this.virtualStage[x][y2 - 1]; } } for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][0] = null; } let linesElem = document.getElementById("lines"); this.deletedLines++; linesElem.innerText = "" + this.deletedLines; } else { y--; } } } drawStage() { this.clear(this.stageCanvas); let context = this.stageCanvas.getContext("2d"); for (let x = 0; x < this.virtualStage.length; x++) { for (let y = 0; y < this.virtualStage[x].length; y++) { if (this.virtualStage[x][y] != null) { this.drawCell(context, this.stageLeftPadding + (x * this.cellSize), this.stageTopPadding + (y * this.cellSize), this.cellSize, this.virtualStage[x][y]); } } } } moveLeft() { if (this.checkBlockMove(this.blockX - 1, this.blockY, this.currentBlock, this.blockAngle)) { this.blockX--; this.refreshStage(); } } moveRight() { if (this.checkBlockMove(this.blockX + 1, this.blockY, this.currentBlock, this.blockAngle)) { this.blockX++; this.refreshStage(); } } rotate() { let newAngle; if (this.blockAngle < 3) { newAngle = this.blockAngle + 1; } else { newAngle = 0; } if (this.checkBlockMove(this.blockX, this.blockY, this.currentBlock, newAngle)) { this.blockAngle = newAngle; this.refreshStage(); } } fall() { while (this.checkBlockMove(this.blockX, this.blockY + 1, this.currentBlock, this.blockAngle)) { this.blockY++; this.refreshStage(); } } refreshStage() { this.clear(this.stageCanvas); this.drawStage(); this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } clear(canvas) { let context = canvas.getContext("2d"); context.fillStyle = "rgb(0, 0, 0)"; context.fillRect(0, 0, canvas.width, canvas.height); } }
以下、プログラムの説明です。
2行目からのコンストラクタは各種インスタンス変数の設定と、キーイベントの登録、ボタンイベントの登録をしています。
ここで、cellSizeとかstageLeftPaddingとかstageTopPaddingとかの設定をしていますが、これは、tetris.htmlで設定したcanvasの幅と高さに合わせて、テトリスの盤面を構成するセル(横に10セル、縦に20セル)の大きさと位置を調整する作業を行っています。セルは正方形でなければならず、「横に10セル、縦に20セル」というのも固定ですから、tetris.htmlでcanvasに指定した幅と高さが1:2になっていない場合、隙間ができるので、中央に寄せます。まあ実際にはcanvasの幅と高さを1:2からわざわざ外すことはないでしょうから、普通はstageLeftPaddingとstageTopPaddingは0になります。
コンストラクタからcreateBlocks()を呼んでいますが、ここではテトリスの7種類のブロック※1の形状と色のデータを定義しています。
おそらくですが、テトリスのブロックの形状の定義といえば、たとえばこんな感じの4×4の2次元配列が登場するのでは、と思った人がいるのではないでしょうか。
[[ 0, 1, 0, 0], [ 1, 1, 1, 0], [ 0, 0, 0, 0], [ 0, 0, 0, 0]]
これは、このブロックの形状を定義している、というわけです。
こんな感じで、4×4の2次元配列(「棒」のブロックは4セルの長さなので、4×4の2次元配列が必要です)でブロックの形状を定義すれば、回転した状態はプログラムで作り出せそうに思えます。
ただ、4×4の配列なら、プログラムでぐるぐる回転させることは可能ですが、上のようなT字型のブロックでは、下図の水色の印をつけたセルを中心に回ってほしいと私は思います(正式なルールは調べてません)。
また、「棒」のブロックは、このプログラムでは以下のように回転します。キーを2回叩いて同じ状態に戻るようにしたかったので(こっちも正式なルールは調べてません)。
こうなると、プログラムで回すのは難しそうです。
そこで、このプログラムでは、回転の4パターンはすべて別に持つようにしたうえで、「何となく真ん中の位置からの相対座標で4つのセルの場所を持つ」という形式にしました。たとえばcreateBlocks()の冒頭はこんな感じになっていますが、
createBlocks() { let blocks = [ { shape: [[[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]], [[-1, 0], [0, 0], [1, 0], [2, 0]], [[0, -1], [0, 0], [0, 1], [0, 2]]], color: "rgb(0, 255, 255)", highlight: "rgb(255, 255, 255)", shadow: "rgb(0, 128, 128)" },
この配列blocksが7種類のブロックを表していて、先頭のものは「棒」です。x, yの相対位置を示す配列が4つ並んでひとつのブロックを、さらにそれが4つ並んで回転の各状態を表しています。
この配列の先頭の行(44行目)のそれぞれの値は、下図のような「何となく真ん中の位置」のセル(赤いセル)からの相対座標です。
回転の4パターンは全部別に保持しているので、「何となく真ん中の位置」のセルは別に回転の中心軸ではありません。
このプログラムにおいて、現在落下中のブロックの座標はthis.blockX, this.blockYで保持していますが、その座標は、この「何となく真ん中の位置」のセル(上図の赤いセル)の位置を示しています。
その他、現在落下中のブロックを示す変数には以下のものがあります。
続きを見ていきます。110行目からはひとつのブロックを描画するdrawBlock()メソッド、122行目からは、ひとつのセルを描画するdrawCell()メソッドです。上で挙げたデータ構造がわかっていれば、drawBlock()は素直に読めるのではないでしょうか。
drawBlock(x, y, type, angle, canvas) { let context = canvas.getContext("2d"); let block = this.blocks[type]; for (let i = 0; i < block.shape[angle].length; i++) { this.drawCell(context, x + (block.shape[angle][i][0] * this.cellSize), y + (block.shape[angle][i][1] * this.cellSize), this.cellSize, type); } }
this.blocks[type]で、typeで示す種類のブロックのデータが取れます(112行目)。その中のshape[angle]が、angleで示す回転状態の形状データです。その中身は、x, yで示す位置からの相対座標なので、4セル分、ループで回して(113行目)、drawCell()を呼び出します。1点、注意しなければいけないのは、this.blockX, this.blockYは盤面の中のセルで数える座標(よって横は0~9、縦は0~19の整数値)であるのに対し、drawBlock()の引数のx, yは、実際にJavaScriptで描画する際のcanvasの座標であることです。これは、NEXT欄のブロック描画とメソッドを統一するためだったかと思います。
drawCell()は、指定した場所に「セル」を描画します。
drawCell(context, cellX, cellY, cellSize, type) { let block = this.blocks[type]; let adjustedX = cellX + 0.5; let adjustedY = cellY + 0.5; let adjustedSize = cellSize - 1; context.fillStyle = block.color; context.fillRect(adjustedX, adjustedY, adjustedSize, adjustedSize); context.strokeStyle = block.highlight; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX, adjustedY); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); context.strokeStyle = block.shadow; context.beginPath(); context.moveTo(adjustedX, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY + adjustedSize); context.lineTo(adjustedX + adjustedSize, adjustedY); context.stroke(); }
ここで、受け取った座標cellX, cellYにわざわざ0.5を足してadjustedX, adjustedYを作っているのは、JavaScriptで整数の座標で線を引くと、アンチエイリアスという機能により、たとえば黒字に真っ白の線を引いても灰色になってしまうためです。アンチエイリアス含め、JavaScriptで絵を描くことに関する詳細はこちらのページを参照してください。
drawCell()では、fillRect()で正方形を描くだけでなく、左と上のハイライト部分、右と下の影部分についても、線を引くことで描画しています。
その続き、143行目からのstartGame()が、ゲームの開始です。tetris.htmlで以下のようにして呼び出しています。
<script> var tetris = new Tetris(); tetris.startGame(); </script>
startGame()の中身はこちら
startGame() { let virtualStage = new Array(this.stageWidth); for (let i = 0; i < this.stageWidth; i++) { virtualStage[i] = new Array(this.stageHeight).fill(null); } this.virtualStage = virtualStage; this.currentBlock = null; this.nextBlock = this.getRandomBlock(); this.mainLoop(); }
startGame()では、まず現在の盤面を表す仮想盤面の2次元配列virtualStageを作っています。ゲーム開始時なので、最初はすべてnullにします。
その上で、getRandomBlock()メソッド(中身を見ればわかりますが、これは単に0~6の乱数を返すだけです)で「次のブロック」を設定し、最後にthis.mainLoop()を呼び出しています。
mainLoop() { if (this.currentBlock == null) { if (!this.createNewBlock()) { return; } } else { this.fallBlock(); } this.drawStage(); if (this.currentBlock != null) { this.drawBlock(this.stageLeftPadding + this.blockX * this.cellSize, this.stageTopPadding + this.blockY * this.cellSize, this.currentBlock, this.blockAngle, this.stageCanvas); } setTimeout(this.mainLoop.bind(this), 500); }
このmainLoop()メソッドが、ゲームのメインループ、つまり、ゲーム開始からGAME OVERになるまでぐるぐる回り続けるループになります――が、その名に反して、中身はforとかwhileによるループにはなっていません。これはブラウザで動かすJavaScriptの「ブラウザに処理を戻さないと描画も何も行われない」という性質によるもので、forとかでループにする代わりに、最後(168行目)のsetTimeout()で、自分自身を500ミリ秒後に呼び出すように設定しています。その後、mainLoop()メソッドの処理は終了するわけですが、500ミリ秒後にはブラウザが呼び出してくれるので、実質的にループになります。なぜforループではだめなのか、ということについてはこちらのページを参照してください。
また、setTimeout()にmainLoop()メソッドを設定する際、なぜ単純に「setTimeout(this.mainLoop, 500);」と書くのではだめで、bind()とかいう妙なメソッドを呼び出さなければいけないのかといえば、JavaScriptにおけるメソッドは、自分自身が属するオブジェクトを知っているわけではないためです。よって、単にmainLoop()メソッドを登録するだけでは、そこから元のthisが辿れないため、this.currentBlockとかを参照できません。JavaScriptにおけるthisは、メソッドの呼び出し時にピリオドの左側に置いたオブジェクトであって、bind()は、「引数で受け取ったオブジェクトを左に置いてこのメソッドを呼び出す関数を返すメソッド」です。その関数をsetTimeout()に登録することで、this.mainLoop()の呼び出し相当の処理が実現できます。
mainLoop()は、ソースを見てわかる通り、以下のような流れになっています。
ソースを読んでいけばわかりますが、それぞれ下位のメソッドでは以下のことをしています。
ここから更に下位のメソッドのうち重要なものの中身を見ていきます。まずはcheckBlockMove()から。
checkBlockMove(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellX < 0 || cellX > this.stageWidth - 1) { return false; } if (cellY > this.stageHeight - 1) { return false; } if (this.virtualStage[cellX][cellY] != null) { return false; } } return true; }
checkBlockMove()は、指定した場所にブロックが置けるかどうかを判定するメソッドです。置ければtrueを、既存のブロックや壁や床とぶつかって置けなかったらfalseを返します。このメソッドは、以下の箇所で使われています。
次はfixBlock()です。
fixBlock(x, y, type, angle) { for (let i = 0; i < this.blocks[type].shape[angle].length; i++) { let cellX = x + this.blocks[type].shape[angle][i][0]; let cellY = y + this.blocks[type].shape[angle][i][1]; if (cellY >= 0) { this.virtualStage[cellX][cellY] = type; } } for (let y = this.stageHeight - 1; y >= 0; ) { let filled = true; for (let x = 0; x < this.stageWidth; x++) { if (this.virtualStage[x][y] == null) { filled = false; break; } } if (filled) { for (let y2 = y; y2 > 0; y2--) { for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][y2] = this.virtualStage[x][y2 - 1]; } } for (let x = 0; x < this.stageWidth; x++) { this.virtualStage[x][0] = null; } let linesElem = document.getElementById("lines"); this.deletedLines++; linesElem.innerText = "" + this.deletedLines; } else { y--; } } }
このメソッドは、ブロックが最後まで落下した時に(fallBlock()の中から)呼び出されるメソッドで、以下のことを行っています。
これぐらいの説明があれば、プログラム全体を見通すことができるのではないでしょうか。
このテトリスのプログラムのライセンスは、NYSL Version 0.9982とします。作者は一切の著作権を主張しませんので、商用、非商用を問わず、自分のWebページに転載するなり改造するなり煮るなり焼くなり好きにしてください。参照元表記も私への連絡も不要です(参照元表記や連絡をするなと言っているのではありません。連絡いただければ私は喜びます)。 自分が作ったと言って友達に自慢するのも自由ですが、ばれて恥をかいたとしても私は責任を取りません。
ここで公開しているプログラムの不具合、または説明にウソ、間違い、誤解を招く表現等ありましたら、ぜひ掲示板の方にご連絡願います。
大幅に追記: 2024/08/13
初回公開日: 2019/05/19