MolliNalli 是一个基于 Monad 的游戏,它的设计思路来源于知名桌游 德国心脏病。
玩家需要通过快速判断三张牌上的相同动物的数量是否为 4 的倍数,如果是,那么就按下 Ring Bell,如果不是,那么就按下 Pass
首先我们需要clone当前仓库。
git clone https://github.com/monad-developers/MolliNalli.git
cd MolliNalli
当前默认分支应该为 starter
。
因为当前是一个Monorepo,所以我们只需要执行 yarn install
即可。
yarn install
此时,基础的依赖已经安装完成。接下来可以开始编写游戏逻辑。
因为原版的德国心脏病是一个多人游戏,所以我们在设计的时候也会加上多人的功能,并且可以在未来添加排行榜这种功能。不过在这个版本中,我们先不实现。
因此,我们先构建一个合约,来存放游戏逻辑。我们在 packages/foundry/contracts
下创建文件 MolliNalli.sol
。
touch packages/foundry/contracts/MolliNalli.sol
并且简单的编写一个空的合约代码。并且我们设置一下游戏的各种常量。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;
contract MolliNalli {
// 常量设置
uint8 public immutable MAX_PLAYERS = 4; // 最多4人
uint256 public immutable CARD_MASK = type(uint32).max; // 卡牌掩码,稍后解释
uint8 public immutable MAX_ACTION = 30; // 每个玩家最多30轮,稍后解释
}
好了,轻松愉快的部分结束了,我们现在来完成第一个目标,实现一个多人游戏加入功能。
这个功能应该通过几个方式实现,首先我们要可以判断游戏的状态,比如是否游戏中,是否等待中。因此我们需要有一个状态变量,并且还要防止玩家多次加入,因此我们也需要存储已经加入的玩家。这已经是一个最基础的版本。
对了,可别忘了一些错误的定义和事件的定义。
// 之前的代码 ...
enum GameStage {
NOT_START, // 未开始
PLAYING // 游戏中
}
error ErrorStarted(); // 游戏已开始
error ErrorIsFull(); // 游戏已满
error ErrorNotAdmin(); // 非管理员
error ErrorNotPlayer(); // 非玩家
error ErrorJoined(); // 已加入
error ErrorEnded(); // 游戏已结束
error ErrorNotPlaying(); // 非游戏中状态
error ErrorOutPlayer(); // 玩家已经出局
contract MolliNalli {
// ...
struct Player {
bool isReady; // 是否准备
bool out; // 是否出局
}
// 状态变量设置
GameStage public stage = GameStage.NOT_START;
mapping(address => Player) public players;
address[] public playersAddr;
event GameStarted(address[] players, uint256 seed); // seed是我们游戏中生成牌的种子,用于后续的牌的生成
event GameEnded(address indexed playerAddr, Player player, uint256 endTime);
// 多人游戏加入函数实现
/**
* 获取玩家信息
* @param playerAddr 玩家地址
* @return Player 玩家信息
*/
function getPlayer(address playerAddr) public view returns (Player memory) {
return players[playerAddr];
}
/**
* 加入游戏
*/
function joinGame() external {
// 如果游戏已经开始则报错
if (stage != GameStage.NOT_START) {
revert ErrorStarted();
}
// 超过最大玩家则报错
if (playersAddr.length >= MAX_PLAYERS) {
revert ErrorIsFull();
}
// 如果玩家已经加入则报错
if (players[msg.sender].isReady) {
revert ErrorJoined();
}
// 设置游戏初始状态
players[msg.sender] =
Player({ isReady: true, out: false });
playersAddr.push(msg.sender);
}
/**
* 启动游戏
*/
function startGame() external isPlayer {
// 如果游戏已经开始则报错
if (stage == GameStage.PLAYING) {
revert ErrorStarted();
}
stage = GameStage.PLAYING;
setup();
}
/**
* 游戏启动的初始化配置
*/
function setup() private {
// 初始化玩家状态
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
Player storage player = players[playerAddr];
// TODO: generate seed
}
emit GameStarted(playersAddr, seed);
}
// 判断是否是玩家,并且没有出局
modifier isPlayer() {
require(players[msg.sender].isReady, ErrorNotPlayer());
require(players[msg.sender].out == false, ErrorOutPlayer());
_;
}
// 判断是否在游戏中
modifier isPlaying() {
require(stage == GameStage.PLAYING, ErrorNotPlaying());
_;
}
}
这样我们一个多人系统就设计完成了,但是现在还没有实现游戏逻辑,因此这部分的逻辑还需要继续完善,我们往下走。
我们一开始说了游戏是一个致敬德国心脏病的游戏,因此游戏逻辑一定会判断是否有指定数量的卡,在这个游戏中,我们要求玩家需要对三张牌进行判断,每张牌最多会有4个吉祥物,或者一个也没有。我们总共有三种吉祥物,并且当三张牌中的吉祥物数量中任意一个的数量为4的倍数时,我们就认为现在必须按下Ring Bell,否则就按下Pass。
当按下Ring Bell或者Pass时,我们就会把第一张牌移除,然后加入一张新的牌,以此迭代。
在这个地方我们有一个非常简单的写法,比如我们可以定义一个struct
,比如
struct Card {
uint8 slot0;
uint8 slot1;
uint8 slot2;
uint8 slot3;
}
然后每一个slot上面通过一个类型来设定,比如0是没有,1是我们的吉祥物Chog,2是Moyaki,3是Molandak。
这样,我们就可以把一个uint256的随机数变成,8张牌,然后我们每次展示3张牌,并且判断这三张牌是否的结果是否满足Ring或者Pass。
但是!
8张牌好像会让游戏结束的很快,因此我们来做一点点小的优化,我们可以注意到,因为三个吉祥物刚好可以用2个位来表示,因此我们其实可以在一个uint8中就放下一张牌4个slot的数据。比如 01 00 01 10
表示为 slot0:01,slot1:00,slot2:01,slot3:10
。而00表示的就是没有牌。
这样,我们一个uint256的seed就可以放下32张牌了!
// 定义一些常量
uint256 private constant TYPE_MASK = 0x03; // Binary: 11
uint256 private constant BITS_PER_TYPE = 2; // 二进制位数
uint256 private constant ANIMAL_COUNT = 4; // 一张牌上最多4个吉祥物
uint256 private constant ANIMAL_TYPE = 3; // 吉祥物种类
uint256 private constant BELL_TARGET = 4; // 目标值
/**
* 判断当前turn下的三张牌是否满足Ring或者Pass
*/
function checkCard(uint256 value, uint8 turn) public pure returns (bool) {
// 储存每个吉祥物的数量
uint8[4] memory types;
// 因为每轮要移除第一张牌,所以移除相应轮次的牌数
value = value >> (turn * BITS_PER_TYPE * ANIMAL_COUNT);
for(uint8 i = 0; i < 3 * ANIMAL_COUNT; ++i) {
uint8 index = uint8(value & TYPE_MASK);
value = value >> BITS_PER_TYPE;
types[index] += 1;
}
for(uint8 i = 1; i <= ANIMAL_TYPE; ++i) {
if (types[i] == BELL_TARGET) {
return true;
}
}
return false;
}
此时我们已经完成了最核心的代码编写!接下来,我们只需要在函数中按照游戏逻辑调用即可。 不过,为了统计玩家的得分情况,我们需要给Player结构体添加一些字段。
// 修改Player结构体
struct Player {
bool isReady; // 是否准备
bool out; // 出局
uint8 score; // 分数
uint8 actionCount; // 操作次数
uint256 seed; // 牌的生成种子
}
/**
* 用户每次决策都是一个action,直接根据seed来计算输赢然后积分。
* @param pressed 是否拍下bell,true为ring bell,false为pass
*/
function action(bool pressed) external isPlayer isPlaying {
Player storage player = players[msg.sender];
uint8 actionCount = player.actionCount;
if (actionCount == MAX_ACTION) {
revert ErrorEnded();
}
uint256 seed = player.seed;
// 判断用户的判断是否正确
bool win = checkCard(seed,actionCount) == pressed;
player.score += win ? 1 : 0;
player.actionCount = ++actionCount;
afterAction(player);
}
function afterAction(Player storage player) private {
uint8 actionCount = player.actionCount;
// 当达到最大操作次数时结束游戏
if (actionCount == MAX_ACTION) {
// addWin(); // TODO: create a leaderboard
endGame();
}
// 当错误三次时,我们直接淘汰玩家
if (actionCount - player.score > 3) {
player.out = true;
emit GameEnded(msg.sender, player, block.timestamp);
// 检查是否达到游戏结束条件,比如当前玩家已经是最后一个出局的了,此时就可以判断是否能结束游戏
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
if (players[playerAddr].out == false) {
return;
}
}
endGame();
}
}
/**
* 游戏结束结算
*/
function endGame() private {
// stop game and reset and emit event
Player[] memory playersTemp = new Player[](playersAddr.length);
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
Player memory player = players[playerAddr];
playersTemp[i] = player;
players[playerAddr].isReady = false;
if (player.out == false) {
emit GameEnded(playerAddr, player, block.timestamp);
}
}
delete playersAddr;
stage = GameStage.NOT_START;
}
那么,我们玩家的seed从哪来? 我们可以在setup函数中设定,给所有玩家设定种子,可以一样,也可以不一样。
function setup() private {
uint256 seed = generateSeed();
// 初始化玩家状态
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
Player storage player = players[playerAddr];
player.seed = seed;
}
emit GameStarted(playersAddr, seed);
}
/**
* @dev Generate a random seed
*/
function generateSeed() private view returns (uint256) {
// 使用伪随机生成种子
bytes memory b = abi.encodePacked(block.timestamp, block.number);
for (uint256 i = 0; i < playersAddr.length; i++) {
b = abi.encodePacked(b, playersAddr[i]);
}
return uint256(keccak256(b));
}
别忘了修改join函数中,player的赋值部分:
players[msg.sender] = Player({
isReady: true,
out: false,
score: 0,
actionCount: 0,
seed: 0
});
此时我们的游戏已经编写完成!完整文件如下:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.27;
enum GameStage {
NOT_START, // 未开始
PLAYING // 游戏中
}
error ErrorStarted(); // 游戏已开始
error ErrorIsFull(); // 游戏已满
error ErrorNotAdmin(); // 非管理员
error ErrorNotPlayer(); // 非玩家
error ErrorJoined(); // 已加入
error ErrorEnded(); // 游戏已结束
error ErrorNotPlaying(); // 非游戏中状态
error ErrorOutPlayer(); // 玩家已经出局
contract MolliNalli {
// 常量设置
uint8 public immutable MAX_PLAYERS = 4; // 最多4人
uint256 public immutable CARD_MASK = type(uint32).max; // 卡牌掩码,稍后解释
uint8 public immutable MAX_ACTION = 30; // 每个玩家最多30轮,稍后解释
// 定义一些常量
uint256 private constant TYPE_MASK = 0x03; // Binary: 11
uint256 private constant BITS_PER_TYPE = 2; // 二进制位数
uint256 private constant ANIMAL_COUNT = 4; // 一张牌上最多4个吉祥物
uint256 private constant ANIMAL_TYPE = 3; // 吉祥物种类
uint256 private constant BELL_TARGET = 4; // 目标值
struct Player {
bool isReady; // 是否准备
bool out; // 出局
uint8 score; // 分数
uint8 actionCount; // 操作次数
uint256 seed; // 牌的生成种子
}
// 状态变量设置
GameStage public stage = GameStage.NOT_START;
mapping(address => Player) public players;
address[] public playersAddr;
event GameStarted(address[] players, uint256 seed); // seed是我们游戏中生成牌的种子,用于后续的牌的生成
event GameEnded(address indexed playerAddr, Player player, uint256 endTime);
// 多人游戏加入函数实现
/**
* 获取玩家信息
* @param playerAddr 玩家地址
* @return Player 玩家信息
*/
function getPlayer(address playerAddr) public view returns (Player memory) {
return players[playerAddr];
}
/**
* 加入游戏
*/
function joinGame() external {
// 如果游戏已经开始则报错
if (stage != GameStage.NOT_START) {
revert ErrorStarted();
}
// 超过最大玩家则报错
if (playersAddr.length >= MAX_PLAYERS) {
revert ErrorIsFull();
}
// 如果玩家已经加入则报错
if (players[msg.sender].isReady) {
revert ErrorJoined();
}
// 设置游戏初始状态
players[msg.sender] = Player({ isReady: true, out: false, score: 0, actionCount: 0, seed: 0 });
playersAddr.push(msg.sender);
}
/**
* 启动游戏
*/
function startGame() external isPlayer {
// 如果游戏已经开始则报错
if (stage == GameStage.PLAYING) {
revert ErrorStarted();
}
stage = GameStage.PLAYING;
setup();
}
function action(bool pressed) external isPlayer isPlaying {
Player storage player = players[msg.sender];
uint8 actionCount = player.actionCount;
if (actionCount == MAX_ACTION) {
revert ErrorEnded();
}
uint256 seed = player.seed;
// 判断用户的判断是否正确
bool win = checkCard(seed, actionCount) == pressed;
player.score += win ? 1 : 0;
player.actionCount = ++actionCount;
afterAction(player);
}
function afterAction(Player storage player) private {
uint8 actionCount = player.actionCount;
// 当达到最大操作次数时结束游戏
if (actionCount == MAX_ACTION) {
// addWin(); // TODO: create a leaderboard
endGame();
}
// 当错误三次时,我们直接淘汰玩家
if (actionCount - player.score > 3) {
player.out = true;
emit GameEnded(msg.sender, player, block.timestamp);
// 检查是否达到游戏结束条件,比如当前玩家已经是最后一个出局的了,此时就可以判断是否能结束游戏
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
if (players[playerAddr].out == false) {
return;
}
}
endGame();
}
}
/**
* 游戏结束结算
*/
function endGame() private {
// stop game and reset and emit event
Player[] memory playersTemp = new Player[](playersAddr.length);
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
Player memory player = players[playerAddr];
playersTemp[i] = player;
players[playerAddr].isReady = false;
if (player.out == false) {
emit GameEnded(playerAddr, player, block.timestamp);
}
}
delete playersAddr;
stage = GameStage.NOT_START;
}
/**
* 游戏启动的初始化配置
*/
function setup() private {
uint256 seed = generateSeed();
// 初始化玩家状态
for (uint256 i = 0; i < playersAddr.length; ++i) {
address playerAddr = playersAddr[i];
Player storage player = players[playerAddr];
player.seed = seed;
}
emit GameStarted(playersAddr, seed);
}
/**
* @dev Generate a random seed
*/
function generateSeed() private view returns (uint256) {
// 使用伪随机生成种子
bytes memory b = abi.encodePacked(block.timestamp, block.number);
for (uint256 i = 0; i < playersAddr.length; i++) {
b = abi.encodePacked(b, playersAddr[i]);
}
return uint256(keccak256(b));
}
/**
* 判断当前turn下的三张牌是否满足Ring或者Pass
*/
function checkCard(uint256 value, uint8 turn) public pure returns (bool) {
// 储存每个吉祥物的数量
uint8[4] memory types;
// 因为每轮要移除第一张牌,所以移除相应轮次的牌数
value = value >> (turn * BITS_PER_TYPE * ANIMAL_COUNT);
for (uint8 i = 0; i < 3 * ANIMAL_COUNT; ++i) {
uint8 index = uint8(value & TYPE_MASK);
value = value >> BITS_PER_TYPE;
types[index] += 1;
}
for (uint8 i = 1; i <= ANIMAL_TYPE; ++i) {
if (types[i] == BELL_TARGET) {
return true;
}
}
return false;
}
// 判断是否是玩家,并且没有出局
modifier isPlayer() {
require(players[msg.sender].isReady, ErrorNotPlayer());
require(players[msg.sender].out == false, ErrorOutPlayer());
_;
}
// 判断是否在游戏中
modifier isPlaying() {
require(stage == GameStage.PLAYING, ErrorNotPlaying());
_;
}
}
此时我们就可以使用ScaffoldEth自带的账号控制系统来部署合约了。
如果你之前没有使用过ScaffoldEth,那么你需要先执部署账号初始化命令
yarn account:generate
然后修改/packages/foundry/.env
中的ETH_KEYSTORE_ACCOUNT
为scaffold-eth-custom
(没有可以从.env.example复制)
在此之前我们还需要设定网络信息,首先是Foundry的网络信息
# packages/foundry/foundry.toml
# 在 [rpc_endpoints] 下添加
monadTestnet= "https://testnet-rpc.monad.xyz"
然后我们修改前端的网络设置,进入目录 packages/nextjs/utils/scaffold-eth
,新建一个文件为customChains.ts
。
touch packages/nextjs/utils/scaffold-eth/customChains.ts
打开编辑
import { defineChain } from "viem";
// monad testnet chain
export const monadTestnet = defineChain({
id: 10143,
name: "Monad Testnet",
nativeCurrency: { name: "TMON", symbol: "TMON", decimals: 18 },
rpcUrls: {
default: {
http: ["https://testnet-rpc.monad.xyz"],
},
},
blockExplorers: {
default: {
name: "Monad Explorer",
url: "https://testnet.monadexplorer.com/",
},
},
});
然后修改 packages/nextjs/scaffold.config.ts
// targetNetworks: [chains.foundry], 改成
targetNetworks: [monadTestnet],
此时,网络环境已经准备就绪,执行以下命令进行代码部署,部署之前,请保证你的地址中有资金。
你可以使用 yarn account
查看你的地址和地址上的余额。注意,如果你设置正确了你的结果中一定会包含-- monadTestnet -- 📡
这样的字符串。
当你确定地址中有足够余额的时候,请执行
yarn deploy --network monadTestnet
你应该得到类似这样的输出。
## Setting up 1 EVM.
==========================
Chain ****
Estimated gas price: 52 gwei
Estimated total gas used for script: 995532
Estimated amount required: 0.051767664 ETH
==========================
✅ [Success] Hash: ******
Contract Address: ****
Block: 2381846
Paid: 0.0382897 ETH (765794 gas * 50 gwei)
✅ Sequence #1 on 20143 | Total Paid: 0.0382897 ETH (765794 gas * avg 50 gwei)
==========================
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Transactions saved to: ***/20143/run-latest.json
Sensitive values saved to: ***/20143/run-latest.json
node scripts-js/generateTsAbis.js
📝 Updated TypeScript contract definition file on ../nextjs/contracts/deployedContracts.ts
这样我们的合约代码就部署完成了。
此刻我们可以构建一下前端代码,在前端代码中,我们会省略所有不重要的内容,只关注我们和链上交互的代码,得益于viem和scffold提供的内置函数,我们可以轻松的完成这一步。
首先,这个游戏中,我们需要构建的是获取多人游戏状态的读取代码,并且让他支持断线冲连的情况,因此我们每次加入时,需要先获取一个整体状态,以便程序可以判断当前游戏是还没有开始还是已经开始。
因此我们需要插入下面的代码,这个代码是直接从合约中读取状态变量的部分,可以看到的是我们获取了一个常量,一个变量,一个函数调用值。
// packages/nextjs/app/game/page.tsx
// TODO: 合约状态初始化
const { data, isFetched, error } = useReadContracts({
contracts: [
{
...deployedContract,
functionName: "MAX_ACTION",
},
{
...deployedContract,
functionName: "stage",
},
{
...deployedContract,
functionName: "getPlayer",
args: [address],
},
],
});
现在有了初始状态下的值,我们就需要进入游戏的核心逻辑部分,这个节点我们需要监听player的变化,以及监听游戏事件,比如监听游戏开始的事件,监听游戏结束的事件。
所以我们来构建两个Hook帮助我们完成这个功能,一个是useEndInfo
,一个是useStart
。
// packages/nextjs/hooks/game/hooks.ts
// 读取链上游戏结束Event
// 通过filters来指定只获取当前player结束游戏的Event
// useScaffoldEventHistory 实际上是封装好的getLogs
const { data } = useScaffoldEventHistory({
enabled: !!blockNumber,
contractName: "MolliNalli",
eventName: "GameEnded",
fromBlock: blockNumber || 0n,
filters: { playerAddr: address },
watch: true,
});
// 读取链上游戏开始Event
const { data } = useScaffoldEventHistory({
enabled: !!blockNumber,
contractName: "MolliNalli",
eventName: "GameStarted",
fromBlock: blockNumber || 0n,
watch: true,
});
我们还有一个主逻辑的Hook,useGameLogic
,这里面包含的是三个用于调用合约执行的Action,我们需要完善一下。
const joinGame = useCallback(async () => {
writeContractAsync({
functionName: "joinGame",
});
}, [writeContractAsync]);
const startGame = useCallback(async () => {
writeContractAsync({
functionName: "startGame",
});
}, [writeContractAsync]);
const action = useCallback(
async (bell: boolean, localNonce: number) => {
return writeContract({
functionName: "action",
args: [bell],
nonce: localNonce,
maxFeePerGas: parseGwei("60"),
gas: 163560n,
});
},
[writeContract],
);
当我们监听了GameStarted事件后,我们就可以知道游戏什么时候开始了,当游戏开始后,我们就需要进行牌的计算。关于拍的计算步骤我已经做好了函数,因此不需要操心,此时我们只需要在点击Ring Bell或者Pass按钮后调用action即可。
// packages/nextjs/app/game/page.tsx
// TODO: 构建action,用来处理用户的决策
const action = async (bell: boolean) => {
triggerAction(bell, localNonce);
setLocalNonce(nonce => nonce + 1);
setSeedInfo(seedInfo => {
if (!seedInfo) return null;
const shouldBell = checkCard(seedInfo.seed, seedInfo.actionCount);
const result = {
...seedInfo,
actionCount: seedInfo.actionCount + 1,
score: seedInfo.score + (shouldBell == bell ? 1 : 0),
};
if (result.actionCount - result.score > 3) {
setGameStage(GameStage.WAITING_END);
}
return result;
});
notification.success("✅ Transaction send success!");
}
此时我们前端部分已经完成,接下来我们可以将其部署到Monad Devnet进行测试。
部署完成后,直接输入yarn start
即可启动前端。