全链游戏突然间再次成为了叙述主流。万幸的是,我所熟悉的 StarkNet 生态系统内存在目前最好的全链游戏引擎之一的 Dojo。
此教程可以认为是 Dojo 开发的 Quick Start
,本文会介绍 dojo 开发环境的搭建与 ECS 系统的基本概念,但是由于笔者不擅长游戏逻辑设计且专精于 cairo 开发,所以本文会包含大量的源代码阅读,帮助读者深入了解 dojo 开发框架。
本文基于 dojo v0.4.1
编写。
我们首先使用以下命令安装 dojoup
:
然后使用以下命令安装 Dojo
组件:
我们可以看到 dojoup
安装了以下四部分内容:
sozo
是一个核心组件,主要用于项目初始化、代码测试、项目交互以及最终的项目部署和迁移更新等工作。与 Foundry 系统内的 forge
命令类似。
katana
是一个快速的 StarkNet 本地节点,允许用户快速启动 StarkNet 本地测试环境。
torii
则是一个本地的检索器,用于开始启动对于项目合约数据的链下检索,使用此命令后会启动一个 graphql
服务器来响应用户请求。
dojo-languge-server
是 dojo 的 LSP ,用于实时显示语法报错。
完成以上安装后,创建一个新的文件夹,并进入此文件夹使用以下命令初始化 dojo 项目:
初始化完成后,我们可以获得以下目录结构:
在介绍具体的合约代码前,我们首先体验一下这个项目的具体功能。此项目实现了以下功能:
spawn
创建一个可参与游戏的用户,用户的位置会被初始化到 x = 1000
和 y = 1000
的位置,同时会把用户的 remaining
设置为 100。当用户每移动一步,remaining
会自减 1 ,直至 remaining = 0
为止move
方法实现移动,我们可以使用以下方法在本地部署此项目:
首先,我们需要在项目文件夹内使用 sozo build
编译此项目。
打开一个新的终端输入以下命令:
上述命令将启动本地的 StarkNet 测试节点。
完成测试节点启动后,我们使用以下命令在本地节点部署 dojo 项目:
我们可以看到如下输出:
此处显示了一系列合约,我们会在后文详细介绍每一个合约的具体功能及其代码。
完成以上部署后,为方便后期调用,我们需要在 Scarb.toml
内写入以下内容:
我们可以进行交互操作,我们首先调用 spawn
方法创建一个可用于移动的用户:
此处,我们填入了 dojo_examples::actions::actions
作为执行 spawn
操作的合约,此合约即上文部署的 actions
合约。在新版本的 dojo 中,我们可以提供直接填写 dojo_examples::actions::actions
来代替 actions
合约的实际地址。关于此处为什么需要填写合约地址,我们会在后文进一步讨论。
接下来,我们可以使用以下命令查询我们注册用户的位置:
此命令的格式如下:
NAME
是我们需要检索的组件的名称,我们系统内存在两个组件,代码如下:
我们需要检索 Position
组件,而 [KEYS]
则是需要检索内容的索引,此处为 player
即用户地址。
返回值如下:
在此处,我们就可以发现 Model
的核心功能就是存储用户数据,#[key]
则是用于检索数据的核心参数。
我们也可以尝试检索 Moves
组件,命令如下:
返回值如下:
完成上述数据检索任务后,我们尝试移动用户的位置,使用以下命令:
此处 execute
表示执行某个函数,而 calldata
则需要填入具体参数,具体如何填写,我们需要参考 move
的具体代码:
我们此处输入了 1
,根据 DirectionIntoFelt252
提供 into
方法,实际上代表向右移动,此时我们调用以下命令重新查询位置参数:
返回值如下:
注意到 x
的值已经增加了 1
,则与我们移动的预期结果是一致的。我们还可以调用以下内容查询用户的 remaining
是否发生变化:
返回值如下:
remaining
为 99,正好减少了 1 ,也符合我们预期。
最后,我们体验以下 dojo 自带的 torii
检索器,在使用检索器前,我们需要创建一个 sqlite
文件,请读者切换到 target
文件夹内,使用以下命令创建数据库:
在进入 sqlite3
的命令后,输入以下内容:
如下图:
然后输入以下命令启动检索器:
此处的 --world
后应为上文部署的 world 合约地址。
启动成功后,读者可以打开 http://127.0.0.1:8080/graphql
进入 GraphQL
控制台页面,使用以下查询即可获得用户当前位置:
结果如下图:
由于本文重点不在于讨论检索器相关内容,所以此处我们不再深入给出 GraphQL
的语法知识等,读者可以参考文档或者是我之前编写的 基于Python与GraphQL的链上数据分析实战 文章。
注意目前
torii
仍只可使用sqlitedb
作为数据存储后端,但是考虑到torii
使用了sqlx
,支持其他数据库后端应该难度不大
在体验完整个项目的具体作用,我们开始进入代码解析阶段。我们需要对 ECS 系统有一定简单的了解,ECS
的全称为 Entity Component System
,其核心为:
Component
组件,正如上文所述,组件的作用是作为数据的载体,用来存储游戏内的各种数据,同时组件具有可组合性,我们可以为用户绑定多个组件Entity
实体,实际上就是只是一个索引,在上文中的 player
就是一个实体,我们可以为实体配置多个组件,目前 sozo 还没有命令可以直接获取实体的所有数据,但我们可以通过 torii
实现此功能System
系统,执行具体的游戏逻辑,系统内往往不存储数据,而是通过 get!
方法获取组件内的数据,修改完成后使用 set!
将其写回组件,我们使用 ssozo execute $action move -c 1
命令其实就是在调用系统内的 move
方法在 dojo 开发系统内,还存在一个较为特殊的存在,其被称为 World
世界,简单来说,World 合约是 dojo 系统的核心,所有的 Component
都会注册到 World 合约中,可以认为 World
合约为 dojo
游戏的核心数据库。
在现有的代码中,Component
被定义在 src/models.cairo
中,该代码内最简单的 Component
定义如下:
其中 Model
为 Component
自动生成了一系列模板代码,而 Serde
则用于 Component
的序列化。此处的 #[key]
定义了该 Component
的主键,也可以认为其代表 Entity
实体。一般来说,我们会将与数据直接相关的部分 trait 逻辑与组件定义写在一起,如:
该 impl
实现了 Direction
组件的 into
函数,实现该函数后,我们可以通过调用 into
实现自动类型转换。常见的需要与组件一起定义的 trait 包含以下几个:
PrintTrait<T>
用于自定义类型 T
的打印Into<T, E>
用于 into
相关的自动类型转换Add<T>
等用于运算符重载的 trait,详细类型可参考 traits.cairo一个较为复杂的组件定义如下:
此处的 Vec2
带有 Introspect
,该 trait 代表该组件只能被嵌入到其他组件内使用,我们可以看到在 Position
内使用了 Vec2
。
而负责游戏逻辑的 System
系统被定义在 src/actions.cairo
文件内,System
的开发与普通的智能合约基本类似,但是我们会使用 #[dojo::contract]
定义合约,且我们需要使用 use dojo_examples::models::{Position, Moves, Direction, Vec2};
导入需要的组件。
我们可以看一个简单的示例:
在 System
中,我们使用 get!
获得数据,使用 set!
写入数据。上文给出的 spawn
是一个初始化函数。
一个复杂的 System
示例如下:
在此处,我们可以看到此函数使用 emit!
释放了事件,简化了 cairo 语言原生的事件释放语法。在此处,我们也可以一个完整读取-修改-写入的流程。具体来说,使用 get!
获得了可变 mut
变量,然后修改结构体内的数据,然后直接 set!
到 world
合约。
最后,我们介绍 System
的测试,如下:
在测试中,我们一般需要按照以下步骤进行:
第一步是初始化 World 合约,首先需要声明包含所有 Component
构成的 models
数组,然后使用 spawn_test_world
初始化并部署 world
合约,最后部署 System
合约。
第二步就是进行调用和 assert
断言判断:
从此节开始,我们将深入 dojo 的源代码来探索每一步操作背后的原理。我们首先从一切的开始 world 及其他合约的部署开始聊起。
在上文的测试中,我们给出了合约初始化的 Cairo 代码:
此处的 spawn_test_world
函数定义如下:
我们可以看到此处的部署是分为以下三步的:
executor
合约executor
合约地址和 dojo::base::base
合约 class 作为参数部署 world 合约model
合约的 class hash 为参数调用 register_model
方法我们首先给出 executor
合约的源代码:
此处的 EXECUTE_ENTRYPOINT
是之前版本的遗留阐述,在本文讨论的 3.10 中已经没有用途。我们可以看到 Executor
合约只包含 call
函数用于进行 library_call_syscall
调用并返回调用结果。
在 World 合约中,我们在以下函数中使用了 Executor
合约:
而 class_call
在 world 合约内只使用了一次:
读者可能好奇为什么 world 合约要引入 executor
进行 class 调用操作?这是为了安全的隔离考虑,某些用户可能恶意构造一些特殊的 class ,当合约调用时会修改调用者的存储。而当我们引入 executor
合约后,即使调用了恶意的 class ,也不会导致 world 合约内的数据被修改。
另一个合约 dojo::base::base
的源代码如下:
正如其名,此合约是一个最简单的基础合约,仅包含一个 upgrade
合约升级函数。该合约在 World 合约内存在以下用法:
此函数用于部署 System 合约,如上文 action
合约即通过此函数部署。可能又有读者好奇为什么不直接使用 deploy_syscall
部署 System 合约,而是通过需要先部署 base 合约后升级到 System 合约呢? 这实际上是一个特殊的初始化方法。
在 action
合约内,我们会使用以下语句:
读者应该可以注意到 action
合约内根本没有 world_dispatcher
定义,此定义实际上是通过预先部署的 base 合约传递的。
部署的最后一步是注册 Component 组件,主要使用了 register_model
函数,此函数在 world 合约内定义如下:
此函数较为简单,其功能为如果系统内不存在当前 model 则直接将此 model 注册到 models
映射内,同时更新 model 的 owner
;如果存在此 model,则判断调用者是否为 owner
,如果是则进行更新 models
映射。
至此,我们再次阅读以下之前执行 sozo migrate --name test
的输出:
读者应该可以理解输出的顺序以及为什么某些输出为 Class Hash
而另一些为 Contract address
此处的
Registered at:
后为Position
注册的交易 hash
在上文中,我们展示了 dojo 中 Model 的定义方法,如下:
但是在上文,我们可以看到这一个结构体实际上被拓展为了合约。此处,我们可以简单讨论一下 dojo 的编译流程。Dojo 的编译实际上建立在 Cairo 编译器的基础上,Dojo 编译器负责将其 Dojo 定义的语法元素(大部分都是宏),如 Model
和 #[key]
等转化为 Cairo 代码。
此处对于 Model 的转化代码可以在 dojo-lang/src/model.rs 内找到,完整代码如下:
此处的所有使用美元符包裹的字段都会根据用户编写的 Dojo 代码进行重写。代码如下:
此部分代码较长,我们将其分为以下三部分分析:
impl
部分,此部分为结构体实现了一系列方法$schema_introspection$
部分,提供结构体的数据库支持contract
部分,此部分提供了大量合约功能我们首先分析 impl
部分,此部分代码如下:
此处我们一并给出上文的示例 Dojo 代码:
此处的 $type_name$
即我们在 Dojo 系统内声明的 Model 名称,此处为 Moves
。此部分为我们声明的 Model提供了多个方法,且使用了 #[inline(always)]
节约 gas。
对于
#[inline(always)]
节约 gas 的原理,可以参考 深入探索 Cairo: Sierra IR 与 Cairo 底层 一文
keys
函数会返回 Model 的 key 部分,即我们上文使用 #[key]
标识的结构体成员,此处为 player
,而 values
则会返回非键的结构体成员,此处为 remaining
和 last_direction
。
根据编译器源代码,我们可以知道 $serialized_keys$
和 $serialized_values$
都使用了同一个函数实现:
此处的 m
指结构体每一个成员。读者可能无法猜测上述代码内出现 ty
的含义,我们在此处给出其 Rust 源代码:
实际上,ty
只是此结构体成员的类型名称。简单来说,serialize_member
实现了以下功能:
felt252
则使用 array::ArrayTrait::append(ref serialized, *self.{});
将其加入 serialized
serde::Serde::serialize(self.{}, ref serialized);
将其加入 serialized
接下来,我们介绍复杂的 layout
函数,此函数实际上是对 dojo::database::schema::SchemaIntrospection::<$type_name$>::layout
的简单包装。而 dojo::database::schema::SchemaIntrospection::<$type_name$>
的实现其实是在 $schema_introspection$
内实现,而 packed_size
函数也与此相关。所以我们首先分析 $schema_introspection$
,此部分实现如下: