HelheimLabs / autochessia

Fully on-chain auto chess, built with MUD
https://dev.autochessia.xyz
GNU Affero General Public License v3.0
21 stars 14 forks source link

New feature: effect #67

Closed ClaudeZsb closed 11 months ago

ClaudeZsb commented 1 year ago

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 commented 1 year ago

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=105which is different with the initial value100`. 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 commented 1 year ago

Effects in Autochessia

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

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

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.