Skip to main content

井字棋

本指南涵盖了在 Sui 上实现井字棋游戏的三种不同方式。第一个示例利用一个集中的管理员代表用户在棋盘上做标记。第二个示例利用一个共享对象,两个用户都可以更改。第三个示例利用多重签名,而不是共享游戏板,而是处于两个用户帐户的 1-of-2 多重签名中。本指南比较和对比了三种不同游戏背后的设计理念,以及每种方法的优缺点。

tic_tac_toe.move

在井字棋的第一个示例中,游戏对象,包括游戏板,由游戏管理员控制。

struct TicTacToe has key {
id: UID,
gameboard: vector<vector<Option<Mark>>>,
cur_turn: u8,
game_status: u8,
x_address: address,
o_address: address,
}

由于玩家不拥有游戏板,他们无法直接更改它。相反,他们通过创建一个带有他们打算放置的标记对象,并将其发送给管理员来指示他们的移动。

struct Mark has key, store {
id: UID,
player: address,
row: u64,
col: u64,
}

游戏的主要逻辑在以下的 create_game 函数中。

/// `x_address` and `o_address` are the account address of the two players.
public entry fun create_game(x_address: address, o_address: address, ctx: &mut TxContext) {
// TODO: Validate sender address, only GameAdmin can create games.

let id = object::new(ctx);
let game_id = object::uid_to_inner(&id);
let gameboard = vector[
vector[option::none(), option::none(), option::none()],
vector[option::none(), option::none(), option::none()],
vector[option::none(), option::none(), option::none()],
];
let game = TicTacToe {
id,
gameboard,
cur_turn: 0,
game_status: IN_PROGRESS,
x_address: x_address,
o_address: o_address,
};
transfer::transfer(game, tx_context::sender(ctx));
let cap = MarkMintCap {
id: object::new(ctx),
game_id,
remaining_supply: 5,
};
transfer::transfer(cap, x_address);
let cap = MarkMintCap {
id: object::new(ctx),
game_id,
remaining_supply: 5,
};
transfer::transfer(cap, o_address);
}

一些需要注意的事项:

  • 游戏作为一个在游戏管理员账户中的拥有对象存在。
  • 棋盘初始化为一个 3x3 的向量,通过 option::none() 实例化。
  • 每个玩家都有五个 MarkMintCap,使他们能够最多放置五个标记。

在玩游戏时,管理员运行一个服务来跟踪这些放置请求。当接收到请求时(send_mark_to_game),管理员尝试将标记放置在棋盘上(place_mark)。每个动作都需要两个步骤(因此两个交易):一个来自玩家,一个来自管理员。这个设置依赖于管理员的服务来推动游戏的进行。

/// Generate a new mark intended for location (row, col).
/// This new mark is not yet placed, just transferred to the game.
public entry fun send_mark_to_game(
cap: &mut MarkMintCap,
game_address: address,
row: u64,
col: u64,
ctx: &mut TxContext,
) {
if (row > 2 || col > 2) {
abort EInvalidLocation
};
let mark = mint_mark(cap, row, col, ctx);
// Once an event is emitted, it should be observed by a game server.
// The game server will then call `place_mark` to place this mark.
event::emit(MarkSentEvent {
game_id: *&cap.game_id,
mark_id: object::id(&mark),
});
transfer::public_transfer(mark, game_address);
}

public entry fun place_mark(game: &mut TicTacToe, mark: Mark, ctx: &mut TxContext) {
// If we are placing the mark at the wrong turn, or if game has ended,
// destroy the mark.
let addr = get_cur_turn_address(game);
if (game.game_status != IN_PROGRESS || &addr != &mark.player) {
delete_mark(mark);
return
};
let cell = get_cell_mut_ref(game, mark.row, mark.col);
if (option::is_some(cell)) {
// There is already a mark in the desired location.
// Destroy the mark.
delete_mark(mark);
return
};
option::fill(cell, mark);
update_winner(game);
game.cur_turn = game.cur_turn + 1;

if (game.game_status != IN_PROGRESS) {
// Notify the server that the game ended so that it can delete the game.
event::emit(GameEndEvent { game_id: object::id(game) });
if (game.game_status == X_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, *&game.x_address);
} else if (game.game_status == O_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, *&game.o_address);
}
}
}

