0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブロックチェーンゲームの作り方8 デッキ編集

Last updated at Posted at 2025-01-02

Previous << 7 - ゲーム内通貨購入
Next >> 9 - マッチング処理

Adminリソースとは何のこと?

前回Adminリソースという言葉が出ました。Day3では「システムを作った人だけができる処理、というのが探してもありません」と書きました。ではどうやってシステム管理者だけが動かせるロジックを書くのでしょうか。

これはCadence言語の特長を捉えることによって、それを可能とする実装を生み出すことができます。Cadence言語の特徴は以下です。

  • リソースはスマートコントラクトの中でだけcreateできる
  • リソースのメソッドはCapabilityを公開しない限りリソースを持つアカウント本人しか実行できない
  • コントラクト、リソースはそのプロパティ(Flowではフィールドと呼ぶ)を定義している場合、初期化のinit関数を必ず実行しなければならない

管理者のみが実行できるロジックは上記を工夫することで生み出せます。Adminというリソースは定義してもスマートコントラクト内にcreateしている場所がなければ、誰もそのリソースを持つことができないのと同じことです。Adminリソースをcreateするコードがコントラクトの init 関数の中にだけある場合、そのロジックはスマートコントラクトをデプロイする時の一度だけしか呼ばれません。ですから、その場合Adminリソースは一つだけしか存在しなくなります。

こう工夫することで、Adminというリソースが一つだけ存在し、しかもスマートコントラクトをデプロイしたシステム管理者だけが持つことができます。

これはFlowブロックチェーンの作者が安全を考え、強力なメソッドを実装するのは危険と判断した結果、このような実装スタイルになりました。(彼は高校時代日本に留学していたので、無駄をしない日本人らしさが出ていますね)

TL;DR 今回、Adminリソースを使って大量のトランザクションを自動化できるようにします。

Why Flow

FLOW(または$FLOW)トークンは、Flowネットワークのネイティブ通貨です。開発者およびユーザーは、FLOWを使用してネットワーク上で取引(transact)を行うことができます。開発者は、ピアツーピア決済(他人同士の決済)、サービス料金徴収、または消費者向け特典(rewards)のために、FLOWを直接アプリに統合することができます。

ということでやっていきます、P2P(ピアツーピア)決済アプリ開発!

💡もし、エミュレータの起動方法やスマートコントラクトのデプロイについて操作に自信がない場合はこちらを参照してください。

現在のデッキを表示する

カードゲームで使用する自分のカードリストのことをデッキといいます。

AwesomeCardGame.cdcPlayerリソースの定義に以下のようにget_player_deckメソッドを追加します。

AwesomeCardGame.cdc
           :

  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 deck = のところで使われています。CadenceはSwift言語に非常に似ているので if let swift でGoogle検索すると情報が出てきます。何か目新しい文法が出てきたらSwiftで検索してみるといいかもしれません。

if let 文は、もし値があれば、左辺に値が入り、括弧内の処理が行われます。その時、左辺の変数を利用することができます。スマートコントラクトでは価値を持つ資産を扱う関係上、値があるかどうか、は非常に大事になってきます。それを確実に条件分岐で処理する方法として if let 文はスマートコントラクトの中でたくさん出てきます。

if let 文以外で値を抽出しようとすると、それが安全でない可能性が高いため、エラーが出る可能性が高いです。万が一にも資産を失うことがないように、if let文で安全にロジックを書くことが推奨されています。


starterDeckDay6の時点で既にAwesomeCardGame.cdcに実装していましたが、playerDeckはまだ実装していませんでしたので追記します。

AwesomeCardGame.cdc
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で取得するコードは以下のようになります。

src/scripts.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とロジックを書く

メイン画面のロジックを以下のようにします。

+page.svelte
<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}

これで画面を表示します。

結果:
スクリーンショット 2025-01-02 15.58.57.png

カード画像も何もなくてすみませんが、デッキのカードID一覧を取得できました。

まだデッキ編集をしていないのでスマートコントラクトAwesomeCardGameinit関数で定義したstarterDeckのデータが表示されています。

デッキを編集する

