1. はじめに
イーサリアムネットワークのスマートコントラクトを使ってゲームを作成します。ほとんど実用性のないゲームです。ゲームとしての面白みは期待しないでください。
1.1 用意するもの
- PaizaCloudのアカウント
1.2 ゲーム内容
- 50×50マス上に艦を配置し、ミサイルで撃ち合うゲーム
- 艦の移動やミサイルの発射、当たり判定などの処理をスマートコントラクトとして実現する
- 出撃時に艦は10コインを持って出撃する
- ミサイルが当たるとダメージとして1コイン減額される
- ミサイルを当てた側には褒賞として1コイン増額される
- 艦のコインがゼロになると艦は消滅する
- 艦は任意のタイミングで引き上げることができる
- 引き上げる際にその艦のコイン数が所有者に戻される
1.3 参考URL
2. 環境構築
2.1 PaizaCloudの起動
いつものようにPaizaCloudを使って環境構築を行います。
2.2 サーバー作成
Node.jsを選択します。なお、ここで指定するサーバ名は、後ほど利用しますのでシンプルな名前にしておきましょう。
2.3 アプリ構築
サーバが構築できたら、ターミナルを開いてアプリをクローンします。
git clone https://github.com/nstshirotays/dappgame.git
続いてアプリのセットアップを行い、起動します。
cd dappgame
npm install
npm start
3. アプリ実行
3.1 MetaMask
別ブラウザもしくは別タブで下記にアクセスします。
-
https://XXXXXX.paiza-user-free.cloud:3000/
XXXXXXには起動時に指定したサーバ名が入ります
ボタンを押してメタマスクをインストールしてください。
インストール後は指示に従ってアカウントを作成してください。
- 右側のウォレットの作成を選択します
- 続いて品質向上への協力依頼が表示されます
- パスワード作成画面でパスワードを設定してください
- ウォレットの保護についての動画になります
- 秘密のバックアップフレーズ画面になります。鍵マークをクリックして内容をどこかに保存します。
- 秘密のバックアップフレーズの確認画面となりますので、保存した内容をみながら選択していきます。
- 確認が終わるとアカウントが作成されます。
次へをクリックします
これでアカウントは作成されました。
3.2 Ropstenへの接続
テストネットワークであるRopstenへ接続します。
- 右上の「イーサリアムメインネット」と書かれている部分をクリックしてRopstenテストネットワークを選択します。
- 画面中央にあるAccount1と書かれている部分をクリックしてアカウントをコピーします。、
- 別タブから下記のサイトにアクセスします。
- 先ほどコピーした自分のアカウントを張り付けてSend me test Etherボタンを押します。
- 数分の後にアカウントに0.3eth入金されます
うまくいかない場合は下記のサイトも試してみて下さい。なおサイトは独自にアクセス制限をしていますので、アクセスは一日1回となります。
3.3 ゲーム画面
無事にアカウントに入金されたら、元のゲーム画面に戻ってください。Metamaskがアプリと接続し、下記のような画面になると思います。
- 右上のGetPiyoPiyoを押してPiyoコインを手に入れてください。0.1Eth+ガス代となります。
- 0.1Ethで30Piyoコイン手に入ります。
- 出撃ボタンを押すと10ピヨピヨをライフ値とした戦艦が画面に表示されます。
- 左に自機や敵機の一覧が表示されます。その際アカウントの先頭二文字をつかって、ニックネームと基本色が設定されます。
- 後は、敵をかわしつつ、〇ボタンでミサイルを発射できます。
- Ropstenネットワークでは1ターンに数分かかるので、実用的な遊びは難しいと思います。
4.解説
4.1 ゲーム解説
- 50×50マス上に艦を配置し、ミサイルで撃ち合うゲーム
- 艦の移動やミサイルの発射、当たり判定などの処理をスマートコントラクトとして実現する
- 出撃時に艦は10コインを持って出撃する
- ミサイルが当たるとダメージとして1コイン減額される
- ミサイルを当てた側には褒賞として1コイン増額される
- 艦のコインがゼロになると艦は消滅する
- 艦は任意のタイミングで引き上げることができる
- 引き上げる際にその艦のコイン数が所有者に戻される
4.2 ゲーム設定
(1) マップ構成
(2) 艦(ship)
- 移動量は1回1マス
- 艦幅は中心点から+2マス
- 4方向に移動可能(上:8、下:2、右:6、左:4)
- 移動方向に他艦があれば移動しない
- 移動方向がマップ外であれば移動しない
(3) ミサイル(missile)
- ミサイル発射時の艦の方向がミサイルの発射方向となる
- 移動量は3マス
- ミサイルの大きさは1マス分
- マップ外で消滅する
- ミサイルの当たり判定は艦の中心座標+艦幅とミサイル座標が重なったとき
4.3 Solidity解説
下記のリンクを押してRemix-IDEを起動してください
二つのファイルから構成されています。
No | ファイル名 | 概要 |
---|---|---|
1 | PiyoPiyoCoin.sol | Piyoコインの発行と管理 |
2 | GameCenter.sol | 本ゲームのコントラクト |
4.3.1 PiyoPiyoCoin.sol
このコントラクトでは、ゲーム通貨としてPiyoPiyoコインの発行と管理を行う。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract PiyoCoin is ERC20 {
address public minter;
event MinterChanged(address indexed from, address to);
constructor() payable ERC20("PiyoPiyoCoin", "PIYO") {
minter = msg.sender; //only initially
}
function passMinterRole(address GameCenter) public returns (bool) {
require(msg.sender==minter, 'Error, only owner can change pass minter role');
minter = GameCenter;
emit MinterChanged(msg.sender, GameCenter);
return true;
}
function mint(address account, uint256 amount) public {
require(msg.sender==minter, 'Error, msg.sender does not have minter role'); //GameCenter
_mint(account, amount);
}
}
(1) 変数解説
No | 変数名 | 型 | 公開 | 内容 |
---|---|---|---|---|
1 | minter | address | public | 通貨発行を許可するアドレス |
(2) 登録イベント
No | イベント名 | 出力値 | 概要 |
---|---|---|---|
1 | MinterChanged | 依頼者アドレス、付与先アドレス | 通貨発行権の付与 |
(3) 関数説明
passMinterRole
function passMinterRole(address GameCenter) public returns (bool) {
require(msg.sender==minter, 'Error, only owner can change pass minter role');
minter = GameCenter;
emit MinterChanged(msg.sender, GameCenter);
return true;
}
引数GameCenterにゲームセンターのアドレスを渡すと、通貨発行者としてそのアドレスを変数minterに格納する。
イベントログを出力し、trueを返す。
mint
function mint(address account, uint256 amount) public {
require(msg.sender==minter, 'Error, msg.sender does not have minter role'); //GameCenter
_mint(account, amount);
}
通貨を発行する処理。ただし、minterとして登録されているアドレス以外からのリクエストはエラーにしている。
_mintはimportで読み込んでいるERC20.solが呼び出され、指定したamountがaccountに付与される。
4.3.2 GameCenter.sol
(1) 変数解説
Constant
No | 定数名 | 型 | 値 | 内容 |
---|---|---|---|---|
1 | MAP_SIZE | uint | 50 | マップの幅 |
2 | SHIP_WIDTH | uint | 2 | 艦のはみだし量 |
Missile構造体
No | 内部変数 | 型 | 内容 |
---|---|---|---|
1 | owner | address | 所有者アドレス |
2 | direction | uint | 方向(上:8、下:2、右:6、左:4) |
3 | x | uint | 現在のx座標 |
4 | y | uint | 現在のy座標 |
5 | live | bool | true:生 false:死 |
Ship構造体
No | 内部変数 | 型 | 内容 |
---|---|---|---|
1 | owner | address | 所有者アドレス |
2 | direction | uint | 方向(上:8、下:2、右:6、左:4) |
3 | x | uint | 現在のx座標 |
4 | y | uint | 現在のy座標 |
5 | live | bool | true:生 false:死 |
変数
No | 変数名 | 型 | 公開 | 内容 |
---|---|---|---|---|
1 | ships[ ] | Ship構造体 | public | ゲーム参加艦の一覧 |
2 | missiles[ ] | Missile構造体 | public | ミサイルの一覧 |
3 | piyo_balance | マッピング | public | アドレスをkeyとしてコイン数を格納する |
4 | token | PiyoCoin | private | PiyoCoinコントラクトのアドレス |
(2) 登録イベント
No | イベント名 | 出力値 | 概要 |
---|---|---|---|
1 | Change2coin | 利用者アドレス | 1Ethでコインを買った |
2 | Sagitarius_start | 参加者アドレス、初期位置(x,y) | ゲーム開始 |
3 | Sagitarius_move | オーナーアドレス、方向 | 艦の移動or方向転換 |
4 | Sagitarius_shot | オーナーアドレス | ミサイルの発射 |
5 | Sagitarius_rtb | オーナーアドレス、最終残高 | 艦の引き上げ |
6 | Sagitarius_hit | 攻撃者アドレス、被弾者アドレス | ミサイルヒット |
7 | Sagitarius_end | オーナーアドレス | 被弾によるGameOver |
(3) 関数説明
sagitarius_start
// The day of Sagitarius III
function sagitarius_start() public {
require(token.transferFrom(msg.sender, address(this), 10*1e18), "require fail");
uint seed;
uint x;
uint y;
do {
seed++;
(x , y) = getRandomPosision(seed);
} while( isExist(x,y));
bool till=false;
for( uint i=0; i < ships.length; i++) {
if (!ships[i].live && !till) {
till=true;
ships[i].owner = msg.sender;
ships[i].direction =8;
ships[i].x = x;
ships[i].y = y;
ships[i].live = true;
break;
}
}
if (!till) {
Ship memory ship = Ship( msg.sender, 8, x, y, true);
ships.push(ship);
}
emit Sagitarius_start(msg.sender,x,y);
piyo_balance[msg.sender] = 10;
moveMissiles();
moveMissiles();
moveMissiles();
}
- 関数の呼び出し時に10piyoコインが渡されているかチェックする
- ランダムな初期位置を算出し、その場所が開いているか確認する
- ships[ ]配列を検索して空きがあれば、そのindexに艦情報を登録する
- 現在のships[ ]配列に空きがない場合は、新たにindexを追加(push)する
- イベントの発行
- piyoコイン残高を設定する
- ミサイルの処理を3回呼び出す
sagitarius_move
function sagitarius_move(uint direc) public {
for( uint i=0; i < ships.length; i++) {
if (ships[i].live && ships[i].owner == msg.sender) {
uint nx=ships[i].x;
uint ny=ships[i].y;
ships[i].direction = direc;
if (direc == 8) {ny--;}
if (direc == 6) {nx++;}
if (direc == 2) {ny++;}
if (direc == 4) {nx--;}
if (nx >= MAP_SIZE - SHIP_WIDTH ) { nx = MAP_SIZE - SHIP_WIDTH -1;}
if (ny >= MAP_SIZE - SHIP_WIDTH ) { ny = MAP_SIZE - SHIP_WIDTH -1;}
bool exist;
for( uint j=0; j < ships.length; j++) {
if (ships[j].live && ships[j].owner != msg.sender) {
uint xw = absDiff( ships[i].x, ships[j].x);
uint yw = absDiff( ships[i].y, ships[j].y);
if ( xw < SHIP_WIDTH*2 && yw < SHIP_WIDTH*2) {
exist = true;
}
}
}
if (!exist) {
ships[i].x = nx;
ships[i].y = ny;
}
break;
}
}
emit Sagitarius_move(msg.sender,direc);
moveMissiles();
moveMissiles();
moveMissiles();
}
- ships[ ]配列を検索して自艦を探す
- 現在座標(x,y)から新座標(nx,ny)を求める
- 新座標がマップの端を超えないように調整する
- 新座標に他の艦がいないかチェックする
- 移動可能であれば、ships[ ]配列の現在位置(x,y)を更新する
- イベントの発行
- ミサイルの処理を3回呼び出す
sagitarius_shot
function sagitarius_shot() public {
for( uint i=0; i < ships.length; i++) {
if (ships[i].live && ships[i].owner == msg.sender) {
uint x = ships[i].x;
uint y = ships[i].y;
if ( ships[i].direction == 8) { y = y - SHIP_WIDTH ;}
if ( ships[i].direction == 6) { x = x + SHIP_WIDTH ;}
if ( ships[i].direction == 2) { y = y + SHIP_WIDTH ;}
if ( ships[i].direction == 4) { x = x - SHIP_WIDTH ;}
bool till=false;
for( uint j=0; j < missiles.length; j++) {
if (!missiles[j].live && !till) {
till=true;
missiles[j].owner = msg.sender;
missiles[j].direction =ships[i].direction;
missiles[j].x = x;
missiles[j].y = y;
missiles[j].live = true;
break;
}
}
if (!till) {
Missile memory missile = Missile( msg.sender, ships[i].direction, x, y, true);
missiles.push(missile);
}
}
}
emit Sagitarius_shot(msg.sender);
moveMissiles();
moveMissiles();
moveMissiles();
}
- ships[ ]配列を検索して自艦を探す
- 現在座標(x,y)と方向(direction)からミサイルの初期座標(x,y)を求める
- missiles[ ]配列を検索して開いているindexを求め、初期情報を設定する
- missiles[ ]配列に空きがなければ、新たにindexを作成し初期情報を登録する
- イベントの発行
- ミサイルの処理を3回呼び出す
sagitarius_rtb
function sagitarius_rtb() public {
for( uint j=0; j < missiles.length; j++) {
if (!missiles[j].live && missiles[j].owner == msg.sender) {
missiles[j].live = false;
}
}
for( uint i=0; i < ships.length; i++) {
if (ships[i].live && ships[i].owner == msg.sender) {
ships[i].live = false;
uint256 z = piyo_balance[msg.sender] ;
piyo_balance[msg.sender] = 0;
token.transfer(msg.sender, z * 1e18 );
emit Sagitarius_rtb(msg.sender,z);
}
}
moveMissiles();
moveMissiles();
moveMissiles();
}
- missiles[ ]配列から自身の発射ミサイルを検索し、あれば消滅させる
- ships[ ]配列を検索して自艦を探す
- 自艦データを破棄(live=false)に設定し、自艦コインをオーナーに送金する
- イベントの発行
- ミサイルの処理を3回呼び出す
moveMissiles
function moveMissiles() private {
for( uint i=0; i < missiles.length; i++) {
if (missiles[i].live) {
uint nx = missiles[i].x;
uint ny = missiles[i].y;
if ( missiles[i].direction == 8) { ny--; }
if ( missiles[i].direction == 6) { nx++; }
if ( missiles[i].direction == 2) { ny++; }
if ( missiles[i].direction == 4) { nx--; }
missiles[i].x = nx;
missiles[i].y = ny;
if (nx == 0 || ny == 0 || nx >= MAP_SIZE || ny >= MAP_SIZE) {
missiles[i].live = false;
break;
}
for( uint j=0; j < ships.length; j++) {
if (ships[j].live) {
uint xw = absDiff( nx, ships[j].x);
uint yw = absDiff( ny, ships[j].y);
if ( xw < SHIP_WIDTH && yw < SHIP_WIDTH) {
// HIT
missiles[i].live = false;
piyo_balance[ships[j].owner]--;
piyo_balance[missiles[i].owner]++;
emit Sagitarius_hit(missiles[i].owner,ships[j].owner);
if( piyo_balance[ships[j].owner] == 0 ) {
ships[j].live = false;
emit Sagitarius_end(ships[j].owner);
}
break;
}
}
}
}
}
}
- missiles[ ]配列で生きているもの(live=true)について処理を行う
- ミサイルの新規位置(ny,nx)を求め、座標を移動させる
- ミサイル座標がマップから外れたらミサイルを消滅させる(live=false)
- ships[ ]配列を検索して、当たり判定を行う。
- ミサイル座標と艦座標が重なっていれば、piyo_balanceの異動、イベントの発行、ミサイルの消滅を行う
- 艦の残高がゼロになったらships[ ]配列で当該艦を撃沈(live=false)させ、撃沈イベントを発行する
(4) 内部関数
No | 関数名 | 引数 | 戻り値 | 概要 |
---|---|---|---|---|
1 | getRandomPosision | シード値 | x,y座標 | 0~49の間でx,y座標をランダムに生成する |
2 | isExist | x,y座標 | bool | その座標に艦が存在するかチェックする |
3 | absDiff | a,b | |a-b| | 差分の絶対値を返却する |
(5) 参照用関数
No | 関数名 | 引数 | 戻り値 | 概要 |
---|---|---|---|---|
1 | getShipLength | なし | uint | ships[ ]配列の大きさ |
2 | getMissleLenght | なし | uint | missiles[ ]配列の大きさ |
※ これらの関数はゲームUI側で利用している
4.4 React解説
src/App.jsがメインとなっています。Reactでアプリを作成したのは、これが初めてですので、無駄が多いかと思います。
No | ファイル名 | 概要 |
---|---|---|
1 | App.js | メインの処理 |
2 | OnBoard.js | Metamaskインストール用 |
3 | TopBar.js | アカウントの基礎情報表示 |
4 | Players.js | 画面左:参加者一覧表示 |
5 | Map.js | 画面中央:全体マップ表示 |
6 | Control.js | 画面右:ゲーム操作パネル |
7 | LogView.js | 画面下:ログの表示 |
8 | ./abis/PiyoCoin.json | abiファイル |
9 | ./abis/GameCenter.json | abiファイル |
10 | name.json | ニックネームデータ |
11 | getUserProp.js | 共通関数 |
4.4.1 App.js
全ての状態変数の管理をここで行っています。Reactはこの状態変数の内容に従って動的にHTMLを変更しますが、更新のタイミングを統一するため、Steteの変更はこのApp.jsで行っています。
このため、すべてのボタンの実処理部分はこのファイルに記載されています。また、初回起動時にブロックチェーンから出力されるイベントを受信するためのハンドルを登録しています。
デバッグなどをしていると、このハンドルが複数回登録されることがあり、その際はログが複数行出力されますが、気にしないでください。
4.4.2 OnBoard.js
メタマスク未導入の場合にに呼び出されるモジュールです。下記のリンクを参考にしてください。
4.4.3 TopBar.js
画面上部のアカウント情報を表示します。内部でgeUserPropを呼び出して、ニックネームと背景色を取得しています。
4.4.4 Players.js
画面左側の参加者一覧を表示しています。ここはApp.jsから渡されたアカウント情報を表示するだけです
4.4.5 Map.js
画面中央のマップ表示です。コンポーネントのマウント時にcanvasを作成しています。
4.4.6 Control.js
画面右側の操作パネルを表示しています。ボタンに対する実際の処理は全てApp.jsに記述されています。
4.4.7 LogView.js
画面下のログ表示を行っています。
表示自体はステート変数を表示しているだけなので、何ら問題ありませんが、このステート変数の更新で本モジュールが自動更新されるようにするのが当初うまくいきませんでした。正解としてはApp.jsに記載の通りです。
4.4.8 ./abis
このjsonファイルをApp.jsの最初でインポートしています。このabiはSolidityファイルのコンパイルに伴って出力されるファイルで、gitで梱包されているPiyoCoin.jsonとGameCenter.jsonは先ほど起動したRemix-IDEでコンパイルして生成されたものをそのまま張り付けてあります。
なお、このファイルはtruffleでも生成することができ、その際にはtruffle-config.jsで指定されたディレクトリにjsonファイルが生成されます。
付録1 ropstenへのデプロイ方法
Remix-IDEからRopstenへのデプロイは簡単です。下記の手順でRemix上のSolidityをRopstenに展開することができます。また、アプリケーションには、先ほどのabiファイルとここで出力されるコントラクトアカウントを記述することでReactアプリと接続することができます。
- Remixでデプロイしたいファイルをコンパイルします。
- デプロイ画面に移ってからEnvironmentのプルダウンでInjected Web3を選択します。
- Metamaskが起動しますので、Ropstenのアカウントと接続してください。
- 接続が完了するとRemixのAccount欄に自分のアカウントが表示されます。
- そのままdeployします。
- デプロイ完了後にコントラクトのアカウントアドレスをコピーしてプログラムに貼り付けます。
付録2 truffle,ganacheによるデプロイ方法
gitに上げたreadme.mdファイルに方法を記載しましたので、参考にしてください。
最後に
いかがだったでしょうか。内容的には解説するほどのものでもないので、少々説明がなおざりですが、Dappのエッセンスはお届けできたのかなと思います。
次回は・・・
IPFSを使ったNFTに挑戦したいと思います。
記事一覧