要查看完整的源代码,请参阅 tic_tac_toe.move 源文件。你可以在那里找到其余的逻辑,包括如何检查胜者以及在游戏结束后删除游戏板。

这个游戏的另一个版本,共享井字棋,使用共享对象进行更为直接的实现,而无需使用中央服务。然而,这会带来略微增加的成本,因为使用共享对象比涉及完全拥有的对象的事务更昂贵。

shared_tic_tac_toe.move

在先前的版本中,管理员拥有游戏对象,阻止玩家直接更改游戏板,同时每个标记放置都需要两个事务。在这个版本中,游戏对象是一个共享对象,允许两名玩家直接访问和修改它,使它们可以在一次事务中放置标记。然而,使用共享对象通常会产生额外的成本,因为 Sui 需要对来自不同事务的操作进行排序。在这个游戏中,玩家预计会轮流进行,这不应显著影响性能。总体而言,与先前的方法相比,这种共享对象的方法简化了实现。

正如下面的代码所示,在这个示例中的 TicTacToe 对象几乎与之前的对象相同。唯一的区别是 gameboard 表示为 vector<vector<u8>>,而不是 vector<vector<Option<Mark>>>。关于采用这种方法的原因在代码之后有解释。

struct TicTacToe has key {
id: UID,
gameboard: vector<vector<u8>>,
cur_turn: u8,
game_status: u8,
x_address: address,
o_address: address,
}

来看看 create_game 函数:

/// `x_address` and `o_address` are the account address of the two players.
public entry fun create_game(x_address: address, o_address: address, ctx: &mut TxContext) {
// TODO: Validate sender address, only GameAdmin can create games.

let id = object::new(ctx);
let gameboard = vector[
vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
];
let game = TicTacToe {
id,
gameboard,
cur_turn: 0,
game_status: IN_PROGRESS,
x_address: x_address,
o_address: o_address,
};
// Make the game a shared object so that both players can mutate it.
transfer::share_object(game);
}

正如代码所示,棋盘上的每个位置都被替换为 MARK_EMPTY 而不是 option::none()。游戏不再发送给游戏管理员,而是作为共享对象实例化。另一个显著的区别是不再需要为两个玩家铸造 MarkMintCap,因为唯一可以玩这个游戏的两个地址是 x_addresso_address,这在接下来的函数 place_mark 中进行了检查:

public entry fun place_mark(game: &mut TicTacToe, row: u8, col: u8, ctx: &mut TxContext) {
assert!(row < 3 && col < 3, EInvalidLocation);
assert!(game.game_status == IN_PROGRESS, EGameEnded);
let addr = get_cur_turn_address(game);
assert!(addr == tx_context::sender(ctx), EInvalidTurn);

let cell = vector::borrow_mut(vector::borrow_mut(&mut game.gameboard, (row as u64)), (col as u64));
assert!(*cell == MARK_EMPTY, ECellOccupied);

*cell = game.cur_turn % 2;
update_winner(game);
game.cur_turn = game.cur_turn + 1;

if (game.game_status != IN_PROGRESS) {
// Notify the server that the game ended so that it can delete the game.
event::emit(GameEndEvent { game_id: object::id(game) });
if (game.game_status == X_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, game.x_address);
} else if (game.game_status == O_WIN) {
transfer::transfer(Trophy { id: object::new(ctx) }, game.o_address);
}
}
}

你可以在 shared_tic_tac_toe.move 中找到完整的源代码。

multisig_tic_tac_toe.move

在这个游戏的实现中,游戏位于一个 1-of-2 多签帐户中,充当游戏管理员。在这种情况下,因为只有两名玩家,前面的例子更方便一些。然而,这个例子说明了在某些情况下,多签可以替代共享对象,从而允许在使用这种实现时绕过共识。

查看这个游戏中的两个主要对象,TicTacToeMark

/// TicTacToe struct should be owned by the game-admin.
/// This should be the multisig 1-out-of-2 account for both players to make moves.
struct TicTacToe has key {
id: UID,
/// Column major 3x3 game board
gameboard: vector<u8>,
/// Index of current turn
cur_turn: u8,
x_addr: address,
o_addr: address,
/// 0 not finished, 1 X Winner, 2 O Winner, 3 Draw
finished: u8
}

