Previous << Day7 - ゲーム内通貨購入
Next >> Day9 - マッチング処理
Why Flow
FLOW(または$FLOW)トークンは、Flowネットワークのネイティブ通貨です。開発者およびユーザーは、FLOWを使用してネットワーク上で取引(transact)を行うことができます。開発者は、ピアツーピア決済(他人同士の決済)、サービス料金徴収、または消費者向け特典(rewards)のために、FLOWを直接アプリに統合することができます。
ということでやっていきます、猿でも分かるP2P(ピアツーピア)決済アプリ開発!
💡もし、エミュレータの起動方法やスマートコントラクトのデプロイについて操作に自信がない場合はこちらを参照してください。
現在のデッキを表示する
カードゲームで使用する自分のカードリストのことをデッキといいます。
AwesomeCardGame.cdc
のPlayer
リソースの定義に以下のようにget_player_deck
メソッドを追加します。
:
access(all) resource Player {
access(all) let player_id: UInt
access(all) let nickname: String
init(nickname: String) {
AwesomeCardGame.totalPlayers = AwesomeCardGame.totalPlayers + 1
self.player_id = AwesomeCardGame.totalPlayers
self.nickname = nickname
AwesomeCardGame.playerList[self.player_id] = CyberScoreStruct(player_name: nickname)
}
access(all) fun get_player_score(): CyberScoreStruct {
return AwesomeCardGame.playerList[self.player_id]!
}
access(all) fun buy_en(payment: @FlowToken.Vault) {
pre {
payment.balance == 2.0: "payment is not 2FLOW coin."
AwesomeCardGame.playerList[self.player_id] != nil: "CyberScoreStruct not found."
}
AwesomeCardGame.FlowTokenVault.borrow()!.deposit(from: <- payment)
if let cyberScore = AwesomeCardGame.playerList[self.player_id] {
cyberScore.set_cyber_energy(new_value: cyberScore.cyber_energy + 100)
AwesomeCardGame.playerList[self.player_id] = cyberScore
}
}
access(all) fun get_player_deck(): [UInt16] {
if let deck = AwesomeCardGame.playerDeck[self.player_id] {
return deck
} else {
return AwesomeCardGame.starterDeck;
}
}
:
if let
文が if let cyberScore =
のところで使われています。CadenceはSwift言語に非常に似ているので if let swift
でGoogle検索すると情報が出てきます。何か目新しい文法が出てきたらSwiftで検索してみるといいかもしれません。
if let
文は、もし値があれば、左辺に値が入り、括弧内の処理が行われます。その時、左辺の変数を利用することができます。スマートコントラクトでは価値を持つ資産を扱う関係上、値があるかどうか、は非常に大事になってきます。それを確実に条件分岐で処理する方法として if let
文はスマートコントラクトの中でたくさん出てきます。
if let
文以外で値を抽出しようとすると、それが安全でない可能性が高いため、エラーが出る可能性が高いです。万が一にも資産を失うことがないように、if let文で安全にロジックを書くことが推奨されています。
starterDeck
はDay6の時点で既にAwesomeCardGame.cdc
に実装していましたが、playerDeck
はまだ実装していませんでしたので追記します。
import "FlowToken"
import "FungibleToken"
access(all) contract AwesomeCardGame {
access(self) let playerList: {UInt: CyberScoreStruct}
access(self) var totalPlayers: UInt
access(self) let playerDeck: {UInt: [UInt16]} <- 追加
access(all) let starterDeck: [UInt16]
access(self) let FlowTokenVault: Capability<&{FungibleToken.Receiver}>
access(self) let PlayerFlowTokenVault: {UInt: Capability<&{FungibleToken.Receiver}>}
:
:
init() {
self.FlowTokenVault = self.account.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
self.PlayerFlowTokenVault = {}
self.starterDeck = [1, 1, 2, 2, 3, 3, 4, 4, 5, 6, 7, 8, 9, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]
self.totalPlayers = 0
self.playerList = {}
self.playerDeck = {} <- 追加
}
}
Day7でリソース定義を変更したときと同じように、一旦アカウントストレージに保存したリソースを削除するためエミュレータを再起動します。
プレイヤーのデッキをjsで取得する
プレイヤーのデッキを取得するスクリプトと、それをjsで取得するコードは以下のようになります。
import { query } from "@onflow/fcl";
export const getPlayerDeck = async function (address) {
const result = await query({
cadence: `
import "AwesomeCardGame"
access(all) fun main(address: Address): [UInt16] {
let cap = getAccount(address).capabilities
.borrow<&AwesomeCardGame.Player>(/public/AwesomeCardGamePlayer)
?? panic("Doesn't have capability!")
return cap.get_player_deck()
}
`,
args: (arg, t) => [arg(address, t.Address)],
});
return result;
};
HTMLとロジックを書く
メイン画面のロジックを以下のようにします。
<script>
import { config, authenticate, unauthenticate, currentUser } from '@onflow/fcl';
import { getBalance, isRegistered, getPlayerDeck } 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; <- 追加
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); <- 追加
}
}
});
</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>
{/if}
{/if}
これで画面を表示します。
カード画像も何もなくてすみませんが、デッキのカードID一覧を取得できました。
まだデッキ編集をしていないのでスマートコントラクトAwesomeCardGame
のinit
関数で定義したstarterDeck
のデータが表示されています。
デッキを編集する
デッキ編集のトランザクションはバックエンドで実施します。
新規登録やゲーム内通貨購入はアカウントのストレージにアクセスする必要がありました。その為、ユーザー自身にウォレット上でApproveを押してもらう必要がどうしてもありました。
しかし、デッキ編集はスマートコントラクト内のデータを修正するだけなので、バックエンドでNode.jsを使ってトランザクションを行うことが出来ます。これによってユーザーの負担を和らげることが出来ます。
これを実現するためにはAdmin
リソースを作成する必要があります。Admin
リソースはスマートコントラクトのデプロイ時にinit
関数内で作成したもので、(Admin
をcreate
するコードが他の場所にない場合)スマートコントラクトのデプロイ者にだけ許される処理を実装することが出来ます。
実際ゲームコントラクトの実装のほとんどは、このAdmin
リソースの関数が占めることなります。
Admin
リソースの作成をAwesomeCardGame.cdc
に追加していきます。
:
/*
** [Resource] Admin (Game Server Processing)
*/
access(all) resource Admin {
/*
** Save the Player's Card Deck
*/
access(all) fun save_deck(player_id: UInt, player_deck: [UInt16]) {
if player_deck.length == 30 {
AwesomeCardGame.playerDeck[player_id] = player_deck
}
}
}
init() {
self.account.storage.save( <- create Admin(), to: /storage/AwesomeCardGameAdmin) // grant admin resource
self.FlowTokenVault = self.account.capabilities.get<&{FungibleToken.Receiver}>(/public/flowTokenReceiver)
self.PlayerFlowTokenVault = {}
self.starterDeck = [1, 1, 2, 2, 3, 3, 4, 4, 5, 6, 7, 8, 9, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]
self.totalPlayers = 0
self.playerList = {}
self.playerDeck = {}
}
}
これでAdmin
リソースとその関数のsave_deck
を準備できました。
エミュレータを再起動して、AwesomeCardGame
コントラクトをデプロイします。
次にバックエンドでNode.jsで実行するjsファイルを作っていきます。ここでは/backend
フォルダを作りこのフォルダの中のファイルはサーバーサイドで実行するものと仮定します。
send_save_deck.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 player_deck;
argv.forEach((val, index) => {
if (index == 2) {
player_id = val;
} else if (index == 3) {
player_deck = 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, player_deck, KEY_ID, KEY_ID_IT);
/* Save the player's card deck. */
let transactionId = await fcl.mutate({
cadence: `
import AwesomeCardGame from 0xf8d6e0586b0a20c7
transaction(player_id: UInt, player_deck: [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.save_deck(player_id: player_id, player_deck: player_deck)
}
execute {
log("success")
}
}
`,
args: (arg, t) => [
arg(player_id, t.UInt),
arg(player_deck, 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);
}
Day4で述べた通り、提案者だけはDOS攻撃対策としてKeyIDを一定期間内に同じものを使うことが出来ません。そのため、Proposer
だけはauthorizationFunctionをPayer
やAuthorizations
と分けて実装する必要が出てきて、本番環境では数百のKey(公開鍵)を秘密鍵から作成しておき、それにKeyIDを紐づけておきます。Adminリソースのように何度も呼ばれるファイルにはこのKeyIDをローテーションさせます。ここではエミュレータですのでパソコンを連打しない限り大丈夫なのでKeyIDを0固定にしました。結果、authorizationFunctionProposer
とauthorizationFunction
は全く同じ関数になっています。
デッキ編集処理をする
プレイヤーが以下のデッキに変更したいとしてデッキ編集ボタンを押したとします。
[
2, 2, 3, 3, 4, 4, 6, 6, 7,
7, 8, 8, 9, 9, 10, 10, 11, 11,
12, 12, 13, 13, 14, 14, 15, 15, 16,
16, 17, 17
]
それをバックエンドで受け取って実行すると仮定します。コマンドは以下とします。
node ../backend/send_save_deck.js 1 "[2,2,3,3,4,4,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,14,14,15,15,16,16,17,17]"
Output:
UInt16がDeprecatedだという警告が上がっていますが、トランザクションはエラーなくブロックに封印されました。
ではplayer_id=1(emulator_account)の画面を表示します。
スターターデッキではなく、ユーザーが指定したデッキが画面上に表示されました。
プレイヤーは画面上でデッキ編集ボタンを押しただけですが、スマートコントラクトに保存されましたので、このNon-Custodial方式の威力を分かっていただけたかと思います。これだけですので全く難しくありません。
もっと高度な、GraphQLによる対戦相手と情報を共有しながらリアルタイム対戦を行える、トランザクションを実施したいという場合には、こちらが参考になるでしょう。(本は薄めですが、無駄を省いてピンポイントでコードを参照出来るようにした為です。ですので、レビューを滅茶甘めで付けていただけると喜びます😭)
この記事のソースコードはこちらにあります。