目录
- 1-从零开发EOS区块链小游戏系列 - 使用EOS Studio
- 2-从零开发EOS区块链小游戏系列 - 智能合约设计与实现
- 3-从零开发EOS区块链小游戏系列 - 游戏公平性及安全性
- 4-从零开发EOS区块链小游戏系列 - 加入Token体系
- 5-从零开发EOS区块链小游戏系列 - 实现玩家免CPU玩游戏(终)
智能合约表结构设计
回顾上一章的游戏规则,首先必须有一张玩家表,用来保存注册玩家的数据,还要有英雄表,用来存放出战英雄的数据。最后一张宝箱表,存放玩家所获得的宝箱,结构如下:
玩家表:
- 玩家EOS账号
- 拥有的SJ token(水晶)
- 游戏局数计数器
英雄表:
- 唯一id
- 英雄名称
- 攻击力最小值
- 攻击力最大值
- 血量
宝箱表:
- 唯一id
- 所属玩家
- 宝箱的级别(金、银、铜)
//player
//scope is self
TABLE players {
name player_account;
asset coin;
uint64_t created_at;
uint64_t primary_key() const { return player_account.value;}
};
//hero
//scope is player
TABLE heros {
uint64_t id = 10000;
string hero_name;
uint32_t atk;
uint32_t hp;
uint64_t created_at;
uint64_t primary_key() const { return id;}
};
//box
//scope is self
//index: player_account
TABLE boxs {
uint64_t id = 10000;
name player_account;
uint8_t level;
uint64_t created_at;
uint64_t get_player() const { return player_account.value;}
uint64_t primary_key() const { return id;}
};
using player_index = multi_index<"players"_n, players>;
using hero_index = multi_index<"heros"_n, heros>;
using box_index = multi_index<"boxs"_n, boxs,
indexed_by<"byplayer"_n, const_mem_fun<boxs, uint64_t, &boxs::primary_key>>
>;
uint64_t primary_key() const { return player_account.value;}
表示将player_account 设置为表的主键,主键具有唯一约束,且每个表都有一个主键。
第三个boxs
表的uint64_t get_player() const { return player_account.value;}
表示将player_account
设为索引,是为了后面方便查询表的数据。
最后using开头的三行是对表进行配置:multi_index
表明示多索引表,即可以存放多条数据,另外一种singleton
声明的,表示只能存放一条数据,但相对地CRUD操作就非常方便。一搬用在存放合约全局配置数据的表。
大家看上面heros
和boxs
表的结构有没有发现有些问题,boxs
有一个所属玩家的字段,而heros
却没有。那怎么查询某个玩家下的hero呢?答案是根据表的scope来查询的,什么是scope?我们先看下使用一个表之前,对表进行实例化的代码:
box_index box_table(get_self(), gete_self().value ); //实例化boxs表
hero_index hero_table(get_self(),"bob"_n.value ); //实例化heros表
实例化需要两个参数:
- 第一个参数指定表的拥有者(owner),owner的账号需要为存入该表的数据支付RAM,表的数据也只能owner能够修改。我们使用get_self()表示使用合约的账号。
- 第二个参数用来将数据分区,如上代码,如果传入玩家bob的账号,那么我们拿到的hero_table就是一个只针对bob的数据,也只能对部分数据进行操作。不知道大家发现没有,如果你要查询这张表的所有数据,是没有办法查的,因为scope必须指定。eosio.token 的accounts也是用户账户作为scope。
使用scope区分和使用索引其实都可以达到目的,至于使用的时机就要看情况。还是以上面两个表为例,根据我之前的经验,个人认为如果在同一个交易中,如果需要同时操作多个玩家的英雄数据,就最好使用索引了,例如:在某个版本,需要给所有攻击力低于30的英雄+10攻击力,因为觉得英雄太弱鸡了。这时查询的维度不是玩家,而是攻击力,如果将攻击力设置为索引,就很方便了。这里heros
用scope的方式,是因为确保每次交易,我们只操作当前玩家的数据。
智能合约代码编写
还记得上一章节新建的项目,有两个文件.hpp和.cpp,hpp用来编写对外描述action、表结构和一些私有方法和变量。cpp主要编写action的具体实现。
按照上一章游戏规则的顺序,先编写注册的action,打开hpp文件,把系统自动生成的 ACTION hi(name user)
删掉,替换为:ACTION signup(const name player);
。在实现前回顾下游戏规则:
玩家需要注册账号,同时获得1000个SJ币(水晶),并得到一个人物用于战斗,人物有攻击力和血量2个属性,攻击力初始35-70,血量初始500。
切换到.cpp文件,实现如下:
ACTION kingofighter::signup(name player) {
//要求必须玩家本人注册
require_auth(player);
//实例化player表
player_index player_tb(get_self(),get_self().value);
//主键获取玩家的数据
auto itr = player_tb.find(player.value);
//如果玩家数据已存在,抛出异常
check(itr==player_tb.end(), "player account exist!" );
//声明水晶数量1000个 乘10000是为了抵消0.0001
const uint64_t amt = 1000 * 10000;
//插入一条玩家数据
player_tb.emplace(get_self(), [&]( auto& r ) {
r.player_account = player; //玩家账号
r.coin = asset(amt, symbol(symbol_code("SJ"), 4);); //初始水晶数量:1000
r.counter = 0; //玩家游戏局数
r.created_at = time_point_sec(current_time_point()); //当前区块链时间
});
//实例化hero表
//第二个入参(scope)为玩家账号
hero_index hero_tb(get_self(),player.value);
hero_tb.emplace(get_self(), [&]( auto& r ) {
r.id = hero_tb.available_primary_key();
r.hero_name = "jakiro"; //英雄名称:杰奇诺
r.min_atk = 35; //攻击力最小值
r.max_atk = 70; //攻击力最大值
r.hp = 500; //血量
r.created_at = time_point_sec(current_time_point()); //当前区块链时间
});
}
入参为注册玩家的EOS账号,注册action可分三部分:校验权限、插入玩家数据、插入英雄数据。上面代码根据注释很好理解,但又两句需要说下r.coin = asset(1000 * 10000, symbol(symbol_code("SJ"), 4));
,这里构造1000个符号为“SJ”,小数点后保留4位的asset资产。r.id = hero_tb.available_primary_key();
表示使用hero表维护的自增id,默认从0开始自增。
注册有了,现在可以开始构思对战的过程。对于玩家来说,应该是只需要调用一个[对战]的action,然后等待战斗结果就可以了。再仔细想想会发觉,其实目前所有战局的因素都是固定的:玩家英雄的属性、BOSS的属性。所以需要外部给一个生产随机的因素。为了提高玩家的参与感,让他觉得可以影响到战局,我们允许玩家提供一个随机数,然后再结合我们在合约的时间戳,产生一个新的随机数。对战逻辑中的所有动作都将会与这个随机数相关,下面贴出部分核心代码:
ACTION kingofighter::battle(const name player, const capi_checksum256 &seed_hash) {
...
const uint32_t NOW_TS = current_time_point().sec_since_epoch();
const uint32_t BOSS_MIN_ATK = 50;
const uint32_t BOSS_MAX_ATK = 70;
const uint32_t BOSS_HP = 700;
uint32_t hero_hp = hero->hp;
uint32_t boss_hp = BOSS_HP;
...
for (size_t i = 0; i < 32; i++) {
const uint32_t hash_val =(uint32_t) seed_hash.extract_as_byte_array()[i] + NOW_TS;
uint32_t damage;
if (i & 1) {
//i为奇数,BOSS攻击
damage = hash_val % (BOSS_MAX_ATK - BOSS_MIN_ATK + 1) + BOSS_MIN_ATK;
hero_hp = hero_hp > damage ? hero_hp - damage : 0;
} else {
//i为偶数,玩家攻击
damage = hash_val % (hero->max_atk - hero->min_atk + 1) + hero->min_atk;
//是否暴击,暴击概率25%
if (hash_val % 4 == 0)
damage += 100;
boss_hp = boss_hp > damage ? boss_hp - damage : 0;
}
//这一轮的战斗结果
scoreboard sb_item = {
.round_no = i + 1,
.attacker = i & 1 ? get_self() : player,
.defender = i & 1 ? player : get_self(),
.damage = damage,
.defender_hp = i & 1 ? hero_hp : boss_hp
};
scoreboards.emplace_back(sb_item);
//如果任何一方血量归0,战斗结束
if (hero_hp == 0 || boss_hp == 0)
break;
}
...
}
首先看看第二个入参seed_hash
是一个checksum256类型,其实就是一个长度64的哈希值。也就是玩家的随机数算哈希:hash(random)。我们在使用的时候体现为一个uint8_t[32]
,分布为32个0-255的数字。这个32个数字加上当前时间戳就是随机数,且BOSS最小攻击50,玩家英雄血量500,假设每次攻击都脸黑,BOSS击败玩家英雄需要500 / 50 = 10 轮,10 * 2 < 32,所以我们目前的需求,32个够用了。我们来运行一下看结果:
- 切回到EOS Studio,构建,然后部署。
- 创建一个玩家账号:点击右上角-》Create Account,这里输入 sweetsummer1
-
点击右上角-》Contract,选择你的合约账号,可以看到一下界面:
区域1是合约的action列表,及每个action的入参,区域2是合约的表数据。
- 我们选择
signup
,在入参填写sweetsummer1,即需要注册的玩家账号:
- 注册成功,开始调用对战的action,需要两个入参:player和seed_hash,其中player填写刚注册的账号,seed_hash需要一个64长度的字符,可以在http://tool.oschina.net/encrypt?type=2网站生成一个,随便输入一些东西, 点击生成 sha256:
看到对战的一局结果已经出来,且是玩家获胜,右边的数据表示每一轮攻击的详细信息,有发动攻击者、攻击血量、是否暴击等... - 再将表切换到
boxs
,可以看到玩家获得了一个铜宝箱:
就这样,按理说对战的结果受玩家的影响,也受时间戳的影响。好像一切都很公平很顺利,但其实是这种使用时间戳生成随机数的方法是可以被攻击的,攻击过程大概是攻击者事先计算好未来的哪个时间戳对自己有利,然后当到达这个时间点,才去调用对战action。例如:攻击者通过计算,知道在1569643270这个时间戳发动攻击,是一定会获胜,所以他自己写一个智能合约,当到达这个时间点,发动攻击就可以了,十分简单,为什么一定要使用智能合约来调用呢?因为这样可以保证时间一致。
怎么样防止攻击?现在开发者应该都已经达成共识,纯链上生成随机数是不可靠的,官方也不建议,我们需要合约开发者也生成一个随机哈希,然后加上玩家的随机哈希,合并在一起,产生随机数。但这种做法有个缺点,就是需要有个server端,下一章节我们会server端的设计,以及智能合约的相关改动。
本章节源代码地址:https://github.com/jan-gogogo/kof-chapter2