/// Mark is passed between game-admin (Multisig 1-out-of-2), x-player and o-player.
struct Mark has key {
id: UID,
/// Column major 3x3 placement
placement: Option<u8>,
/// Flag that sets when the Mark is owned by a player
during_turn: bool,
/// Multi-sig account to place the mark
game_owners: address,
/// TicTacToe object this mark is part of
game_id: ID
}

在这个 TicTacToe 对象中最大的区别是 gameboard 是一个 vector<u8>,但除此之外,gameboard 的主要功能是相同的。Mark 对象在这个版本中再次出现,因为我们需要一种方法来识别当前玩家的回合(在游戏的共享版本中,这是在 TicTacToe 对象本身完成的)。

create_game 函数与前两个版本中的一个相似:

/// This should be called by a multisig (1 out of 2) address.
/// x_addr and o_addr should be the two addresses part-taking in the multisig.
public fun create_game(x_addr: address, o_addr: address, ctx: &mut TxContext) {
let id = object::new(ctx);
let game_id = object::uid_to_inner(&id);

let tic_tac_toe = TicTacToe {
id,
gameboard: vector[MARK_EMPTY, MARK_EMPTY, MARK_EMPTY,
MARK_EMPTY, MARK_EMPTY, MARK_EMPTY,
MARK_EMPTY, MARK_EMPTY, MARK_EMPTY],
cur_turn: 0,
x_addr,
o_addr,
finished: 0
};
let mark = Mark {
id: object::new(ctx),
placement: option::none(),
during_turn: true, // Mark is passed to x_addr
game_owners: tx_context::sender(ctx),
game_id
};

transfer::transfer(tic_tac_toe, tx_context::sender(ctx));
transfer::transfer(mark, x_addr);
}

现在看一下 send_mark_to_gameplace_mark

/// This is called by the one of the two addresses participating in the multisig, but not from
/// the multisig itself.
/// row: [0 - 2], col: [0 - 2]
public fun send_mark_to_game(mark: Mark, row: u8, col: u8) {
// Mark.during_turn prevents multisig-acc from editing mark.placement after it has been sent to it.
assert!(mark.during_turn, ETriedToCheat);

option::fill(&mut mark.placement, get_index(row, col));
mark.during_turn = false;
let game_owners = mark.game_owners;
transfer::transfer(mark, game_owners);
}

/// This is called by the multisig account to execute the last move by the player who used
/// `send_mark_to_game`.
public fun place_mark(game: &mut TicTacToe, mark: Mark, ctx: &mut TxContext) {
assert!(mark.game_id == object::uid_to_inner(&game.id), EMarkIsFromDifferentGame);

let addr = get_cur_turn_address(game);
// Note here we empty the option
let placement: u8 = option::extract(&mut mark.placement);
if (get_cell_by_index(&game.gameboard, placement) != MARK_EMPTY) {
mark.during_turn = true;
transfer::transfer(mark, addr);
return
};

// Apply turn
let mark_symbol = if (addr == game.x_addr) {
MARK_X
} else {
MARK_O
};
*vector::borrow_mut(&mut game.gameboard, (placement as u64)) = mark_symbol;

// Check for winner
let winner = get_winner(game);

// Game ended!
if (option::is_some(&winner)) {
let played_as = option::extract(&mut winner);
let (winner, loser, finished) = if (played_as == MARK_X) {
(game.x_addr, game.o_addr, 1)
} else {
(game.o_addr, game.x_addr, 2)
};

transfer::transfer(
TicTacToeTrophy {
id: object::new(ctx),
winner,
loser,
played_as,
game_id: object::uid_to_inner(&game.id)
},
winner
);

delete_mark(mark);
* &mut game.finished = finished;
return
} else if (game.cur_turn >= 8) { // Draw
delete_mark(mark);
* &mut game.finished = 3;
return
};

// Next turn
* &mut game.cur_turn = game.cur_turn + 1;
addr = get_cur_turn_address(game);
mark.during_turn = true;
transfer::transfer(mark, addr);
}

第一个函数很简单。玩家向多签账户发送标记的位置。接下来的函数中,多签账户实际放置了玩家请求的标记,以及检查是否有获胜者、结束游戏并颁发奖杯的所有逻辑,或者如果没有获胜者则进入下一个玩家的回合。查看 multisig_tic-tac-toe 仓库 获取游戏此版本的完整源代码。