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?

ブロックチェーンゲームの作り方10 マリガンおよびゲーム開始

Last updated at Posted at 2025-01-03

Previous << 9 - 対戦相手のマッチング
Next >> 11 - ターンチェンジ

スマートコントラクトにはEventsというのがあります。イーサリアムが発祥ですが、Flow, Sui, Aptos などモダン言語は全てEventsを備えてあります。Eventsはブロックチェーンで何が起きているか監視にも使えますが、蓄積して統計データとして提供するサービスにも使えます。

例えば、ブロックチェーンゲームで最初の手札をEventsでゲームのIDと一緒に飛ばしたとします。(スマートコントラクト上部で定義し、あとはそれを呼び出す(emit)だけです)

そのゲームのIDで勝敗が決まった時にもEventsを飛ばせば、それを監視・収集すれば、どのカードを初手に持ってきた時に勝率がよいのか統計を得られます。ブロックチェーンですから誰でもこのようなサービスを展開できます。

一旦面白いブロックチェーンゲームがモダン言語で出れば、コミュニティの人達が役立ちそうなサービスを、彼ら自身で展開する事が可能です。

コミュニティが費用面等の兼ね合いでDBに蓄えないのであれば、公式が蓄積し、そのデータをサービスにすることができます。

このページではゲーム開始トランザクションでEventsの処理を省いていますが、有効ですのでEventsの併用がおすすめです。

Why Flow

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

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

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

マリガンをする

対戦相手が確定したので、スマートコントラクトからマリガンカードリストを取得します。

AwesomeCardGame.cdc
           :

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

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

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

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

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

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

乱数はうまくいっていそうです。

スマートコントラクトのゲームを開始するロジック

マリガンでカードを4回まで引き直しできて、それが決定したら、ゲームスタートになります。

ゲームスタートロジックを実装します。

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

/backend/send_game_start.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 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:
スクリーンショット 2025-01-03 9.42.33.png
トランザクションはエラーなくブロックに封印されました。

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

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

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

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

上記結果から分かることは以下です。

  • ハンドのカードが[16,20,4,3]になっています(keyはハンドのポジション)。と思ったけど20のはずが21になってますね。デッキから取り除く時に位置がズレるバグがあるようです。。
  • ハンドのカードがデッキから取り除かれています。
  • game_startedがtrueになっています。

CadenceスマートコントラクトにはArrayやDictionaryの組み込み関数が多く、言語の文法がモダンで実装が非常にしやすかったです。Swiftに近いと言われますが、Javascriptの知識だけでも十分にサクサクと開発ができます。


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

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

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


Previous << 9 - 対戦相手のマッチング

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

Next >> 11 - ターンチェンジ

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?