Solana RPG游戏开发实战
在本文中,我们将在 Solana 上构建一款 RPG 游戏。 我们将通过分解每个步骤来涵盖应用程序的完整技术栈:程序、nft 集合掉落、前端和游戏。
一键发币: SUI | SOL | BNB | ETH | BASE | ARB | OP | POLYGON | AVAX | FTM | OK
Solana 是最令人兴奋的 Layer 1 区块链平台之一。 与以太坊不同,它的共识是通过使用权益证明和历史证明来实现的。 部署在链上的代码称为程序(program)。 在以太坊中,它们被称为智能合约。 Solana与以太坊采取了不同的方法,这意味着我们还需要学习新的逻辑来构建 Solana。 在 Solana 上进行构建非常困难。 它被 Solana 建设者称为“咀嚼玻璃”。
无需担心。 在本指南中,我们将在 Solana 上构建一款 RPG 游戏。 我们将通过分解每个步骤来涵盖应用程序的完整技术栈:程序、nft 集合掉落、前端和游戏。
我们将从允许用户在链上存储游戏进度的程序开始。 我们将使用一种适合初学者的编程语言—Python。 这要归功于我们将与 Solana Playground Web 编辑器一起使用的新 Seahorse 框架。 我们创建了一个 NFT 收藏品。 所有玩家都可以免费铸造 NFT 来开始游戏。 然后我们将使用 kaboomjs 来创建游戏。 它是 Replit 的一个游戏库,允许我们使用 Javascript 来构建浏览器游戏,因此你无需下载任何游戏引擎(例如 Unity)来构建游戏。
1、使用的技术栈
我们将介绍的工具包括:
- Solana
Solana 以其快速且便宜而闻名。 它提供 web3js SDK、CLI、Solana 程序库 (SPL) 或用于查询 Solana 网络的接口。 我们可以用传统方式用 Rust 编写程序,也可以用 Anchor 编写程序。
Rust 的学习曲线陡峭,这使得新手很难在 Solana 上进行构建。 不用担心,因为我们将使用全新的 Seahorse 语言来构建程序。 它允许你使用 Python 编写 Solana 程序。 开发人员可以获得 Python 的易用性,同时仍然拥有与 Solana 链上每个 Rust 程序相同的安全保证。
- Thirdweb
Thirdweb 通过直观的仪表板和 SDK 来部署程序并与程序交互,从而简化了 Solana 开发工作流程。
- Kaboom
Kaboom 是一个 Javascript 游戏编程库,可帮助你使游戏变得快速且有趣。 借助 Replit 的 Kaboom,在浏览器中构建游戏从未如此简单。
让我们快速浏览存储库结构:
📦 dungeon3
├─ art_layers // Layers of PNGs to be use with Hashlips Engine
├─ program // Solana program to save player progress and typescript tests
├─ src
│ ├─ component
│ │ └─ kaboom // The RPG game
│ ├─ contexts // Wallet provider contex
│ ├─ hooks // Web3 methods and utilities
│ ├─ utils // Constants and the program IDL
│ ├─ App.tsx // The game page
│ └─ MintPage
└─ public
└─ assets // Game audios and images
2、开发环境设置
跳过你之前已经完成的任何步骤。
安装 Solana 工具套件以与 Solana 集群交互。 我们将使用它的命令行界面,也称为 CLI。 它提供对你的 Solana 帐户最直接、灵活且安全的访问。 安装后,你可能需要通过复制粘贴将显示在终端中的脚本来公开路径。 检查版本以验证是否已正确安装。
$ solana --version
solana-cli 1.13.0 (src:devbuild; feat:2324890699)
让我们使用 CLI 生成密钥对。 密钥对是用于访问帐户的公钥和相应的私钥。 公钥是你的地址,可以与朋友分享以接收资金,并且当你向他们提供资产时,他们可以看到该地址。 你必须对自己的私钥保密。 它允许任何拥有它的人移动任何资产。
使用以下命令生成新的密钥对:
solana-keygen new
它将把一个 JSON 文件写入你计算机上的密钥对路径。 该文件包含一个字节数组。 一个字节只是 0 到 255 之间的一个数字。 运行即可找到路径。
$ solana config get
Config File: /Users/USERNAME/.config/solana/cli/config.yml
RPC URL: https://api.devnet.solana.com
WebSocket URL: wss://api.devnet.solana.com/ (computed)
Keypair Path: /Users/USERNAME/.config/solana/id.json
Commitment: confirmed
我们可以通过相应的关键字查看地址和余额。
solana address
solana balance
为了获得一些 SOL 进行测试,我们使用空投命令。 目前,每次调用的最大 SOL 设置为 2 SOL。 如果需要的话,我们可以通过拨打更多次来获得更多。
solana airdrop 2
我们不会使用 localnet,因此请确保 RPC URL 设置为 devnet
solana config set --url devnet
使用文件路径运行 cat 以显示其内容。 我们可以复制粘贴将其导入到 Phantom Wallet 中。 这样,我们就不用我们的主钱包了。
cat /Users/USERNAME/.config/solana/id.json
现在我们已经设置了钱包,下一步是创建程序以在区块链上存储游戏数据。
3、游戏程序
我们可以使用 3 个框架来创建 Solana 程序。 原生、anchor或最近发布的seahorse ,最常用的框架是 Anchor,因为它可以在 GitHub 和最著名协议的开源存储库中观察到。 Anchor 抽象了原生生活方式的很大一部分,但它仍然使用 Rust。
Rust 因其学习曲线高而以速度慢而闻名,并且由于代码非常冗长而导致写入速度慢,这也使其难以阅读。 它很复杂,这意味着开发的进入壁垒很高。 这会减慢开发速度并阻碍生态系统内的创新。
Solehorse 允许你使用 Python 编写 Solana 程序。 这是一个基于 Anchor 的社区主导项目。 与 Rust 代码完全互操作。 与 Anchor 的兼容性它尚未准备好投入生产,因为它仍处于测试阶段,这意味着存在错误和限制。 尽管如此,我们仍将探索它的简单性和潜力。
3.1 程序分解
前往 Solana Playground 并单击加号以创建一个新项目。 将其命名为 dungeon3
,选择最后一个选项 Seahorse(Python)
,然后按创建按钮。 将 fizzbuzz.py
的名称更改为 dungeon.py
并粘贴以下代码。
# calculator
# Built with Seahorse v0.2.1
from seahorse.prelude import *
# This is your program's public key and it will update
# automatically when you build the project.
declare_id('11111111111111111111111111111111');
class UserAccount(Account):
authority: Pubkey
score: u64
map: u8
health: u8
class ItemAccount(Account):
authority: Pubkey
id: u8
quantity: u64
exports: u8
@instruction
def init_user(authority: Signer, new_user_account: Empty[UserAccount]):
user_account = new_user_account.init(
payer=authority, seeds=['user', authority])
user_account.authority = authority.key()
user_account.score = 0
user_account.map = 0
user_account.health = 0
@instruction
def init_item(authority: Signer, new_item_account: Empty[ItemAccount], id: u8):
item_account = new_item_account.init(
payer=authority, seeds=['item', authority, id])
item_account.authority = authority.key()
item_account.id = id
item_account.quantity = 0
item_account.exports = 0
@instruction
def set_user(authority: Signer, user_account: UserAccount, score: u64, map: u8, health: u8):
assert authority.key() == user_account.authority, "signer must be user account authority"
user_account.score = score
user_account.map = map
user_account.health = health
@instruction
def add_item(authority: Signer, item_account: ItemAccount):
assert authority.key() == item_account.authority, "signer must be user account authority"
item_account.quantity = item_account.quantity + 1
@instruction
def export_items(authority: Signer, item_account: ItemAccount):
assert authority.key() == item_account.authority, "signer must be user account authority"
item_account.quantity = 0
item_account.exports = item_account.exports + 1
让我们看看在这里做什么。 第一行导入类和函数定义,为编辑器提供自动完成功能并用作文档。
from seahorse.prelude import *
declare_id
是程序的公钥。
# This is your program's public key and it will update
# automatically when you build the project.
declare_id('11111111111111111111111111111111');
我们派生基本 account
类型来创建计划帐户。 我们创建了 2 个帐户。 第一个是UserAccount,用于存储用户的进度; 第二个是 ItemAccount,用于记录可以导出为 NFT 的用户物品。
class UserAccount(Account):
authority: Pubkey
score: u64
map: u8
health: u8
使用@instruction装饰器,我们将函数转换为指令。 如果您使用过 Anchor,您应该知道帐户是分开的并放入帐户上下文结构中。 在 Seahorse 中,我们没有帐户上下文。 指令的参数可以包括账户参数和常规参数。
我们传入了一个 Signer,即使用此指令调用签署交易的钱包。 它经常被用作账户付款人或种子。
对于第二个帐户 new_user_account
我们用 Empty 包装该帐户,以指示它将由该指令初始化。 付款人表明谁将支付租金。 在 Solana 上,所有帐户都需要支付费用来为帐户数据分配空间。 如果发起金额等于 2 年以上租金,则可免租。 如果我们关闭帐户,该金额是可以收回的,但这超出了本教程的目标范围。 种子使我们能够确定地生成帐户地址。 启动后,我们设置默认值。
@instruction
def init_user(authority: Signer, new_user_account: Empty[UserAccount]):
user_account = new_user_account.init(
payer=authority, seeds=['user', authority])
user_account.authority = authority.key()
user_account.score = 0
user_account.map = 0
user_account.health = 0
切换到 Solana Playground 中的第二个选项卡。 复制你的地址并为其提供资金。
$ solana airdrop 2 <YOUR_ADDRESS>
单击“构建”,然后单击“部署”以将程序发送到 devnet。 打开“程序凭据”并复制程序 ID。 将其保存在某处,因为它需要作为前端的环境变量。
现在是测试该程序的时候了。
3.2 程序测试
在第三个选项卡中,我们可以试用该程序。 它分为两部分:说明和帐户。 在第一部分中,你可以通过传递所需的帐户和参数来调用所有可用的函数。 在第二部分中,你可以获取帐户数据。
我们还可以使用测试文件自动执行测试,你可以在文件资源管理器选项卡的“客户端”部分下找到该文件。
你可以先尝试编写自己的测试。 这非常简单。 该编辑器具有自动完成功能,可以帮助我们了解可以使用什么。 fizzbuzz 程序中已经有一个示例。 否则,你可以粘贴我的。
// No imports needed: web3, anchor, pg and more are globally available
describe("Test", async () => {
// Generate the account public key from its seeds
const [userAccountAddress] = await web3.PublicKey.findProgramAddress(
[Buffer.from("user"), pg.wallet.publicKey.toBuffer()],
pg.PROGRAM_ID
);
it("init user", async () => {
// Send transaction
const txHash = await pg.program.methods
.initUser()
.accounts({
newUserAccount: userAccountAddress,
})
.rpc();
console.log(`Use 'solana confirm -v ${txHash}' to see the logs`);
// Confirm transaction
await pg.connection.confirmTransaction(txHash);
// Fetch the account
const userAccount = await pg.program.account.userAccount.fetch(
userAccountAddress
);
console.log("Score: ", userAccount.score.toString());
console.log("Health: ", userAccount.health);
});
it("set user", async () => {
// Send transaction
const txHash = await pg.program.methods
.setUser(new BN(15000), 0, 1)
.accounts({
userAccount: userAccountAddress,
})
.rpc();
console.log(`Use 'solana confirm -v ${txHash}' to see the logs`);
// Confirm transaction
await pg.connection.confirmTransaction(txHash);
// Fetch the account
const userAccount = await pg.program.account.userAccount.fetch(
userAccountAddress
);
console.log("Score: ", userAccount.score.toString());
console.log("Health: ", userAccount.health);
});
});
userAccountAddress
是一个 PDA 或程序派生帐户,由我们启动帐户时传递的种子生成。 PDA 没有私钥,因此它们可以安全地存在于链上。
首先我们调用 iniUser
方法。 没有参数传递给 initUser()
。 然后我们传递指令将与之交互的帐户。 在本例中为用户帐户地址。 我们可以注意到, authority
、 systemProgram
和 rent
也是指令与之交互的帐户,因此需要它们,但我们毫无问题地省略了它们。 那是因为 Anchor 可以推断出变量,因此它们是可选的。
const txHash = await pg.program.methods
.initUser()
.accounts({
// authority: pg.wallet.publicKey,
newUserAccount: userAccountAddress,
// systemProgram: new PublicKey("11111111111111111111111111111111"),
// rent: new PublicKey("SysvarRent111111111111111111111111111111111")
})
.rpc();
在下一行中,我们等待交易已成功完成的确认。 因此,我们正在等待帐户成功创建并更新为默认值。
await pg.connection.confirmTransaction(txHash);
一旦确认,我们就可以获取帐户数据并记录结果。
const userAccount = await pg.program.account.userAccount.fetch(
userAccountAddress
);
console.log("Score: ", userAccount.score.toString());
console.log("Health: ", userAccount.health);
这对于我们稍后使用 Thirdweb Solana SDK 从前端实现合约调用非常有用。 正如概述开头所述,我们还希望用户铸造一个免费的 NFT 来访问游戏。 为了实现这一目标,我们将创建 NFT Collection Drop,以便人们可以领取它。
4、收藏品
玩家需要拥有我们的 NFT 才能玩游戏。 nfts 必须是可回收的。 为了实现这一点,我们将使用thirdweb nft drop程序。 第一步是创建设计的图层。 最流行的工具是 Photoshop、Illustrator 或 Figma。 对于本教程,你可以使用我的,可以从我的 Figma 文件中复制该文件,可以在其中进行任何您想要的更改,或者使用从 art_layers 文件夹导出的 PNG。
为了组合图层,我们将使用 Hashlips 艺术引擎。 为了使它更好,我们将使用 Waren Gonzaga 为 Thirdweb 改编的修改版本。 克隆存储库。
git clone https://github.com/warengonzaga/thirdweb-art-engine.git
删除图层文件夹内的所有文件夹。 把我们艺术作品的文件夹放在那里。 转到 src 文件夹下的配置文件。 在此更改你的集合的名称前缀和描述。 将 layersOrder
更改为你放置在layers文件夹中的文件夹,并将 growEditionSizeTo
更改为你想要生成的PNG数量。
使用yarn安装依赖项并运行:
yarn generate && yarn generate_thirdweb
4.1 设置钱包
从此处安装 Phantom 钱包 Chrome 扩展。按照 Phantom 指示的说明创建一个新钱包。 确保 12 个单词的恢复短语的安全非常重要。 切换到DEVNET。
如果你打算直接使用这个钱包,可以复制你的钱包地址并打开终端运行solana cli来给自己空投2 SOL。
solana airdrop 2 WALLET_ADDRESS
我所做的是导入从上面设置部分所示的终端生成的第二个钱包,因此我将持有真实资产的钱包和第二个钱包分开,出于安全考虑,第二个钱包仅用于测试目的。 通过运行打开密钥对 json 文件
$ solana config get
...
Keypair Path: /Users/USERNAME/.config/solana/id.json
要打开该路径,请按住 cmd 或 CTRL 键并单击它。您还可以通过文件资源管理器转到该路径来访问该文件。 内容如下所示 [12,21,45]。 在 Phantom 钱包中,再次单击 图标和您的钱包名称。 单击“添加/连接钱包”->“导入私钥”并将其粘贴到此处进行导入。
4.2 部署并上传
前往 Thirdweb 仪表板。 链接到你的 Phantom 钱包。 单击部署新程序按钮。 选择 NFT 掉落。 给它一个名字; Dungeon3。 将总供应量设置为我们生成的 NFT 数量。 将网络更改为开发人员,然后单击“立即部署”。
使用批量上传,拖放Hashlips引擎生成的文件夹,或单击选择文件。
部署程序时,你需要上传与直接供应集匹配的所有 NFT。 设置回收条件以使用户能够开始回收。 在回收条件选项卡中,我们可以更改起始时间、特许权使用费以及收费金额。 我将可领取的 NFTS 总数设置为最大供应量,其余部分保持原样。 然后我们点击保存回收条件。
5、铸造页面
设置环境变量。 所需的变量可以在 .env.example
中找到。
- Program ID来自我们一开始部署的程序。
- Collection Address可以从 Thirdweb Dashboard 复制,更具体地说是我们为集合创建的 nft drop。
- RPC URL可以通过创建 Alchemy 帐户,然后创建应用程序来获得它,然后可以从仪表板复制 url。
项目从 pnpm 开始。 npm 和yarn 的命令是相同的。 要安装依赖项,请运行
pnpm install
并在本地启动项目:
pnpm dev
5.1 路由
我们希望用户从他们的收藏中铸造一个 nft 才能玩游戏。 在 react-router-dom
的帮助下,我们用react创建了两个页面。 铸造页面会检查用户的 nft,如果用户还没有 nft,我们允许用户铸造一个。 用户连接 Phantom 钱包并铸造一个免费的 nft。 一旦铸造完成,用户就可以玩游戏。
使用 createBrowserRouter
在 main.tsx 中创建路由器,并将路由器传递给 RouterProvider
。
import "@solana/wallet-adapter-react-ui/styles.css";
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import App from "./App";
import { ContextProvider } from "./contexts/ContextProvider";
import MintPage from "./MintPage";
import "./styles/globals.css";
const router = createBrowserRouter([
{
path: "/",
element: <App />,
},
{
path: "mint",
element: <MintPage />,
},
]);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<ContextProvider>
<RouterProvider router={router} />
</ContextProvider>
</React.StrictMode>
);
路径 mint 将用户带到 mint 页面 MintPage.tsx。
import MintPage from "./MintPage";
// ...
{
path: "mint",
element: <MintPage />,
},
它应该运行在 http://127.0.0.1:5173/mint
5.2 铸造NFT
MintPage.tsx:
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import "@solana/wallet-adapter-react-ui/styles.css";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import useProgram from "./hooks/anchor";
import useTw from "./hooks/tw";
export default function MintPage() {
const { publicKey } = useWallet();
const navigate = useNavigate();
const { nftDrop, hasNft } = useTw();
const { initUserAnchor } = useProgram();
/**
* Check if the wallet has NFT
* Go to the game page if we find it.
*/
useEffect(() => {
if (hasNft === 1) {
navigate("/");
}
}, [hasNft]);
const mint = async () => {
if (!nftDrop || !publicKey) return;
try {
// Claim 1 NFT
const claimedAddresses = await nftDrop.claim(1);
console.log("Claimed NFT to: ", claimedAddresses[0]);
// Initialize user account
await initUserAnchor();
navigate("/");
} catch (error) {
alert("something went wront :(");
}
};
return (
<>
<div className="flex justify-around">
<div className="self-center">
<h2 className="font-bold">Dungeon3</h2>
</div>
<WalletMultiButton className="btn btn-primary" />
</div>
<div className="h-screen">
<div className="flex flex-col gap-3 h-[inherit] items-center justify-center">
<h2 className="font-bold">Dungeon3</h2>
<img src="/hero.png" alt="dungeon3" className="w-60" />
<span>Mint your Hero</span>
<button className="btn btn-primary" onClick={mint}>
Mint
</button>
</div>
</div>
</>
);
}
通过钱包按钮组件,玩家可以将钱包连接到 Solana 区块链。
<WalletMultiButton className="btn btn-primary" />
然后我们有另一个按钮,允许用户铸造 NFT。
<button className="btn btn-primary" onClick="{mint}">Mint</button>
该页面将首先检查用户是否拥有该集合的 NFT。
/**
* Check if the wallet has NFT
* Go to the game page if we find it.
*/
useEffect(() => {
if (hasNft === 1) {
navigate("/");
}
}, [hasNft]);
mint
函数从 useTw()
钩子调用 nftDrop
属性
const { nftDrop } = useTw();
// ...
const mint = async () => {
if (!nftDrop || !wallet.publicKey) return;
try {
// Claim 1 NFT
const claimedAddresses = await nftDrop.claim(1);
console.log("Claimed NFT to: ", claimedAddresses[0]);
// Initialize user account
await initUserAnchor();
navigate("/");
} catch (error) {
alert("something went wront :(");
}
};
nftDrop
由 Thirdweb SDK 发起。 SDK在用户连接钱包时初始化。
import { NETWORK_URL, TW_COLLECTION_ADDRESS } from "@/utils/constants";
import { useWallet } from "@solana/wallet-adapter-react";
import "@solana/wallet-adapter-react-ui/styles.css";
import { NFTDrop, ThirdwebSDK } from "@thirdweb-dev/sdk/solana";
import { useEffect, useMemo, useState } from "react";
export default function useTw() {
const wallet = useWallet();
const { publicKey } = wallet;
const [nftDrop, setNftDrop] = useState<NFTDrop>();
const [hasNft, setHasNft] = useState(-1);
// Initialize sdk with wallet when wallet is connected
const sdk = useMemo(() => {
if (publicKey) {
const sdk = ThirdwebSDK.fromNetwork(NETWORK_URL);
sdk.wallet.connect(wallet);
return sdk;
}
}, [publicKey]);
// Initialize collection drop program when sdk is defined
useEffect(() => {
async function load() {
if (sdk) {
const nftDrop = await sdk.getNFTDrop(TW_COLLECTION_ADDRESS);
setNftDrop(nftDrop);
}
}
load();
}, [sdk]);
useEffect(() => {
async function getHasNft() {
try {
if (publicKey !== null && nftDrop !== undefined) {
const nfts = await nftDrop.getAllClaimed();
const userAddress = publicKey.toBase58();
const hasNFT = nfts.some((nft) => nft.owner === userAddress);
if (hasNFT === undefined) {
setHasNft(0);
} else {
setHasNft(1);
}
}
} catch (error) {
console.error(error);
}
}
getHasNft();
}, [publicKey, nftDrop]);
return {
sdk,
nftDrop,
hasNft,
};
}
当应用程序获取用户钱包地址时,该钩子实例化 Thirdweb SDK 并获取 nft drop。 它将使用 getHasNft()
检查用户是否拥有该集合的 NFT。
if (publicKey !== null && nftDrop !== undefined) {
const nfts = await nftDrop.getAllClaimed();
const userAddress = publicKey.toBase58();
const hasNFT = nfts.some((nft) => nft.owner === userAddress);
if (hasNFT === undefined) {
setHasNft(0);
} else {
setHasNft(1);
}
}
当我们调用 mint
函数时,我们还调用了 initUserAnchor()
。
5.3 启动用户帐户
initUserAnchor()
是从 hooks/anchor.ts
导入的。 我们使用 Solana SDK 来获取锚点程序。 该代码对ni来说应该很熟悉。 我们粘贴了用于测试程序的代码,并进行了微小的更改。
import { PROGRAM_ID } from "@/utils/constants";
import { Dungeon3, IDL } from "@/utils/idl";
import { BN, Program } from "@project-serum/anchor";
import { useWallet } from "@solana/wallet-adapter-react";
import "@solana/wallet-adapter-react-ui/styles.css";
import { PublicKey } from "@solana/web3.js";
import { useEffect, useState } from "react";
import useTw from "./tw";
export type SetUserAnchor = (
score: number,
health: number
) => Promise<string | undefined>;
export default function useProgram() {
const wallet = useWallet();
const { sdk } = useTw();
const [program, setProgram] = useState<Program<Dungeon3>>();
useEffect(() => {
// Load program when sdk is defined
load();
async function load() {
if (sdk) {
const { program }: { program: Program<Dungeon3> } =
(await sdk.getProgram(PROGRAM_ID.toBase58(), IDL)) as any;
setProgram(program);
}
}
}, [sdk]);
const initUserAnchor = async () => {
try {
if (!program || !wallet.publicKey) return;
// Find user account. PDA
const [userAccountAddress] = await PublicKey.findProgramAddress(
[Buffer.from("user"), wallet.publicKey.toBuffer()],
PROGRAM_ID
);
// Send transaction
const txHash = await program.methods
.initUser()
.accounts({
newUserAccount: userAccountAddress,
})
.rpc();
console.log(`Use 'solana confirm -v ${txHash}' to see the logs`);
return txHash;
} catch (error) {
console.error(error);
return undefined;
}
};
const setUserAnchor = async (score: number, health: number) => {
try {
if (!program || !wallet.publicKey) return;
// Find user account. PDA
const [userAccountAddress] = await PublicKey.findProgramAddress(
[Buffer.from("user"), wallet.publicKey.toBuffer()],
PROGRAM_ID
);
// Send transaction
const txHash = await program.methods
.setUser(new BN(score), 0, health)
.accounts({
userAccount: userAccountAddress,
authority: wallet.publicKey,
})
.rpc();
console.log(`Use 'solana confirm -v ${txHash}' to see the logs`);
return txHash;
} catch (error) {
console.error(error);
return undefined;
}
};
return {
program,
initUserAnchor,
setUserAnchor,
};
}
6、游戏
Kaboom 是一个 Javascript 游戏编程库,可帮助您快速、有趣地制作游戏。 我们在 App.tsx 中启动 kaboom 并将上下文传递给 kaboom 组件。
游戏的所有静态资源都位于资源文件夹内。 声音文件夹包含在玩家操作时播放的 mp3 和 wav。 主要 PNG 位于 dungeon.png 文件中。 dungeon.json 文件定义了我们要从 dungeon.png 中提取的像素并定义了动画。
在开发过程中,我遇到了一个 class extends value undefined is not a constructor or null
的问题。
Polyfill 问题。 Metaplex SDK 的一些依赖项仍然依赖于默认情况下浏览器中不可用的 Node.js 功能。 我们正在通过 rollup 插件安装一些 polyfill,因为 Vite 在捆绑包的底层使用 rollup 进行生产。 Thirdweb Solana SDK 构建在 Metaplex 之上,这意味着 Metaplex 问题也得到了反映。 了解有关该问题的更多信息
这个存储库已经安装了polyfills,但是如果你必须使用create-react-app或vite启动一个新项目。 请记住,需要 Polyfill。
6.1 启动并加载资产
让我们从 App.tsx 文件开始分解游戏。
import { loadKaboom } from "@/components/kaboom";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import "@solana/wallet-adapter-react-ui/styles.css";
import kaboom from "kaboom";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import useProgram from "./hooks/anchor";
import useTw from "./hooks/tw";
export default function Home() {
const { hasNft } = useTw();
const navigate = useNavigate();
const { setUserAnchor, program } = useProgram();
// Check if the user has the nft.
// Go to the mint page if the user hasn't.
useEffect(() => {
if (hasNft === 0) {
navigate("/mint");
}
}, [hasNft]);
// Get the canvas where we are going to load the game.
const canvasRef = useRef(
document.getElementById("canvas") as HTMLCanvasElement
);
useEffect(() => {
// Start kaboom with configuration
const k = kaboom({
global: false,
width: 640,
height: 480,
stretch: true,
letterbox: true,
canvas: canvasRef.current,
background: [0, 0, 0],
});
loadKaboom(k, setUserAnchor);
}, [program]);
return (
<>
<div className="flex justify-around">
<div className="self-center">
<h2 className="font-bold">Dungeon3</h2>
</div>
<WalletMultiButton className="btn btn-primary" />
</div>
<canvas
id="canvas"
width={window.innerWidth - 160}
height={window.innerHeight - 160}
ref={canvasRef}
></canvas>
</>
);
}
当页面加载时,我们在 useEffect 内部启动来创建一个新的 kaboom 实例。 我们将画布尺寸设置为拉伸以适合容器,同时保持宽高比。
const k = kaboom({
global: false,
width: 640,
height: 480,
stretch: true,
letterbox: true,
canvas: canvasRef.current,
background: [0, 0, 0],
});
获取id为canvas的元素作为参考。 这允许 React 在画布组件内渲染游戏。 160px 充当边框的边距。
const canvasRef = useRef(
document.getElementById("canvas") as HTMLCanvasElement
);
// ... inside return
<canvas
id="canvas"
width={window.innerWidth - 160}
height={window.innerHeight - 160}
ref={canvasRef}
></canvas>;
我们传递 kaboom 上下文。
import { loadKaboom } from "@/components/kaboom";
// ... k = Kaboom Context
loadKaboom(k, setUserAnchor);
在 kaboom/index.ts 中我们有:
import { SetUserAnchor } from "@/hooks/anchor";
import { KaboomCtx } from "kaboom";
import { OLDMAN, OLDMAN2, OLDMAN3 } from "../../utils/constants";
import { Game } from "./game";
import { Home } from "./home";
export const loadKaboom = (k: KaboomCtx, setUserAnchor: SetUserAnchor) => {
const { go, loadSpriteAtlas, loadSound, loadSprite, play, scene } = k;
/**
* Load Sprites and Sounds
*/
loadSpriteAtlas("/assets/dungeon.png", "/assets/dungeon.json");
loadSprite(OLDMAN, "/assets/OldMan/SeparateAnim/Idle.png", {
sliceX: 4,
sliceY: 1,
anims: {
idle: {
from: 0,
to: 3,
},
},
});
loadSprite(OLDMAN2, "/assets/OldMan2/SeparateAnim/Idle.png", {
sliceX: 4,
sliceY: 1,
anims: {
idle: {
from: 0,
to: 3,
},
},
});
loadSprite(OLDMAN3, "/assets/OldMan3/SeparateAnim/Idle.png", {
sliceX: 4,
sliceY: 1,
anims: {
idle: {
from: 0,
to: 3,
},
},
});
loadSound("coin", "/assets/sounds/coin.wav");
loadSound("hit", "/assets/sounds/hit.mp3");
loadSound("wooosh", "/assets/sounds/wooosh.mp3");
loadSound("kill", "/assets/sounds/kill.wav");
loadSound("dungeon", "/assets/sounds/dungeon.ogg");
const music = play("dungeon", {
volume: 0.2,
loop: true,
});
scene("home", () => Home(k));
scene("game", () => Game(k, setUserAnchor));
function start() {
// Start with the "game" scene, with initial parameters
go("home", {});
}
start();
};
我们将在第一行中使用要使用的所有函数,以避免每次调用任何函数时都使用上下文。
const { go, loadSpriteAtlas, loadSound, loadSprite, play, scene } = k;
我们加载精灵和声音。 如果你问,什么是精灵? 精灵是代表游戏资产的图像。
/**
* Load Sprites and Sounds
*/
loadSpriteAtlas("/assets/dungeon.png", "/assets/dungeon.json");
loadSprite(OLDMAN, "/assets/OldMan/SeparateAnim/Idle.png", {
sliceX: 4,
sliceY: 1,
anims: {
idle: {
from: 0,
to: 3,
},
},
});
// ...
loadSound("coin", "/assets/sounds/coin.wav");
loadSound("hit", "/assets/sounds/hit.mp3");
loadSound("wooosh", "/assets/sounds/wooosh.mp3");
loadSound("kill", "/assets/sounds/kill.wav");
loadSound("dungeon", "/assets/sounds/dungeon.ogg");
const music = play("dungeon", {
volume: 0.2,
loop: true,
});
loadSpriteAtlas
是一个聚合多个图像的 PNG 文件,这就是为什么我们还必须传入一个 json 文件,该文件通过定义宽度和高度的大小来提取每个图像。 x 和 y 为其坐标sliceX 和 anims 为其帧并配置其动画。
"coin": {
"x": 288,
"y": 272,
"width": 32,
"height": 8,
"sliceX": 4,
"anims": {
"spin": {
"from": 0,
"to": 3,
"speed": 10,
"loop": true
}
}
},
对于loadSprite中的动画配置,我们可以作为第三个传入。 然后我们有 loadSound 来加载带有名称和资源 url 的声音。
loadSound("coin", "/assets/sounds/coin.wav");
加载资源后,我们可以通过指定的名称来调用它来使用它。
loadSound("dungeon", "/assets/sounds/dungeon.ogg");
play("dungeon", {
volume: 0.2,
loop: true,
});
创建 2 个场景。 主组件包含一个允许用户开始新游戏的菜单。 我们首先通过调用函数 start()
来显示 Home 组件。
scene("home", () => Home(k));
scene("game", () => Game(k, setUserAnchor));
function start() {
// Start with the "game" scene, with initial parameters
go("home", {});
}
start();
6.2 地图、人物、物品和逻辑
Home 组件与我们将在 Game 组件中介绍的元素基本相同,因此让我们直接转到 game.ts
文件。
让我们为游戏创建地图。 大多数游戏编辑器都带有可视化编辑器,允许我们将项目拖放到其中。 Kaboom 的工作方式有点不同。 我们用代码编写地图。 addLevel 需要两个参数。 在第一个参数中,我们定义要放置带有符号的游戏资产的位置。 你可以使用所有你能想象到的符号,加上数字、大写和小写。 然后在第二个参数中,我们定义大小并将每个符号与其代表的精灵相关联。 符号的工作方式类似于 HTML 标签,然后在为符号定义精灵时,就像将 CSS 样式添加到 HTML 标签一样。
/**
* Map
*/
// map floor
addLevel(
[
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
],
{
width: 16,
height: 16,
" ": () => [sprite("floor", { frame: ~~rand(0, 8) })],
}
);
// map walls, enemies, items, coins...
const map = addLevel(
[
" ",
"tttttttttttttttttttttttttttttttttttttttt",
"qwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwd",
"l r",
"l $ r",
"l r",
"l ccc ccc ccc ccc r",
"l r",
"l ccc ccc ccc r",
"4ttttttttttttttttttttttttttttttttttttt r",
"ewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww r",
"l r",
"l c r",
"l ccccccccc r",
"l c r",
"l r",
"l r",
"4ttttttttttttttttttttttttttttttttttttttr",
"ewwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwr",
"l r",
"l cccccccccccccccccccccccccccccccc r",
"l r",
"l cccccccccccccccccccccccccccccccc r",
"l r",
"l r",
"attttttttttttttttttttttttttttttttttttttb",
"wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww",
],
{
width: 16,
height: 16,
$: () => [sprite("chest"), area(), solid(), { opened: false }, "chest"],
c: () => [sprite("coin", { anim: "spin" }), area(), "coin"],
a: () => [sprite("wall_botleft"), area({ width: 4 }), solid()],
b: () => [
sprite("wall_botright"),
area({ width: 4, offset: vec2(12, 0) }),
solid(),
],
q: () => [sprite("wall_topleft"), area(), solid()],
4: () => [sprite("wall_topmidleft"), area(), solid()],
e: () => [sprite("wall_midleft"), area(), solid()],
d: () => [sprite("wall_topright"), area(), solid()],
w: () => [sprite("wall"), area(), solid()],
t: () => [
sprite("wall_top"),
area({ height: 4, offset: vec2(0, 12) }),
solid(),
],
l: () => [sprite("wall_left"), area({ width: 4 }), solid()],
r: () => [
sprite("wall_right"),
area({ width: 4, offset: vec2(12, 0) }),
solid(),
],
}
);
使用 add 函数,我们从组件列表中组装一个游戏对象并将其添加到游戏中。 在地图上定位自己。 精灵获取已加载精灵的 ID。 在loadSpriteAtlas中,我们已经在json文件中定义了所有的id。 碰撞区域定义碰撞区域并启用与其他对象的碰撞检测。 例如,这允许我们在玩家触摸硬币时增加玩家的硬币。 我们将其用于墙壁和敌人,这样玩家就无法像幽灵一样穿过它们。
/**
* Sprites
*/
const player = add([
pos(map.getPos(11, 11)),
sprite(HERO, { anim: "idle" }),
area({ width: 12, height: 12, offset: vec2(0, 6) }),
solid(),
origin("center"),
]);
const sword = add([
pos(),
sprite(SWORD),
origin("bot"),
rotate(0),
follow(player, vec2(-4, 9)),
area(),
spin(),
]);
const oldman = add([
OLDMAN,
sprite(OLDMAN),
pos(map.getPos(30, 12)),
origin("bot"),
area(),
solid(),
{ msg: "Save progress?" },
]);
const oldman2 = add([
OLDMAN2,
sprite(OLDMAN2),
pos(map.getPos(8, 20)),
origin("bot"),
area(),
solid(),
{ msg: "Save progress?" },
]);
const oldman3 = add([
OLDMAN3,
sprite(OLDMAN3),
pos(map.getPos(8, 4)),
origin("bot"),
area(),
solid(),
{ msg: "Save progress?" },
]);
const ogre = add([
"ogre",
sprite("ogre"),
pos(map.getPos(6, 14)),
origin("bot"),
area({ scale: 0.5 }),
solid(),
]);
const monster = add([
"monster",
sprite("monster", { anim: "run" }),
pos(map.getPos(4, 7)),
origin("bot"),
patrol(100),
area({ scale: 0.5 }),
solid(),
]);
const monster2 = add([
"monster",
sprite("monster", { anim: "run" }),
pos(map.getPos(24, 9)),
origin("bot"),
patrol(100),
area({ scale: 0.5 }),
solid(),
]);
对于 HUD(平视显示器),我们添加
/**
* HUD
*/
const counter = add([
text("Score: 0", { size: 18, font: "sinko" }),
pos(40, 4),
z(100),
fixed(),
{ value: 0 },
]);
const health = add([
sprite("health", { width: 18, height: 18 }),
pos(12, 4),
fixed(),
]);
然后我们创建角色和项目使用的定义动作的函数。
/**
* Logics
*/
// Spin the sword 360 degree
function spin() {
let spinning = false;
return {
angle: 0,
id: "spin",
update() {
if (spinning) {
this.angle += 1200 * dt();
if (this.angle >= 360) {
this.angle = 0;
spinning = false;
}
}
},
spin() {
spinning = true;
},
};
}
// Reduces the life of the player.
// Reset player stats and move to home if there is no life left.
function reduceHealth() {
switch (health.frame) {
case 0:
health.frame = 1;
break;
case 1:
health.frame = 2;
break;
default:
go("home");
counter.value = 0;
counter.text = "0";
health.frame = 0;
break;
}
}
// Make enemy to move left and right on collision
function patrol(speed = 60, dir = 1) {
return {
on: (obj: any, col: any) => console.log(),
move: (x: any, y: any) => console.log(),
id: "patrol",
require: ["pos", "area"],
add() {
this.on("collide", (obj: any, col: any) => {
if (col.isLeft() || col.isRight()) {
dir = -dir;
}
});
},
update() {
this.move(speed * dir, 0);
},
};
}
// Show a dialog box. The player can save their progress on-chain if accept.
function addDialog() {
const h = 160;
const btnText = "Yes";
const bg = add([
pos(0, height() - h),
rect(width(), h),
color(0, 0, 0),
z(100),
fixed(),
]);
const txt = add([
text("", {
size: 18,
}),
pos(vec2(300, 400)),
scale(1),
origin("center"),
z(100),
fixed(),
]);
const btn = add([
text(btnText, {
size: 24,
}),
pos(vec2(400, 400)),
area({ cursor: "pointer" }),
scale(1),
origin("center"),
z(100),
fixed(),
]);
btn.onUpdate(() => {
if (btn.isHovering()) {
btn.scale = vec2(1.2);
} else {
btn.scale = vec2(1);
cursor("default");
}
});
btn.onClick(() => {
setUserAnchor(counter.value, health.frame);
});
bg.hidden = true;
txt.hidden = true;
btn.hidden = true;
return {
say(t: string) {
txt.text = t;
bg.hidden = false;
txt.hidden = false;
btn.hidden = false;
},
dismiss() {
if (!this.active()) {
return;
}
txt.text = "";
bg.hidden = true;
txt.hidden = true;
btn.hidden = true;
},
active() {
return !bg.hidden;
},
destroy() {
bg.destroy();
txt.destroy();
},
};
}
const dialog = addDialog();
这里需要注意的一件事是玩家与老人互动的时候。 玩家可以通过调用 setUserAnchor(counter.value, health.frame)
来保存游戏进度; 功能。
定义当玩家接触敌人或物品时会发生什么。
/**
* on Player Collides
*/
// Reduce the player life when collides with the ogre enemy
player.onCollide("ogre", async (obj, col) => {
play("hit");
reduceHealth();
});
// Increase the score when the player touch a coin. Make disappear the coin.
player.onCollide("coin", async (obj, col) => {
destroy(obj);
play("coin");
counter.value += 10;
counter.text = `Score: ${counter.value}`;
});
// Reduce the player life when collides with the monster enemy
// Move the player a fixed distance in the opposite direction of the collision.
player.onCollide("monster", async (obj, col) => {
if (col?.isRight()) {
player.moveBy(-32, 0);
}
if (col?.isLeft()) {
player.moveBy(32, 0);
}
if (col?.isBottom()) {
player.moveBy(0, -32);
}
if (col?.isTop()) {
player.moveBy(0, 32);
}
if (col?.displacement) play("hit");
reduceHealth();
});
// When the sword collides with ogre, kill it and receive 100 coins.
sword.onCollide("ogre", async (ogre) => {
play("kill");
counter.value += 100;
counter.text = `Score: ${counter.value}`;
destroy(ogre);
});
// Start a dialog with the old man on contact.
player.onCollide(OLDMAN, (obj) => {
dialog.say(obj.msg);
});
// Start a dialog with the old man on contact.
player.onCollide(OLDMAN2, (obj) => {
dialog.say(obj.msg);
});
// Start a dialog with the old man on contact.
player.onCollide(OLDMAN3, (obj) => {
dialog.say(obj.msg);
});
将相机设置为变焦并跟随玩家、动作和动画。
/**
* Player Controls
*/
// Follow the player with the camera
camScale(vec2(2));
player.onUpdate(() => {
camPos(player.pos);
});
// Press space to spin the sword
// Open a chest if the player is touching it.
onKeyPress("space", () => {
let interacted = false;
every("chest", (c) => {
if (player.isTouching(c)) {
if (c.opened) {
c.play("close");
c.opened = false;
} else {
c.play("open");
c.opened = true;
counter.value += 500;
counter.text = `Score: ${counter.value}`;
}
interacted = true;
}
});
if (!interacted) {
play("wooosh");
sword.spin();
}
});
// Player movement controls
onKeyDown("right", () => {
player.flipX(false);
sword.flipX(false);
player.move(SPEED, 0);
sword.follow.offset = vec2(-4, 9);
});
onKeyDown("left", () => {
player.flipX(true);
sword.flipX(true);
player.move(-SPEED, 0);
sword.follow.offset = vec2(4, 9);
});
onKeyDown("up", () => {
player.move(0, -SPEED);
});
onKeyDown("down", () => {
player.move(0, SPEED);
});
// Player animation while stationary and in motion
onKeyRelease(["left", "right", "up", "down"], () => {
player.play("idle");
});
onKeyPress(["left", "right", "up", "down"], () => {
dialog.dismiss();
player.play("run");
});
7、结束语
你做得很棒! 我知道内容很丰富,你已经坚持到最后了! 该应用程序不完整; 它是 Web3 应用程序开发的起点。 根据所获得的知识,你可以继续前进,建立自己的想法。 在这里,我将为你留下一些可以添加到应用程序中的功能:
- 从用户帐户导入游戏进度。
- 获取 NFT 集合元数据并将项目添加到游戏中。
- 将游戏资产导出为 NFT,或将币导出为代币。
- 添加更多物品、关卡或敌人。
- 使用 NFT 角色作为具有不同属性的可玩英雄。
原文链接:Build an RPG game on Solana
DefiPlot翻译整理,转载请标明出处
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。