Previous << Day9 - 対戦相手のマッチング
Next >> Day11 - ターンチェンジ
スマートコントラクトにはEventsというのがあります。イーサリアムが発祥ですが、Flow, Sui, Aptos などモダン言語は全てEventsを備えてあります。Eventsはブロックチェーンで何が起きているか監視にも使えますが、蓄積して統計データとして提供するサービスにも使えます。
例えば、ブロックチェーンゲームで最初の手札をEventsでゲームのIDと一緒に飛ばしたとします。(スマートコントラクト上部で定義し、あとはそれを呼び出す(emit)だけです)
そのゲームのIDで勝敗が決まった時にもEventsを飛ばせば、それを監視・収集すれば、どのカードを初手に持ってきた時に勝率がよいのか統計を得られます。ブロックチェーンですから誰でもこのようなサービスを展開できます。
一旦面白いブロックチェーンゲームがモダン言語で出れば、コミュニティの人達が役立ちそうなサービスを、彼ら自身で展開する事が可能です。
コミュニティが費用面等の兼ね合いでDBに蓄えないのであれば、公式が蓄積し、そのデータをサービスにすることができます。
このページではゲーム開始トランザクションでEventsの処理を省いていますが、有効なのでEventsとの併用がおすすめです。
Why Flow
FLOW(または$FLOW)トークンは、Flowネットワークのネイティブ通貨です。開発者およびユーザーは、FLOWを使用してネットワーク上で取引(transact)を行うことができます。開発者は、ピアツーピア決済(他人同士の決済)、サービス料金徴収、または消費者向け特典(rewards)のために、FLOWを直接アプリに統合することができます。
ということでやっていきます、猿でも分かるP2P(ピアツーピア)決済アプリ開発!
💡もし、エミュレータの起動方法やスマートコントラクトのデプロイについて操作に自信がない場合はこちらを参照してください。
マリガンをする
対戦相手が確定したので、スマートコントラクトからマリガンカードリストを取得します。
:
access(all) fun get_marigan_cards(): [[UInt16]] {
if let playerMatchingInfo = AwesomeCardGame.playerMatchingInfo[self.player_id] {
var ret_arr: [[UInt16]] = []
for i in [0, 1, 2, 3, 4] {
if let deck = AwesomeCardGame.playerDeck[self.player_id] {
var tmp = deck.slice(from: 0, upTo: deck.length)
ret_arr.append([tmp[playerMatchingInfo.marigan_cards[i][0]], tmp[playerMatchingInfo.marigan_cards[i][1]], tmp[playerMatchingInfo.marigan_cards[i][2]], tmp[playerMatchingInfo.marigan_cards[i][3]]])
} else {
var tmp = AwesomeCardGame.starterDeck.slice(from: 0, upTo: AwesomeCardGame.starterDeck.length)
ret_arr.append([tmp[playerMatchingInfo.marigan_cards[i][0]], tmp[playerMatchingInfo.marigan_cards[i][1]], tmp[playerMatchingInfo.marigan_cards[i][2]], tmp[playerMatchingInfo.marigan_cards[i][3]]])
}
}
return ret_arr
}
return []
}
:
Player
リソースの下に上記のような関数を追記します。
マリガンカードリストをjsで取得する
マリガンカードリストを取得するスクリプトと、それをjsで取得するコードは以下のようになります。
import { query } from "@onflow/fcl";
export const getMariganCards = async function (address) {
const result = await query({
cadence: `
import "AwesomeCardGame"
access(all) fun main(address: Address): AnyStruct {
let cap = getAccount(address).capabilities.borrow<&AwesomeCardGame.Player>(/public/AwesomeCardGamePlayer)
?? panic("Doesn't have capability!")
return cap.get_marigan_cards()
}
`,
args: (arg, t) => [arg(address, t.Address)],
});
return result;
};
アカウントのストレージが持つリソースは古いため、get_marigan_cards
を見つけられないので一度エミュレータを止めて再起動します。そしてコントラクトをデプロイして新規登録から再度行います。そのため、マッチング処理も再度行う必要があります。
HTMLとロジックを書く
メイン画面のロジックを以下のようにします。
<script>
<script>
import { config, authenticate, unauthenticate, currentUser } from '@onflow/fcl';
import { getBalance, isRegistered, getPlayerDeck, getCurrentStatus, getMariganCards } from '../scripts'; <- 修正
import { createPlayer, buyCyberEn } from '../transactions'
import flowJSON from '../flow.json';
const network = 'emulator';
let walletUser;
let playerName;
let flowBalance;
let cyberEnergyBalance;
let hasResource;
let playerDeck;
let currentStatus;
let mariganCards; <- 追加
config({
'flow.network': network,
'accessNode.api': 'http://localhost:8888',
'discovery.wallet': 'http://localhost:8701/fcl/authn',
}).load({ flowJSON });
currentUser.subscribe(async (user) => {
walletUser = user;
if (user.addr) {
hasResource = await isRegistered(user.addr);
if (hasResource) {
const [flowTokenBalance, cyberEnergy, pName] = await getBalance(user.addr);
flowBalance = flowTokenBalance;
cyberEnergyBalance = cyberEnergy;
playerName = pName;
playerDeck = await getPlayerDeck(user.addr);
mariganCards = await getMariganCards(user.addr); <- 追加
mariganCards = `[` + mariganCards.join('], [') + ']' <- 追加
setInterval(async () => {
currentStatus = await getCurrentStatus(user.addr);
if (!Number.isNaN(parseInt(currentStatus))) {
currentStatus = `最後にマッチングを行った時刻=${new Date(currentStatus * 1000).toLocaleString()}`
} else if (currentStatus !== null) {
console.log(currentStatus, Object.keys(currentStatus), currentStatus["turn"])
const keys = Object.keys(currentStatus);
const tmp = currentStatus;
if (keys.length) {
currentStatus = '';
for (let key of keys) {
currentStatus += `${key} : ${tmp[key]}; `;
}
}
}
}, 1000)
}
}
});
</script>
{#if !walletUser?.addr}
<button onclick={authenticate}>ログイン</button>
{/if}
{#if walletUser?.addr}
<b>{playerName}さん</b> FLOW残高: {flowBalance} / ゲーム内通貨: {cyberEnergyBalance}<br>
<button onclick={unauthenticate}>ログアウト</button>
{#if !hasResource}
Playerリソースが作成されていません。
<button onclick={() => createPlayer('新規ユーザーA')}>新規登録</button>
{/if}
{#if hasResource}
Playerリソース作成済みです。
<br>
<button onclick={buyCyberEn}>CyberEnergy購入</button>
<hr>
<div>
現在のデッキ: { playerDeck }
</div>
<div>
現在のゲーム進捗状況: { currentStatus ?? 'なし' }
</div>
<hr>
<div>
マリガンカード: { mariganCards } <- 追加
</div>
{/if}
{/if}
これで画面を表示します。
乱数はうまくいっていそうです。
スマートコントラクトのゲームを開始するロジック
マリガンでカードを4回まで引き直しできて、それが決定したら、ゲームスタートになります。
ゲームスタートロジックを実装します。
:
access(all) struct BattleStruct {
:
access(contract) fun set_game_started(new_value: Bool) {
self.game_started = new_value
}
access(contract) fun set_your_remain_deck(new_value: [UInt16]) {
self.your_remain_deck = new_value
}
access(contract) fun set_last_time_turnend(new_value: UFix64) {
self.last_time_turnend = new_value
}
access(contract) fun set_turn(new_value: UInt8) {
self.turn = new_value
}
:
}
:
/*
** [Resource] Admin (Game Server Processing)
*/
access(all) resource Admin {
:
/*
** Game Start Transaction
*/
access(all) fun game_start(player_id: UInt, drawed_cards: [UInt16]) {
pre {
drawed_cards.length == 4 : "Invalid argument."
AwesomeCardGame.battleInfo[player_id] != nil && AwesomeCardGame.battleInfo[player_id]!.game_started == false : "Game already started."
}
var drawed_pos: [UInt8] = []
if let playerMatchingInfo = AwesomeCardGame.playerMatchingInfo[player_id] {
for arr in playerMatchingInfo.marigan_cards {
if let deck = AwesomeCardGame.playerDeck[player_id] {
var arrCopy = deck.slice(from: 0, upTo: deck.length)
let card_id1 = arrCopy[arr[0]]
let card_id2 = arrCopy[arr[1]]
let card_id3 = arrCopy[arr[2]]
let card_id4 = arrCopy[arr[3]]
if (card_id1 == drawed_cards[0] && card_id2 == drawed_cards[1] && card_id3 == drawed_cards[2] && card_id4 == drawed_cards[3]) {
drawed_pos = arr
}
} else {
var arrCopy = AwesomeCardGame.starterDeck.slice(from: 0, upTo: AwesomeCardGame.starterDeck.length)
let card_id1 = arrCopy[arr[0]]
let card_id2 = arrCopy[arr[1]]
let card_id3 = arrCopy[arr[2]]
let card_id4 = arrCopy[arr[3]]
if (card_id1 == drawed_cards[0] && card_id2 == drawed_cards[1] && card_id3 == drawed_cards[2] && card_id4 == drawed_cards[3]) {
drawed_pos = arr
}
}
}
if (drawed_pos.length == 0) {
/* Maybe the player did marigan more than 5 times. Set first cards to avoid errors. */
drawed_pos = playerMatchingInfo.marigan_cards[0]
}
}
if let info = AwesomeCardGame.battleInfo[player_id] {
info.set_game_started(new_value: true)
if let deck = AwesomeCardGame.playerDeck[player_id] {
info.set_your_remain_deck(new_value: deck)
} else {
info.set_your_remain_deck(new_value: AwesomeCardGame.starterDeck)
}
info.set_last_time_turnend(new_value: getCurrentBlock().timestamp)
/* Set hand */
var key: UInt8 = 1
for pos in drawed_pos {
let card_id = info.your_remain_deck.remove(at: pos)
info.your_hand[key] = card_id
key = key + 1
}
/** 今回はバトルはしない予定なので、以下部分は省略
if (info.is_first == true) {
info.your_cp = 2
} else {
info.your_cp = 3
}
**/
/* Save */
AwesomeCardGame.battleInfo[player_id] = info
let opponent = info.opponent
if let opponentInfo = AwesomeCardGame.battleInfo[opponent] {
/** 今回はバトルはしない予定なので、以下部分は省略
opponentInfo.last_time_turnend = info.last_time_turnend
opponentInfo.opponent_remain_deck = info.your_remain_deck.length
opponentInfo.opponent_hand = info.your_hand.keys.length
opponentInfo.opponent_cp = info.your_cp
**/
/* Save */
AwesomeCardGame.battleInfo[opponent] = opponentInfo
}
}
}
}
:
game_start
関数はAdmin
リソースの中に配置します。
マリガンしたカードを渡し、それをデッキから引いてハンドカードにします。
AwesomeCardGame
コントラクトを以下コマンドで更新します。
flow accounts update-contract ./AwesomeCardGame.cdc
ゲームを開始する
次にバックエンドでNode.jsで実行するjsファイルを作っていきます。ここでは/backend
フォルダの中のファイルはサーバーサイドで実行するものと仮定します。
send_game_start.js
ファイルを作成して、Non-Custodial方式のトランザクションを書きます。
import fs from "fs";
import fcl from "@onflow/fcl";
import { SHA3 } from "sha3";
import pkg from "elliptic";
const { ec } = pkg;
import { argv } from "node:process";
var player_id;
var drawed_cards;
argv.forEach((val, index) => {
if (index == 2) {
player_id = val;
} else if (index == 3) {
drawed_cards = JSON.parse(val);
}
});
fcl
.config()
.put("flow.network", "emulator")
.put("accessNode.api", "http://localhost:8888");
try {
var KEY_ID_IT = 0;
// 以下はProposerのKeyが300ある場合を想定したものなのでエミュレータでは0とする
// if (fs.existsSync("/tmp/sequence.txt")) {
// KEY_ID_IT = parseInt(
// fs.readFileSync("/tmp/sequence.txt", { encoding: "utf8" })
// );
// } else {
// KEY_ID_IT = new Date().getMilliseconds() % 300;
// }
// KEY_ID_IT = !KEY_ID_IT || KEY_ID_IT >= 300 ? 1 : KEY_ID_IT + 1;
// fs.writeFileSync("/tmp/sequence.txt", KEY_ID_IT.toString());
const ec_ = new ec("p256");
/* CHANGE THESE THINGS FOR YOU */
/* 注意! これはエミュレータのプライベートキーなので、テストネットやメインエットに影響がないのでコードに埋めているが、本来は決して行ってはならない */
const PRIVATE_KEY = `9ba63c9cd20a8214bcd8178b6d65d6cb54725670bba95a56f30d3bb1de9baaf4`;
const ADDRESS = "0xf8d6e0586b0a20c7";
const KEY_ID = 0;
const hash = (message) => {
const sha = new SHA3(256);
sha.update(Buffer.from(message, "hex"));
return sha.digest();
};
const sign = (message) => {
const key = ec_.keyFromPrivate(Buffer.from(PRIVATE_KEY, "hex"));
const sig = key.sign(hash(message)); // hashMsgHex -> hash
const n = 32;
const r = sig.r.toArrayLike(Buffer, "be", n);
const s = sig.s.toArrayLike(Buffer, "be", n);
return Buffer.concat([r, s]).toString("hex");
};
async function authorizationFunction(account) {
return {
...account,
tempId: `${ADDRESS}-${KEY_ID}`,
addr: fcl.sansPrefix(ADDRESS),
keyId: Number(KEY_ID),
signingFunction: async (signable) => {
return {
addr: fcl.withPrefix(ADDRESS),
keyId: Number(KEY_ID),
signature: sign(signable.message),
};
},
};
}
async function authorizationFunctionProposer(account) {
return {
...account,
tempId: `${ADDRESS}-${KEY_ID_IT}`,
addr: fcl.sansPrefix(ADDRESS),
keyId: Number(KEY_ID_IT),
signingFunction: async (signable) => {
return {
addr: fcl.withPrefix(ADDRESS),
keyId: Number(KEY_ID_IT),
signature: sign(signable.message),
};
},
};
}
console.log(player_id, drawed_cards, KEY_ID, KEY_ID_IT);
/* player matching. */
let transactionId = await fcl.mutate({
cadence: `
import AwesomeCardGame from 0xf8d6e0586b0a20c7
transaction(player_id: UInt, drawed_cards: [UInt16]) {
prepare(signer: auth(BorrowValue) &Account) {
let admin = signer.storage.borrow<&AwesomeCardGame.Admin>(from: /storage/AwesomeCardGameAdmin)
?? panic("Could not borrow reference to the Administrator Resource.")
admin.game_start(player_id: player_id, drawed_cards: drawed_cards)
}
execute {
log("success")
}
}
`,
args: (arg, t) => [
arg(player_id, t.UInt),
arg(drawed_cards, t.Array(t.UInt16)),
],
proposer: authorizationFunctionProposer,
payer: authorizationFunction,
authorizations: [authorizationFunction],
limit: 999,
});
console.log(`TransactionId: ${transactionId}`);
fcl.tx(transactionId).subscribe((res) => {
console.log(res);
});
} catch (error) {
console.error(error);
}
ゲーム開始処理をする
プレイヤーが画面上でマリガンによるカードが確定したとします。([16,20,4,3]
)
それをバックエンドで受け取って実行すると仮定します。コマンドは以下とします。
node ../backend/send_game_start.js 1 "[16,20,4,3]"
Output:
トランザクションはエラーなくブロックに封印されました。
ではplayer_id=1(emulator_account)の画面を表示します。
ではplayer_id=1(emulator_account)の画面を表示します。
上記結果から分かることは以下です。
- ハンドのカードが
[16,20,4,3]
になっています。(keyはハンドのポジション)と思ったけど20のはずが21になってますね。デッキから取り除く時に位置がズレるバグがあるようです。。 - ハンドのカードがデッキから取り除かれています。
- game_startedがtrueになっています。
CadenceスマートコントラクトにはArrayやDictionaryの組み込み関数が多く、言語の文法がモダンで実装が非常にしやすかったです。Swiftに近いと言われますが、Javascriptの知識だけでも十分にサクサクと開発ができます。
もっと高度な、GraphQLによる対戦相手と情報を共有しながらリアルタイム対戦を行える、トランザクションを実施したいという場合には、こちらが参考になるでしょう。(本は薄めですが、無駄を省いてピンポイントでコードを参照出来るようにした為です。ですので、レビューを滅茶甘めで付けていただけると喜びます😭)
この記事のソースコードはこちらにあります。