Previous << Day8 - デッキ編集
Next >> Day10 - マリガンおよびゲーム開始
Why Flow
FLOW(または$FLOW)トークンは、Flowネットワークのネイティブ通貨です。開発者およびユーザーは、FLOWを使用してネットワーク上で取引(transact)を行うことができます。開発者は、ピアツーピア決済(他人同士の決済)、サービス料金徴収、または消費者向け特典(rewards)のために、FLOWを直接アプリに統合することができます。
ということでやっていきます、猿でも分かるP2P(ピアツーピア)決済アプリ開発!
💡もし、エミュレータの起動方法やスマートコントラクトのデプロイについて操作に自信がない場合はこちらを参照してください。
対戦相手とのマッチングをする
まず、最初に言っておきたいのは、私はWebエンジニアであって、マッチング処理を実装したことは一度もありません。ここで紹介しているコードは5日しかない第一回ハッカソンで、限りある時間の中でなんとかマッチングができる機能として実装したものです。ゲームエンジニアから見ると笑うレベルのものであるということです。
要件は以下
- マッチング時間は1分とする(1分を超えると再度マッチング処理を開始できる)
- 対戦相手が決まるとすぐにマリガン処理(カード引き直し)を開始するのでマッチングが成立したらマリガンカードを決める(4回引き直し可能)
- 自分自身とはマッチングしない
- 毎秒ブラウザで情報を更新するのでこれら情報を取得できるメソッド
- マッチングが成立した時点でゲーム内通貨
30cyber_energy
を徴収する
ゲーム開始ボタンを押すと、マッチング処理が開始します。その後ブラウザから毎秒最新情報を取得しますので、その情報を返す関数が必要になります。
:
access(all) fun get_current_status(): AnyStruct {
if let info = AwesomeCardGame.battleInfo[self.player_id] {
return info
}
if let obj = AwesomeCardGame.playerMatchingInfo[self.player_id] {
return obj.lastTimeMatching
}
return nil
}
:
Player
リソースの下に上記のような関数を追記します。battleInfo
とplayerMatchingInfo
が初出なのでこれを定義します。
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(self) let battleInfo: {UInt: BattleStruct} <- 追加
access(self) let playerMatchingInfo: {UInt: PlayerMatchingStruct} <- 追加
access(all) let starterDeck: [UInt16]
access(self) let FlowTokenVault: Capability<&{FungibleToken.Receiver}>
access(self) let PlayerFlowTokenVault: {UInt: Capability<&{FungibleToken.Receiver}>}
/* [Struct] PlayerMatchingStruct */
access(all) struct PlayerMatchingStruct { <-- 追加
access(all) var lastTimeMatching: UFix64?
access(all) var marigan_cards: [[UInt8]]
init() {
self.lastTimeMatching = nil
self.marigan_cards = []
}
/* Setter関数 */
access(contract) fun set_lastTimeMatching(new_value: UFix64) {
self.lastTimeMatching = new_value
}
access(contract) fun set_marigan_cards(new_value: [[UInt8]]) {
self.marigan_cards = new_value
}
}
/* [Struct] BattleStruct (Setter関数は省略) */
access(all) struct BattleStruct { <-- 追加
access(all) var turn: UInt8 /* 現在のターン */
access(all) var is_first_turn: Bool /* 先行or後攻 */
access(all) let is_first: Bool /* 自分は先攻か後攻か */
access(all) let opponent: UInt
access(all) let matched_time: UFix64
access(all) var game_started: Bool
access(all) var last_time_turnend: UFix64?
access(all) var opponent_life: UInt8
access(all) var opponent_cp: UInt8
access(all) var opponent_field_unit: {UInt8: UInt16}
access(all) var opponent_field_unit_action: {UInt8: UInt8}
access(all) var opponent_field_unit_bp_amount_of_change: {UInt8: Int}
access(all) var opponent_trigger_cards: Int
access(all) var opponent_remain_deck: Int
access(all) var opponent_hand: Int
access(all) var opponent_dead_count: Int
access(all) var your_life: UInt8
access(all) var your_cp: UInt8
access(all) var your_field_unit: {UInt8: UInt16}
access(all) var your_field_unit_action: {UInt8: UInt8}
access(all) var your_field_unit_bp_amount_of_change: {UInt8: Int}
access(all) var your_trigger_cards: {UInt8: UInt16}
access(all) var your_remain_deck: [UInt16]
access(all) var your_hand: {UInt8: UInt16}
access(all) var your_dead_count: Int
init(is_first: Bool, opponent: UInt, matched_time: UFix64) {
self.turn = 1
self.is_first_turn = true
self.is_first = is_first
self.opponent = opponent
self.matched_time = matched_time
self.game_started = false
self.last_time_turnend = nil
self.opponent_life = 7
self.opponent_cp = 2
self.opponent_field_unit = {}
self.opponent_field_unit_action = {}
self.opponent_field_unit_bp_amount_of_change = {}
self.opponent_trigger_cards = 0
self.opponent_remain_deck = 30
self.opponent_hand = 0
self.opponent_dead_count = 0
self.your_life = 7
self.your_cp = 2
self.your_field_unit = {}
self.your_field_unit_action = {}
self.your_field_unit_bp_amount_of_change = {}
self.your_trigger_cards = {}
self.your_remain_deck = []
self.your_hand = {}
self.your_dead_count = 0
}
}
:
:
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 = {}
self.battleInfo = {} <- 追加
self.playerMatchingInfo = {} <- 追加
}
}
現在のゲーム進捗状況をjsで取得する
現在のゲーム進捗状況を取得するスクリプトと、それをjsで取得するコードは以下のようになります。
import { query } from "@onflow/fcl";
export const getCurrentStatus = 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_current_status()
}
`,
args: (arg, t) => [arg(address, t.Address)],
});
return result;
};
アカウントのストレージが持つリソースは古いため、get_current_status
を見つけられないので一度エミュレータを止めて再起動します。そしてコントラクトをデプロイして新規登録から再度行います。
HTMLとロジックを書く
メイン画面のロジックを以下のようにします。
<script>
import { config, authenticate, unauthenticate, currentUser } from '@onflow/fcl';
import { getBalance, isRegistered, getPlayerDeck, getCurrentStatus } 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; <- 追加
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);
setInterval(async () => {
currentStatus = await getCurrentStatus(user.addr); <- 追加
}, 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>
{/if}
{/if}
これで画面を表示します。
ゲーム内通貨を買います。
結果:
(プレイヤーがAdminなのでFLOWは減っていません。)
ゲームが開始していないし、Play
ボタンも押していない為マッチングもしていないので、ゲーム進捗状況はなし、と表示されています。
マッチング処理の実装をする
要件に合わせてマッチング処理を実装します。
要件:
- マッチング時間は1分
- 対戦相手が決まるとすぐにマリガン処理(カード引き直し)を開始するのでマッチングが成立したらマリガンカードを決める(4回引き直し可能)
- 自分自身とはマッチングしない
- マッチングが成立した時点でゲーム内通貨
30cyber_energy
を徴収する
マッチングが成立した時点でマリガンカードを決めるので擬似乱数を使います。
:
access(self) var matchingLimits: [UFix64]
access(self) var matchingPlayers: [UInt]
:
/*
** [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
}
}
/*
** Player Matching Transaction
*/
access(all) fun matching_start(player_id: UInt) {
pre {
/* preの中の条件に合わない場合はエラーメッセージが返ります。 ここでは"Still matching."。 */
AwesomeCardGame.playerMatchingInfo[player_id] == nil ||
AwesomeCardGame.playerMatchingInfo[player_id]!.lastTimeMatching == nil ||
AwesomeCardGame.playerMatchingInfo[player_id]!.lastTimeMatching! + 60.0 <= getCurrentBlock().timestamp : "Still matching."
}
var counter = 0
var outdated = -1
let current_time = getCurrentBlock().timestamp
if let obj = AwesomeCardGame.playerMatchingInfo[player_id] {
obj.set_lastTimeMatching(new_value: current_time)
AwesomeCardGame.playerMatchingInfo[player_id] = obj /* save */
} else {
let newObj = PlayerMatchingStruct()
newObj.set_lastTimeMatching(new_value: current_time)
AwesomeCardGame.playerMatchingInfo[player_id] = newObj
}
/* Search where matching times are already past 60 seconds */
for time in AwesomeCardGame.matchingLimits {
if outdated == -1 && current_time > time + 60.0 {
outdated = counter
}
counter = counter + 1
}
/* If there are some expired matching times */
if outdated > -1 {
/* Save only valid matchin times */
if (outdated == 0) {
AwesomeCardGame.matchingLimits = []
AwesomeCardGame.matchingPlayers = []
} else {
AwesomeCardGame.matchingLimits = AwesomeCardGame.matchingLimits.slice(from: 0, upTo: outdated)
AwesomeCardGame.matchingPlayers = AwesomeCardGame.matchingPlayers.slice(from: 0, upTo: outdated)
}
}
/* 既にマッチングリストに入っている場合。このまま進むと自分とマッチングしかねない */
if (AwesomeCardGame.matchingPlayers.firstIndex(of: player_id) != nil) {
return
}
if AwesomeCardGame.matchingLimits.length >= 1 {
/* Pick the opponent from still matching players. */
let time = AwesomeCardGame.matchingLimits.removeLast()
let opponent = AwesomeCardGame.matchingPlayers.removeLast()
var is_first = false
/* Decides which is first */
if (AwesomeCardGame.matchingLimits.length % 2 == 1) {
is_first = true
}
/* マッチング成立したのでnilで初期化 */
AwesomeCardGame.playerMatchingInfo[player_id] = PlayerMatchingStruct()
AwesomeCardGame.battleInfo[player_id] = BattleStruct(is_first: is_first, opponent: opponent, matched_time: current_time)
AwesomeCardGame.battleInfo[opponent] = BattleStruct(is_first: !is_first, opponent: player_id, matched_time: current_time)
/* charge the play fee (料金徴収) */
if let cyberScore = AwesomeCardGame.playerList[player_id] {
cyberScore.set_cyber_energy(new_value: cyberScore.cyber_energy - 30)
AwesomeCardGame.playerList[player_id] = cyberScore
}
/* charge the play fee (料金徴収) */
if let cyberScore = AwesomeCardGame.playerList[opponent] {
cyberScore.set_cyber_energy(new_value: cyberScore.cyber_energy - 30)
AwesomeCardGame.playerList[opponent] = cyberScore
}
} else {
/* Put player_id in the matching list. */
AwesomeCardGame.matchingLimits.append(current_time)
AwesomeCardGame.matchingPlayers.append(player_id)
}
/* Creates Pseudorandom Numbe for the marigan cards(擬似乱数生成関数、revertibleRandomを使います。) */
let modulo: UInt8 = 30
var marigan_cards1: [UInt8] = []
var marigan_cards2: [UInt8] = []
var marigan_cards3: [UInt8] = []
var marigan_cards4: [UInt8] = []
var marigan_cards5: [UInt8] = []
for i in [0, 1, 2, 3, 4] {
var used1: UInt8 = 99
var used2: UInt8 = 99
var used3: UInt8 = 99
var used4: UInt8 = 99
let tmp: [UInt8] = []
while (used4 == 99) {
let withdrawPosition = revertibleRandom(modulo: modulo)
if (used1 == 99) {
used1 = withdrawPosition
tmp.append(withdrawPosition)
} else if (used1 != withdrawPosition && used2 == 99) {
used2 = withdrawPosition
tmp.append(withdrawPosition)
} else if (used1 != withdrawPosition && used2 != withdrawPosition && used3 == 99) {
used3 = withdrawPosition
tmp.append(withdrawPosition)
} else if (used1 != withdrawPosition && used2 != withdrawPosition && used3 != withdrawPosition) {
used4 = withdrawPosition
tmp.append(withdrawPosition)
}
}
if (i == 0) {
marigan_cards1 = tmp.slice(from: 0, upTo: tmp.length)
} else if (i == 1) {
marigan_cards2 = tmp.slice(from: 0, upTo: tmp.length)
} else if (i == 2) {
marigan_cards3 = tmp.slice(from: 0, upTo: tmp.length)
} else if (i == 3) {
marigan_cards4 = tmp.slice(from: 0, upTo: tmp.length)
} else if (i == 4) {
marigan_cards5 = tmp.slice(from: 0, upTo: tmp.length)
}
}
if let playerMatchingInfo = AwesomeCardGame.playerMatchingInfo[player_id] {
playerMatchingInfo.set_marigan_cards(new_value: [marigan_cards1, marigan_cards2, marigan_cards3, marigan_cards4, marigan_cards5])
AwesomeCardGame.playerMatchingInfo[player_id] = playerMatchingInfo /* save */
}
}
}
init() {
:
self.matchingLimits = []
self.matchingPlayers = []
}
}
matching_start
関数はAdmin
リソースの中に配置します。
エミュレータを再起動して、AwesomeCardGame
コントラクトをデプロイします。
対戦相手とマッチングする
対戦相手のマッチングのトランザクションはバックエンドで実施します。
新規登録やゲーム内通貨購入はアカウントのストレージにアクセスする必要がありました。その為、ユーザー自身にウォレット上でApproveを押してもらう必要がどうしてもありました。
しかし、対戦相手のマッチングはスマートコントラクト内のデータを修正するだけなので、バックエンドでNode.jsを使ってトランザクションを行うことが出来ます。これによってユーザーの負担を和らげることが出来ます。
これを実現するためにはAdmin
リソースを作成する必要があります。Admin
リソースはスマートコントラクトのデプロイ時にinit
関数内で作成したもので、(Admin
をcreate
するコードが他の場所にない場合)スマートコントラクトのデプロイ者にだけ許される処理を実装することが出来ます。
実際ゲームコントラクトの実装のほとんどは、このAdmin
リソースの関数が占めることなります。
次にバックエンドでNode.jsで実行するjsファイルを作っていきます。ここでは/backend
フォルダの中のファイルはサーバーサイドで実行するものと仮定します。
send_to_decide_who_to_play_against.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;
argv.forEach((val, index) => {
if (index == 2) {
player_id = 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, KEY_ID, KEY_ID_IT);
/* player matching. */
let transactionId = await fcl.mutate({
cadence: `
import AwesomeCardGame from 0xf8d6e0586b0a20c7
transaction(player_id: UInt) {
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.matching_start(player_id: player_id)
}
execute {
log("success")
}
}
`,
args: (arg, t) => [
arg(player_id, t.UInt),
],
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
は全く同じ関数になっています。
マッチング開始処理をする
プレイヤーが画面上でPlay
ボタンを押したとします。
それをバックエンドで受け取って実行すると仮定します。コマンドは以下とします。
node ../backend/send_to_decide_who_to_play_against.js 1
Output:
トランザクションはエラーなくブロックに封印されました。
ではplayer_id=1(emulator_account)の画面を表示します。
現在のゲーム進捗状況からわかること
上記結果から分かることは以下です。
- ゲーム進捗状況に、最後にマッチング開始した時間が表示されました。
- マリガンカードを擬似乱数生成関数を使って生成するコードも実行されました。
なぜ擬似乱数生成関数をこのタイミングで実行しているかというと、マッチングは2パターンあるからです。マッチングしようと思ったけど他に人がいなかった場合と、マッチングしようとしてすぐに人が見つかった場合です。前者も誰かがマッチング処理を開始した時点でマッチングが成立するので、擬似乱数生成関数はどちらの場合でもこのタイミングで実施してその結果を使ってマリガンカードリストを保存しておく必要がありました。
少しわかりにくいので、最後にマッチング開始した時間を人間が見える形にします。
現在時刻を取得するgetCurrentBlock().timestamp
は、秒以下がスラッシュされていますので、Date関数の引数に渡すときは * 1000
して渡します。
このままではずっと対戦相手が決まらないのでもう1プレイヤーにもマッチング処理をしてもらいます。既に1分以上経っているので、時間を空けて2人分のコマンドを実行します。
node ../backend/send_to_decide_who_to_play_against.js 1
# (Proposerは同じKeyIDで連続トランザクションを行えないので少し時間を空ける)
node ../backend/send_to_decide_who_to_play_against.js 2
ではplayer_id=1(emulator_account)の画面を表示します。
結果:
[object Object]
ではわかりにくいので少し加工しています。
上記結果から分かることは以下です。(2人とも同じニックネームなのは忘れて下さい)
- 対戦相手が確定したのでゲーム内通貨(30 cyber_energy)が徴収されました。
- player_id:1が先攻、player_id:2が後攻。
すごいですよね、スマートコントラクトだけでマッチング処理が モダン言語によるロジック を記述するだけでできるのです。FlowとCadenceはもっと知られるべきだと思います。 (CadenceはSwift言語に似ています。if let
文などは、if let swift
のように"swift"を付けてGoogle検索すると情報が出てきます。)
もっと高度な、GraphQLによる対戦相手と情報を共有しながらリアルタイム対戦を行える、トランザクションを実施したいという場合には、こちらが参考になるでしょう。(本は薄めですが、無駄を省いてピンポイントでコードを参照出来るようにした為です。ですので、レビューを滅茶甘めで付けていただけると喜びます😭)
この記事のソースコードはこちらにあります。