Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New feature: effect #67

Closed
ClaudeZsb opened this issue Aug 1, 2023 · 2 comments · Fixed by #139
Closed

New feature: effect #67

ClaudeZsb opened this issue Aug 1, 2023 · 2 comments · Fixed by #139
Assignees

Comments

@ClaudeZsb
Copy link
Collaborator

ClaudeZsb commented Aug 1, 2023

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.

@ClaudeZsb ClaudeZsb self-assigned this Aug 1, 2023
@ClaudeZsb ClaudeZsb moved this to In Progress in Long-term dev Aug 1, 2023
@ClaudeZsb
Copy link
Collaborator Author

ClaudeZsb commented Aug 1, 2023

How effects are implemented in games

Regardless of our game and evm characters, this would be good effect system to me.

1. effects can have attribute modifier, special process triggered by specific event or both of two.

NOTE. interface, abstract, contract don't mean that we should implement effects through contracts. I just use solidity 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;

2. effects have durations.

interface BASE_EFFECT {
    function isAlive() external returns (bool) {}
}

(optional)3. effects need a remove method if we cache the modified value instead of regenerating the exact value each time

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=105 which 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);
        }
    }
}

@ClaudeZsb
Copy link
Collaborator Author

ClaudeZsb commented Aug 1, 2023

Effects in Autochessia

Before discussing the exact implementation, let me remind you how does one turn be processed in our game.

  • We cached each pieces' attribute in contract storage and read it at the beginning of each call at tick.
  • Auto-battle logicis that for each piece, it'll try to attack every enemy piece and choose the best target according a predefined rule.

    This is really expensive. I think that we should make it lighter like that a piece would only attack the frontmost enemy.

  • Once the target is chosen, it produces an action. The action may be MOVE, MOVE&ATTACK or ATTACK.
  • At last we do the action, where we update pieces' position and health.

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",
    }
}

effect index

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.
  • IMPORTANT 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.
  • As an example the effect with index = binary 0 0001 000000 0001 is the second defined effect in type of withModifier=true and eventType=ON_MOVE.
  • One day the index of type uint16 is too small to fit our demand, we could expand it to uint24 or larger.

modifier

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

trigger

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 and move.

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.

Piece.effects

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 ...

How we generate an effect from an hero ability or an equipment?

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 in Piece 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 from Creature 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.

@noyyyy noyyyy pinned this issue Aug 10, 2023
@ClaudeZsb ClaudeZsb linked a pull request Aug 16, 2023 that will close this issue
@noyyyy noyyyy linked a pull request Aug 24, 2023 that will close this issue
@github-project-automation github-project-automation bot moved this from In Progress to Done in Long-term dev Nov 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging a pull request may close this issue.

1 participant