diff --git a/src/main/java/emu/grasscutter/data/binout/AbilityModifier.java b/src/main/java/emu/grasscutter/data/binout/AbilityModifier.java index 896751926..80c951e0d 100644 --- a/src/main/java/emu/grasscutter/data/binout/AbilityModifier.java +++ b/src/main/java/emu/grasscutter/data/binout/AbilityModifier.java @@ -42,6 +42,7 @@ public class AbilityModifier implements Serializable { public String stacking; public AbilityMixinData[] modifierMixins; + public AbilityModifierProperty properties; public ElementType elementType; public DynamicFloat elementDurability = DynamicFloat.ZERO; @@ -328,6 +329,9 @@ public class AbilityModifier implements Serializable { public int skillID; public int resistanceListID; + public int monsterID; + public int summonTag; + public AbilityModifierAction[] actions; public AbilityModifierAction[] successActions; @@ -370,6 +374,11 @@ public class AbilityModifier implements Serializable { } } + public static class AbilityModifierProperty implements Serializable { + public float Actor_HpThresholdRatio; + // Add more properties here when GC needs them. + } + public enum State { LockHP, Invincible, diff --git a/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombat.java b/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombat.java index 516f5a006..38e865bd4 100644 --- a/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombat.java +++ b/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombat.java @@ -8,4 +8,5 @@ import lombok.experimental.FieldDefaults; public class ConfigCombat { // There are more values that can be added that might be useful in the json ConfigCombatProperty property; + ConfigCombatSummon summon; } diff --git a/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombatSummon.java b/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombatSummon.java new file mode 100644 index 000000000..c8c373d12 --- /dev/null +++ b/src/main/java/emu/grasscutter/data/binout/config/fields/ConfigCombatSummon.java @@ -0,0 +1,15 @@ +package emu.grasscutter.data.binout.config.fields; + +import lombok.*; +import lombok.experimental.FieldDefaults; +import java.util.List; + +@Data +public class ConfigCombatSummon { + List summonTags; + + @Getter + public final class SummonTag { + int summonTag; + } +} diff --git a/src/main/java/emu/grasscutter/game/ability/Ability.java b/src/main/java/emu/grasscutter/game/ability/Ability.java index d105f067e..0c7f5337e 100644 --- a/src/main/java/emu/grasscutter/game/ability/Ability.java +++ b/src/main/java/emu/grasscutter/game/ability/Ability.java @@ -71,6 +71,24 @@ public class Ability { .filter(action -> action.type == AbilityModifierAction.Type.AvatarSkillStart) .map(action -> action.skillID) .toList()); + + if (data.onAdded != null) { + processOnAddedAbilityModifiers(); + } + } + + public void processOnAddedAbilityModifiers() { + for (AbilityModifierAction modifierAction : data.onAdded) { + if (modifierAction.type == null) continue; + + if (modifierAction.type == AbilityModifierAction.Type.ApplyModifier) { + if (modifierAction.modifierName == null) continue; + else if (!data.modifiers.containsKey(modifierAction.modifierName)) continue; + + var modifierData = data.modifiers.get(modifierAction.modifierName); + owner.onAddAbilityModifier(modifierData); + } + } } public static String getAbilityName(AbilityString abString) { diff --git a/src/main/java/emu/grasscutter/game/ability/actions/ActionSummon.java b/src/main/java/emu/grasscutter/game/ability/actions/ActionSummon.java new file mode 100644 index 000000000..8ebe0687f --- /dev/null +++ b/src/main/java/emu/grasscutter/game/ability/actions/ActionSummon.java @@ -0,0 +1,62 @@ +package emu.grasscutter.game.ability.actions; + +import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; +import emu.grasscutter.Grasscutter; +import emu.grasscutter.data.GameData; +import emu.grasscutter.data.binout.AbilityModifier.AbilityModifierAction; +import emu.grasscutter.game.ability.Ability; +import emu.grasscutter.game.entity.*; +import emu.grasscutter.game.world.*; +import emu.grasscutter.server.packet.send.PacketMonsterSummonTagNotify; +import emu.grasscutter.net.proto.EPKDEHOJFLIOuterClass.EPKDEHOJFLI; +import emu.grasscutter.utils.*; + +@AbilityAction(AbilityModifierAction.Type.Summon) +public class ActionSummon extends AbilityActionHandler { + @Override + public synchronized boolean execute( + Ability ability, AbilityModifierAction action, ByteString abilityData, GameEntity target) { + EPKDEHOJFLI summonPosRot = null; + try { + // In game version 4.0, summoned entity's + // position and rotation are packed in EPKDEHOJFLI. + // This is packet AbilityActionSummon and has two fields: + // 4: Vector pos + // 13: Vector rot + summonPosRot = EPKDEHOJFLI.parseFrom(abilityData); + } catch (InvalidProtocolBufferException e) { + Grasscutter.getLogger().error("Failed to parse abilityData: {}", Utils.bytesToHex(abilityData.toByteArray())); + return false; + } + + var pos = new Position(summonPosRot.getPos()); + var rot = new Position(summonPosRot.getRot()); + var monsterId = action.monsterID; + + var scene = target.getScene(); + + var monsterData = GameData.getMonsterDataMap().get(monsterId); + if (monsterData == null) { + Grasscutter.getLogger().error("Failed to find monster by ID {}", monsterId); + return false; + } + + if (target instanceof EntityMonster ownerEntity) { + var level = scene.getLevelForMonster(0, ownerEntity.getLevel()); + var entity = new EntityMonster(scene, monsterData, pos, rot, level); + ownerEntity.getSummonTagMap().put(action.summonTag, entity); + entity.setSummonedTag(action.summonTag); + entity.setOwnerEntityId(target.getId()); + scene.addEntity(entity); + scene.getPlayers().get(0).sendPacket(new PacketMonsterSummonTagNotify(ownerEntity)); + + Grasscutter.getLogger().trace("Spawned entityId {} monsterId {} pos {} rot {}, target { {} }, action { {} }", + entity.getId(), monsterId, pos, rot, target, action); + + return true; + } else { + return false; + } + } +} diff --git a/src/main/java/emu/grasscutter/game/entity/EntityMonster.java b/src/main/java/emu/grasscutter/game/entity/EntityMonster.java index 4656c9d50..f8db2acc8 100644 --- a/src/main/java/emu/grasscutter/game/entity/EntityMonster.java +++ b/src/main/java/emu/grasscutter/game/entity/EntityMonster.java @@ -25,6 +25,7 @@ import emu.grasscutter.net.proto.SceneEntityAiInfoOuterClass.SceneEntityAiInfo; import emu.grasscutter.net.proto.SceneEntityInfoOuterClass.SceneEntityInfo; import emu.grasscutter.net.proto.SceneMonsterInfoOuterClass.SceneMonsterInfo; import emu.grasscutter.net.proto.SceneWeaponInfoOuterClass.SceneWeaponInfo; +import emu.grasscutter.net.proto.ServantInfoOuterClass.ServantInfo; import emu.grasscutter.scripts.constants.EventType; import emu.grasscutter.scripts.data.*; import emu.grasscutter.server.event.entity.EntityDamageEvent; @@ -49,6 +50,9 @@ public class EntityMonster extends GameEntity { @Getter private final Position bornPos; @Getter private final int level; @Getter private EntityWeapon weaponEntity; + @Getter private Map summonTagMap; + @Getter @Setter private int summonedTag; + @Getter @Setter private int ownerEntityId; @Getter @Setter private int poseId; @Getter @Setter private int aiId = -1; @@ -67,6 +71,9 @@ public class EntityMonster extends GameEntity { this.bornPos = this.getPosition().clone(); this.level = level; this.playerOnBattle = new ArrayList<>(); + this.summonTagMap = new HashMap<>(); + this.summonedTag = 0; + this.ownerEntityId = 0; if (GameData.getMonsterMappingMap().containsKey(this.getMonsterId())) { this.configEntityMonster = @@ -76,6 +83,14 @@ public class EntityMonster extends GameEntity { this.configEntityMonster = null; } + if (this.configEntityMonster != null && + this.configEntityMonster.getCombat() != null && + this.configEntityMonster.getCombat().getSummon() != null && + this.configEntityMonster.getCombat().getSummon().getSummonTags() != null) { + this.configEntityMonster.getCombat().getSummon().getSummonTags().forEach( + t -> this.summonTagMap.put(t.getSummonTag(), null)); + } + // Monster weapon if (getMonsterWeaponId() > 0) { this.weaponEntity = new EntityWeapon(scene, getMonsterWeaponId()); @@ -316,6 +331,11 @@ public class EntityMonster extends GameEntity { this.getMonsterData().getType().getValue()); scene.triggerDungeonEvent( DungeonPassConditionType.DUNGEON_COND_KILL_MONSTER, this.getMonsterId()); + + // If this entity spawned servants, kill those too. + summonTagMap.values().stream() + .filter(Objects::nonNull) + .forEach(entity -> scene.killEntity(entity, killerId)); } public void recalcStats() { @@ -387,14 +407,21 @@ public class EntityMonster extends GameEntity { public SceneEntityInfo toProto() { var data = this.getMonsterData(); + var aiInfo = + SceneEntityAiInfo.newBuilder() + .setIsAiOpen(true) + .setBornPos(this.getBornPos().toProto()); + if (ownerEntityId != 0) { + aiInfo.setServantInfo( + ServantInfo.newBuilder() + .setMasterEntityId(ownerEntityId)); + } + var authority = EntityAuthorityInfo.newBuilder() .setAbilityInfo(AbilitySyncStateInfo.newBuilder()) .setRendererChangedInfo(EntityRendererChangedInfo.newBuilder()) - .setAiInfo( - SceneEntityAiInfo.newBuilder() - .setIsAiOpen(true) - .setBornPos(this.getBornPos().toProto())) + .setAiInfo(aiInfo) .setBornPos(this.getBornPos().toProto()) .build(); @@ -425,7 +452,10 @@ public class EntityMonster extends GameEntity { .setAuthorityPeerId(this.getWorld().getHostPeerId()) .setPoseId(this.getPoseId()) .setBlockId(this.getScene().getId()) + .setSummonedTag(this.summonedTag) + .setOwnerEntityId(this.ownerEntityId) .setBornType(MonsterBornType.MONSTER_BORN_TYPE_DEFAULT); + summonTagMap.forEach((k, v) -> monsterInfo.putSummonTagMap(k, v == null ? 0 : 1)); if (this.metaMonster != null) { if (this.metaMonster.special_name_id != 0) { diff --git a/src/main/java/emu/grasscutter/game/entity/GameEntity.java b/src/main/java/emu/grasscutter/game/entity/GameEntity.java index 00a0b3150..6d7d6e6e5 100644 --- a/src/main/java/emu/grasscutter/game/entity/GameEntity.java +++ b/src/main/java/emu/grasscutter/game/entity/GameEntity.java @@ -1,6 +1,8 @@ package emu.grasscutter.game.entity; +import emu.grasscutter.Grasscutter; import emu.grasscutter.data.GameData; +import emu.grasscutter.data.binout.*; import emu.grasscutter.game.ability.*; import emu.grasscutter.game.player.Player; import emu.grasscutter.game.props.*; @@ -32,6 +34,8 @@ public abstract class GameEntity { @Getter @Setter private int lastMoveReliableSeq; @Getter @Setter private boolean lockHP; + private boolean limbo; + private float limboHpThreshold; @Setter(AccessLevel.PROTECTED) @Getter @@ -110,6 +114,20 @@ public abstract class GameEntity { }); } + protected void setLimbo(float hpThreshold) { + limbo = true; + limboHpThreshold = hpThreshold; + } + + public void onAddAbilityModifier(AbilityModifier data) { + // Set limbo state (invulnerability at a certain HP threshold) + // if ability modifier calls for it + if (data.state == AbilityModifier.State.Limbo && + data.properties != null && data.properties.Actor_HpThresholdRatio > .0f) { + this.setLimbo(data.properties.Actor_HpThresholdRatio); + } + } + protected MotionInfo getMotionInfo() { return MotionInfo.newBuilder() .setPos(this.getPosition().toProto()) @@ -167,11 +185,26 @@ public abstract class GameEntity { return; // If the event is canceled, do not damage the entity. } + float effectiveDamage = 0; float curHp = getFightProperty(FightProperty.FIGHT_PROP_CUR_HP); - if (curHp != Float.POSITIVE_INFINITY && !lockHP || lockHP && curHp <= event.getDamage()) { - // Add negative HP to the current HP property. - this.addFightProperty(FightProperty.FIGHT_PROP_CUR_HP, -(event.getDamage())); + if (limbo) { + float maxHp = getFightProperty(FightProperty.FIGHT_PROP_MAX_HP); + float curRatio = curHp / maxHp; + if (curRatio > limboHpThreshold) { + // OK if this hit takes HP below threshold. + effectiveDamage = event.getDamage(); + } + if (effectiveDamage >= curHp && limboHpThreshold > .0f) { + // Don't let entity die while in limbo. + effectiveDamage = curHp - 1; + } } + else if (curHp != Float.POSITIVE_INFINITY && !lockHP || lockHP && curHp <= event.getDamage()) { + effectiveDamage = event.getDamage(); + } + + // Add negative HP to the current HP property. + this.addFightProperty(FightProperty.FIGHT_PROP_CUR_HP, -effectiveDamage); this.lastAttackType = attackType; this.checkIfDead(); diff --git a/src/main/java/emu/grasscutter/game/world/World.java b/src/main/java/emu/grasscutter/game/world/World.java index 4c53acb8c..434583f6f 100644 --- a/src/main/java/emu/grasscutter/game/world/World.java +++ b/src/main/java/emu/grasscutter/game/world/World.java @@ -167,7 +167,7 @@ public class World implements Iterable { * @param idType The entity type. * @return The next entity ID. */ - public int getNextEntityId(EntityIdType idType) { + public synchronized int getNextEntityId(EntityIdType idType) { return (idType.getId() << 24) + ++this.nextEntityId; } diff --git a/src/main/java/emu/grasscutter/server/packet/send/PacketMonsterSummonTagNotify.java b/src/main/java/emu/grasscutter/server/packet/send/PacketMonsterSummonTagNotify.java new file mode 100644 index 000000000..dab91e2d9 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/packet/send/PacketMonsterSummonTagNotify.java @@ -0,0 +1,23 @@ +package emu.grasscutter.server.packet.send; + +import emu.grasscutter.game.entity.EntityMonster; +import emu.grasscutter.game.player.Player; +import emu.grasscutter.game.world.Scene; +import emu.grasscutter.net.packet.*; +import emu.grasscutter.net.proto.MonsterSummonTagNotifyOuterClass.MonsterSummonTagNotify; +import java.util.*; +import static java.util.Map.entry; + +public class PacketMonsterSummonTagNotify extends BasePacket { + + public PacketMonsterSummonTagNotify(EntityMonster monsterEntity) { + super(PacketOpcodes.MonsterSummonTagNotify); + + var proto = + MonsterSummonTagNotify.newBuilder() + .setMonsterEntityId(monsterEntity.getId()); + monsterEntity.getSummonTagMap().forEach((k, v) -> proto.putSummonTagMap(k, v == null ? 0 : 1)); + + this.setData(proto.build()); + } +}