Closed ClaudeZsb closed 11 months ago
Regardless of our game and evm characters, this would be good effect system to me.
NOTE.
interface, abstract, contract
don't mean that we should implement effects through contracts. I just usesolidity
syntax to represent their inter-relationship.
interface ATTRIBUTE_MODIFIER {
bytes4 constant ATTRIBUTE_MODIFIER_ID = byte4(keccak256(bytes("ATTRIBUTE_MODIFIER")));
function applyAttributeModifier(ENTITY memory _entity) external {}
}
interface ON_RECEIVE_DAMAGE {
bytes4 constant ON_RECEIVE_DAMAGE_ID = byte4(keccak256(bytes("ON_RECEIVE_DAMAGE")));
function onReceiveDamage(ENTITY memory _entity, uint _damage) external {}
}
interface ON_ATTACK {
bytes4 constant ON_ATTACK_ID = byte4(keccak256(bytes("ON_ATTACK")));
function onAttack(ENTITY memory _entity) external {}
}
abstract BASE_EFFECT {
mapping[] INTERFACE_IDENTIFIER
function supports(byte4 _id) public virtual return (bool) {}
}
contract EFFECT_1 is ATTRIBUTE_MODIFIER, BASE_EFFECT;
contract EFFECT_2 is ON_RECEIVE_DAMAGE, BASE_EFFECT;
contract EFFECT_3 is ATTRIBUTE_MODIFIER, ON_RECEIVE_DAMAGE, BASE_EFFECT;
contract EFFECT_3 is ATTRIBUTE_MODIFIER, ON_RECEIVE_DAMAGE, BASE_EFFECT;
interface BASE_EFFECT {
function isAlive() external returns (bool) {}
}
NOTE: Cache the modified value would be buggy when we try to remove an attribute modifier that contains
*
operation. For example, we have two positive buff 1. +10 attack power 2. 2 attack power. If we apply it in order to an entity with 100 attack power, the final value would be `(100+10)2=220. Assuming the first buff disappears first, and then the second buff, we would finally get
(220-10)/2=105which is different with the initial value
100`. So we need to cache also the exact adding value when we apply a buff with attribute modifier, in order to not getting wrong values after removal.
interface ATTRIBUTE_MODIFIER {
function applyAttributeModifier(ENTITY memory _entity) external {}
function removeAttributeModifier(ENTITY memory _entity) external {}
}
Then if an entity wants to attack another entity, we would process like this:
function doAttack(ENTITY memory _attacker, ENTITY memory _target) public {
// check buff supports ON_ATTACK
for (uint i; i < _attacker.buffs.length; ++i) {
address buff = _attacker.buffs[i];
if (buff.supports(ON_ATTACK_ID)) {
ON_ATTACK(buff).onAttack(_attacker);
}
}
uint damage = calculateDamage(_attacker, _target);
// trigger receiveDamage
doReceiveDamage(_target, damage);
}
function doReceiveDamage(ENTITY memory _target, uint _damage) public {
// check buff supports ON_RECEIVE_DAMAGE
for (uint i; i < _target.buffs.length; ++i) {
address buff = _target.buffs[i];
if (buff.supports(ON_RECEIVE_DAMAGE_ID)) {
ON_RECEIVE_DAMAGE(buff).onReceiveDamage(_target, _damage);
}
}
}
Before discussing the exact implementation, let me remind you how does one turn be processed in our game.
tick
.This is really expensive. I think that we should make it lighter like that a piece would only attack the frontmost enemy.
MOVE
, MOVE&ATTACK
or ATTACK
.At first we should define how effects are described or stored in our game.
enums: {
EventType: ["NONE", "ON_MOVE", "ON_ATTACK", "ON_CAST", "ON_DAMAGE", "ON_DEATH", "ON_END_TURN"]
}
Effect: {
keySchema: {
index: "uint16"
},
schema: {
modifier: "uint160",
trigger: "uint96"
}
}
Piece: {
schema: {
effects: "uint192",
}
}
index | withModifier | eventType | direct | place-holder | internal-index |
---|---|---|---|---|---|
length | 1 bit | 4 bits | 1 bit | 6 bits | 4 bits |
place-holder
will be used for future extension. If we need to define whether an effect could be removed, then we'll use 1 bit from place-holder
to represent removability of the effect.internal-index
is an incremental number counting from 0
and is used to distinguish an effect from other effects of the same type. 4 bits
means we limit that there would be at most 16
different effects of the same type.effect_index=0
is invalid.direct
details the case when this effect will be triggered under the specific event. Honestly speaking, at this moment we consider that all event has two affected entities and only effects on the affected entities could be triggered. For example, under an event ON_ATTACK
, a effect with direct=false
is triggered only when the entity is attacked and one with direct=true
is triggered only when the entity is attacking others. In other word, direct
field is introduced to expand the eventType
. Of course we can split event ON_ATTACK
into two different events ON_ATTACKING
and ON_ATTACKED
. I think this would be more readable, and we probably make this change at a time in future.index = binary 0 0001 000000 0001
is the second defined effect in type of withModifier=true
and eventType=ON_MOVE
.uint16
is too small to fit our demand, we could expand it to uint24
or larger.modifier
has length of 160 bits
and is the first part of the effect data. It would be a composable value like effect index and describes all the attribute modifications. Since it's limited to 160 bits, if we use 20 bits to describe the modification of one attribute, then we can have at most 8 different modification within one effect. That is enough while we only have health
, attack
, range
, defense
, speed
, movement
, totally 6 kinds of attribute now. Then let's talk about how we divide 20 bits.
We now have 6 kinds of attribute. Considering scalability, I think 4 bits assigned to attributeId
is sufficient. 1 bit should be assigned to modifierOperation
that denotes how the change is applied to the attribute. 1 bit for the sign. Then the left 14 can be the change value.
modifier | attributeId | operation | sign | change |
---|---|---|---|---|
length | 4 bit | 1 bit | 1bit | 14 bits |
If 96 bits are not sufficient, we can borrow some from
modifier
. Because 8 attribute modification at the same time are still too much.
9.2 update
Not all effects should be triggered even if the matched even is emited. There is probably an additional checker to decide whether the effect is triggered finally. For example, when a piece attacks others, it will have a possibility of 20% to cast a chain of lightning. There may be many kinds of checker, but we can describe it as an environment state extractor and a selector. envExtractor
is composed of a uint8 type
and a uint8 data
that means there will be at most 256 different kinds of env extractor. The supplementary data might be a little bit small so we can not support some extractor like how many enemies of which health is more than 500. We're not expected to have much complicated selector logic, so uint8
would be good.
env extractor | type | data |
---|---|---|
length | 8 bits | 8 bit |
checker | env extractor | selector |
---|---|---|
length | 16 bits | 8 bit |
So there are two different types of trigger, one is to apply at most two effects to whatever target. This type of trigger is mainly used for modifying pieces' attribute including current health(it means dealing real damage). The other one is to do one sub-action(attack
, cast
or move
). This is mainly used for realizing more sophiscated gameplay like dealing damage to pieces around when the piece is attacked.
We need to allocate a space for describing how we use the value returned by env extractor. If it's a possibility checker, the value returned will be 0/1. If it's an extractor about how many allies locate around this entity, the value returned would be the exact number of adjacent allies. An effect can choose to how to use the value, maybe like the more allies surrounded, the more damage the entity can deal, or do whatever thing that has no relationship with the value.
trigger | checker. | isSubAction | place holder | appliedTo | effect | appliedTo | effect |
---|---|---|---|---|---|---|---|
length | 24 bits | 0 (1 bit) | 7 bits | 8 bits | 24 bits | 8 bits | 24 bits |
trigger | checker. | isSubAction | place holder | subAction description |
---|---|---|---|---|
length | 24 bits | 1 (1 bit) | 7 bits | 64 bits |
subAction description
will be detailed during hero ability design. Basically there will be three basic action type:attack
,cast
andmove
.
9.7 update
subAction | type | appliedTo | data |
---|---|---|---|
length | 8 bits | 8 bits | 48 bits |
At present, only an attack
type sub action is implemented. So the composition of subAction
seems to be very simple. When we support equipments and abilities, we could re-design this part.
Effect
table shows how does each effect work. When an effect is applied to an Piece
, we should write down its duration. We can use an uint8
to represent the duration, where 0
means permanent effect and an temporary effect can last up to 255 turns. Now we can use uint16(Effect.index)+uint8(duration)
to represent an alive effect of which length is 24 bits.
I want to limit the maximum total effect number of a Piece
to 8. That's why I use uint192=8*uint24
as Piece.effects
.
Piece.effects | effectId | duration | ... |
---|---|---|---|
length | 16 bit | 8 bits | ... |
Effect
table should be initialized as Creature
. It means that we predefine all available effects when we deploy the game. So even if two effects are slightly different in health modification like one is +10HP
and the other one is +20HP
, but they have totally the same trigger, they have still different index. Now you might want to propose a counter idea about making effect composable so that the same trigger can be stored only once in storage. Obviously it saves gas from writing states. But it will be a question that if this structure saves gas from reading. If n
parts are used to compose a complete effect and apparently they are stored in different slots, actually we are reading at least n*32bytes
from evm. I've tested in mud
that writing a specific data is only 3 times more expensive than reading it. So in a scenario where write once but read multi-times, packing data tightly as much as possible would be the first choice. That's why I'm using one slot to separately represent each different effect rather than make it composable.
NOTE. This also remind me the design for creature of different tier. I'm thinking about removing attribute amplifier in
CreatureConfig
table, and initializing all creature of all possible tier. Further more, we can drop basic attribute field inPiece
table and only store values that need to be cached like position, current health and alive effects. At each turn, we read the basic attribute fromCreature
and recalculate the exact attribute value based on alive effects in memory. Then do the same for battle.
Come back to the question about how we describe that an equipment or an hero ability has an effect. The answer is quite simple. Just like how we represent the effects applied to a Piece
. An uint24=uint16+uint8
is sufficient. If an equipment can grant an effect to the hero wearing it, we can add a field effect
of type uint24
to the equipment table and it'll work. The same for hero ability.
purpose
In order to realize more complex battle system that supports skills, buffs and debuffs.
Future features like hero abilities, equipment, racial effects would heavily rely on this.