デッキ編集のトランザクションはバックエンドで実施します。

新規登録ゲーム内通貨購入はアカウントのストレージにアクセスする必要がありました。その為、ユーザー自身にウォレット上でApproveを押してもらう必要がどうしてもありました。

しかし、デッキ編集はスマートコントラクト内のデータを修正するだけなので、バックエンドでNode.jsを使ってトランザクションを行うことが出来ます。これによってユーザーの負担を和らげることが出来ます。

これを実現するためにはAdminリソースを作成する必要があります。Adminリソースはスマートコントラクトのデプロイ時にinit関数内で作成したもので、(Admincreateするコードが他の場所にない場合)スマートコントラクトのデプロイ者にだけ許される処理を実装することが出来ます。

実際ゲームコントラクトの実装のほとんどは、このAdminリソースの関数が占めることなります。

Adminリソースの作成をAwesomeCardGame.cdcに追加していきます。

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方式のトランザクションを書きます。

/backend/send_save_deck.js
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をPayerAuthorizationsと分けて実装する必要が出てきて、本番環境では数百のKey(公開鍵)を秘密鍵から作成しておき、それにKeyIDを紐づけておきます。Adminリソースのように何度も呼ばれるファイルにはこのKeyIDをローテーションさせます。ここではエミュレータですのでパソコンを連打しない限り大丈夫なのでKeyIDを0固定にしました。結果、authorizationFunctionProposerauthorizationFunctionは全く同じ関数になっています。

デッキ編集処理をする

プレイヤーが以下のデッキに変更したいとしてデッキ編集ボタンを押したとします。

[
   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:
スクリーンショット 2025-01-02 18.12.49.png
UInt16がDeprecatedだという警告が上がっていますが、トランザクションはエラーなくブロックに封印されました。

ではplayer_id=1(emulator_account)の画面を表示します。

結果:
スクリーンショット 2025-01-02 18.15.19.png

スターターデッキではなく、ユーザーが指定したデッキが画面上に表示されました。

プレイヤーは画面上でデッキ編集ボタンを押しただけですが、スマートコントラクトに保存されましたので、このNon-Custodial方式の威力を分かっていただけたかと思います。これだけですので全く難しくありません。


ハンヅオンで学べる書籍もあります。 こちら↗︎ (Version1.0ではないですが、先端技術を使用します!)
特色: ブロックチェーンのトランザクションは送信から情報取得までに7-10秒かかります。それを時間を感じさせなくする為には、バックエンドにGraphQLサーバーを採用することが考えられます。GraphQLは大人数による同時接続通信が可能なので、ゲームで何をしたのかを対戦相手に前もって通知することができるからです。(YouTube動画の右下部分、またはここ。)
GraphQL はAWS Lambdaと相性が良く(特に金銭面で)、ブロックチェーンにもスムーズにトランザクションを送れるという利点があります。GraphQLはNode.jsで動作し、そのためAWS Lambdaで動きます。そしてAWS Lambdaがreturnする値を現在ブラウザに接続しているすべての端末に対して、Push送信することができます。Svelteに関しては他にも良い書籍がありますが、AWS LambdaをGraphQLサーバーとして立ち上げる方法をきちんと図説している情報媒体は少ないはずです(僕は英語でAWSの開発陣から知りました)。インフラを自分で立ち上げたことがある人なら、この本を読みながら簡単に低コストのバックエンドをセットアップすることができます。
(個人出版ですので帯とかはありませんが、GraphQLの概念や実装はシンプルですので、ブロックチェーンゲームを誰でも作れるようになります。レビューは極甘で...お願いします🙇🙇‍♂️)

(English ver.▶️入門書もあります。入門書以外は実務書ですのでご注意ください。)

この動画のロジックは全てスマートコントラクトに書かれてます。画面上に出ている情報は全てブロックチェーンから取得したものです。(注:画像ファイルはSvelteフレームワークで管理しています)

この記事のソースコードはこちらにあります。


Previous << 7 - ゲーム内通貨購入

Flow blockchain / Cadence version1.0ドキュメント

Next >> 9 - マッチング処理

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?