diff --git a/src/main/java/emu/grasscutter/game/ability/AbilityManager.java b/src/main/java/emu/grasscutter/game/ability/AbilityManager.java index 81ab8e60b..deb14fe54 100644 --- a/src/main/java/emu/grasscutter/game/ability/AbilityManager.java +++ b/src/main/java/emu/grasscutter/game/ability/AbilityManager.java @@ -7,6 +7,7 @@ import emu.grasscutter.data.binout.*; import emu.grasscutter.data.binout.AbilityModifier.AbilityModifierAction; import emu.grasscutter.game.ability.actions.*; import emu.grasscutter.game.ability.mixins.*; +import emu.grasscutter.game.entity.EntityAvatar; import emu.grasscutter.game.entity.GameEntity; import emu.grasscutter.game.player.*; import emu.grasscutter.game.props.FightProperty; @@ -562,6 +563,14 @@ public final class AbilityManager extends BasePlayerManager { if (killState.getKilled()) { scene.killEntity(entity); } else if (!entity.isAlive()) { + if (entity instanceof EntityAvatar) { + // TODO Should EntityAvatar act on this invocation? + // It bugs revival due to resetting HP to max when + // the avatar should just stay dead. + Grasscutter.getLogger() + .trace("Entity of ID {} is EntityAvatar. Ignoring", invoke.getEntityId()); + return; + } entity.setFightProperty( FightProperty.FIGHT_PROP_CUR_HP, entity.getFightProperty(FightProperty.FIGHT_PROP_MAX_HP)); diff --git a/src/main/java/emu/grasscutter/game/dungeons/DungeonSystem.java b/src/main/java/emu/grasscutter/game/dungeons/DungeonSystem.java index 49fca7c50..27810bf87 100644 --- a/src/main/java/emu/grasscutter/game/dungeons/DungeonSystem.java +++ b/src/main/java/emu/grasscutter/game/dungeons/DungeonSystem.java @@ -168,13 +168,21 @@ public final class DungeonSystem extends BaseGameSystem { dungeonManager.unsetTrialTeam(player); } // clean temp team if it has - player.getTeamManager().cleanTemporaryTeam(); + if (!player.getTeamManager().cleanTemporaryTeam()) + { + // no temp team. Will use real current team, but check + // for any dead avatar to prevent switching into them. + player.getTeamManager().checkCurrentAvatarIsAlive(null); + } player.getTowerManager().clearEntry(); dungeonManager.setTowerDungeon(false); - // Transfer player back to world - player.getWorld().transferPlayerToScene(player, prevScene, prevPos); - player.sendPacket(new BasePacket(PacketOpcodes.PlayerQuitDungeonRsp)); + // Transfer player back to world after a small delay. + // This wait is important for avoiding double teleports, + // which specifically happen when player quits a dungeon + // by teleporting to map waypoints. + // From testing, 200ms seem reasonable. + player.getWorld().queueTransferPlayerToScene(player, prevScene, prevPos, 200); } public void restartDungeon(Player player) { diff --git a/src/main/java/emu/grasscutter/game/entity/EntityAvatar.java b/src/main/java/emu/grasscutter/game/entity/EntityAvatar.java index d33149e3b..2bff768ba 100644 --- a/src/main/java/emu/grasscutter/game/entity/EntityAvatar.java +++ b/src/main/java/emu/grasscutter/game/entity/EntityAvatar.java @@ -67,6 +67,11 @@ public class EntityAvatar extends GameEntity { } this.initAbilities(); + + // New EntityAvatar instances are created on every scene transition. + // Ensure that isDead is properly carried over between scenes. + // Otherwise avatars could have 0 HP but not considered dead. + this.checkIfDead(); } @Override diff --git a/src/main/java/emu/grasscutter/game/entity/GameEntity.java b/src/main/java/emu/grasscutter/game/entity/GameEntity.java index 6ce1ec1e8..00a0b3150 100644 --- a/src/main/java/emu/grasscutter/game/entity/GameEntity.java +++ b/src/main/java/emu/grasscutter/game/entity/GameEntity.java @@ -174,13 +174,7 @@ public abstract class GameEntity { } this.lastAttackType = attackType; - - // Check if dead - if (this.getFightProperty(FightProperty.FIGHT_PROP_CUR_HP) <= 0f) { - this.setFightProperty(FightProperty.FIGHT_PROP_CUR_HP, 0f); - this.isDead = true; - } - + this.checkIfDead(); this.runLuaCallbacks(event); // Packets @@ -194,6 +188,17 @@ public abstract class GameEntity { } } + public void checkIfDead() { + if (this.getFightProperties() == null || !hasFightProperty(FightProperty.FIGHT_PROP_CUR_HP)) { + return; + } + + if (this.getFightProperty(FightProperty.FIGHT_PROP_CUR_HP) <= 0f) { + this.setFightProperty(FightProperty.FIGHT_PROP_CUR_HP, 0f); + this.isDead = true; + } + } + /** * Runs the Lua callbacks for {@link EntityDamageEvent}. * @@ -333,6 +338,8 @@ public abstract class GameEntity { if (entityController != null) { entityController.onDie(this, getLastAttackType()); } + + this.isDead = true; } /** Invoked when a global ability value is updated. */ diff --git a/src/main/java/emu/grasscutter/game/player/Player.java b/src/main/java/emu/grasscutter/game/player/Player.java index fc50aa29e..571da1f91 100644 --- a/src/main/java/emu/grasscutter/game/player/Player.java +++ b/src/main/java/emu/grasscutter/game/player/Player.java @@ -176,6 +176,7 @@ public class Player implements PlayerHook, FieldFetch { @Getter @Setter private Set moonCardGetTimes; @Transient @Getter private boolean paused; + @Transient @Getter @Setter private Future queuedTeleport; @Transient @Getter @Setter private int enterSceneToken; @Transient @Getter @Setter private SceneLoadState sceneLoadState = SceneLoadState.NONE; @Transient private boolean hasSentLoginPackets; diff --git a/src/main/java/emu/grasscutter/game/player/TeamManager.java b/src/main/java/emu/grasscutter/game/player/TeamManager.java index 970cf7d4b..86d7a0d45 100644 --- a/src/main/java/emu/grasscutter/game/player/TeamManager.java +++ b/src/main/java/emu/grasscutter/game/player/TeamManager.java @@ -425,6 +425,30 @@ public final class TeamManager extends BasePlayerDataManager { this.getPlayer().sendPacket(responsePacket); } + // Ensure new selected character index is alive. + // If not, change to another alive one or revive. + checkCurrentAvatarIsAlive(currentEntity); + } + + public void checkCurrentAvatarIsAlive(EntityAvatar currentEntity) { + if (currentEntity == null) { + currentEntity = this.getCurrentAvatarEntity(); + } + + // Ensure currently selected character is still alive + if (!this.getActiveTeam().get(this.currentCharacterIndex).isAlive()) { + // Character died in a dungeon challenge... + int replaceIndex = getDeadAvatarReplacement(); + if (0 <= replaceIndex && replaceIndex < this.getActiveTeam().size()) { + this.currentCharacterIndex = replaceIndex; + } else { + // Team wiped in dungeon... + // Revive and change to first avatar. + this.currentCharacterIndex = 0; + this.reviveAvatar(this.getCurrentAvatarEntity().getAvatar()); + } + } + // Check if character changed var newAvatarEntity = this.getCurrentAvatarEntity(); if (currentEntity != null && newAvatarEntity != null && currentEntity != newAvatarEntity) { @@ -700,15 +724,16 @@ public final class TeamManager extends BasePlayerDataManager { this.updateTeamEntities(null); } - public void cleanTemporaryTeam() { + public boolean cleanTemporaryTeam() { // check if using temporary team if (useTemporarilyTeamIndex < 0) { - return; + return false; } this.useTemporarilyTeamIndex = -1; this.temporaryTeam = null; this.updateTeamEntities(null); + return true; } public synchronized void setCurrentTeam(int teamId) { @@ -810,20 +835,13 @@ public final class TeamManager extends BasePlayerDataManager { // TODO: Perhaps find a way to get vanilla experience? this.getPlayer().sendPacket(new PacketWorldPlayerDieNotify(dieType, killedBy)); } else { - // Replacement avatar - EntityAvatar replacement = null; - int replaceIndex = -1; - - for (int i = 0; i < this.getActiveTeam().size(); i++) { - EntityAvatar entity = this.getActiveTeam().get(i); - if (entity.isAlive()) { - replaceIndex = i; - replacement = entity; - break; - } - } - - if (replacement == null) { + // Find replacement avatar + int replaceIndex = getDeadAvatarReplacement(); + if (0 <= replaceIndex && replaceIndex < this.getActiveTeam().size()) { + // Set index and spawn replacement member + this.setCurrentCharacterIndex(replaceIndex); + this.getPlayer().getScene().addEntity(this.getActiveTeam().get(replaceIndex)); + } else { // No more living team members... this.getPlayer().sendPacket(new PacketWorldPlayerDieNotify(dieType, killedBy)); // Invoke player team death event. @@ -831,10 +849,6 @@ public final class TeamManager extends BasePlayerDataManager { new PlayerTeamDeathEvent( this.getPlayer(), this.getActiveTeam().get(this.getCurrentCharacterIndex())); event.call(); - } else { - // Set index and spawn replacement member - this.setCurrentCharacterIndex(replaceIndex); - this.getPlayer().getScene().addEntity(replacement); } } @@ -842,6 +856,20 @@ public final class TeamManager extends BasePlayerDataManager { this.getPlayer().sendPacket(new PacketAvatarDieAnimationEndRsp(deadAvatar.getId(), 0)); } + public int getDeadAvatarReplacement() { + int replaceIndex = -1; + + for (int i = 0; i < this.getActiveTeam().size(); i++) { + EntityAvatar entity = this.getActiveTeam().get(i); + if (entity.isAlive()) { + replaceIndex = i; + break; + } + } + + return replaceIndex; + } + public boolean reviveAvatar(Avatar avatar) { for (EntityAvatar entity : this.getActiveTeam()) { if (entity.getAvatar() == avatar) { diff --git a/src/main/java/emu/grasscutter/game/world/World.java b/src/main/java/emu/grasscutter/game/world/World.java index 707fe6db6..1e4253745 100644 --- a/src/main/java/emu/grasscutter/game/world/World.java +++ b/src/main/java/emu/grasscutter/game/world/World.java @@ -2,6 +2,7 @@ package emu.grasscutter.game.world; import static emu.grasscutter.server.event.player.PlayerTeleportEvent.TeleportType.SCRIPT; +import emu.grasscutter.Grasscutter; import emu.grasscutter.data.GameData; import emu.grasscutter.data.excels.dungeon.DungeonData; import emu.grasscutter.game.entity.*; @@ -19,8 +20,10 @@ import emu.grasscutter.server.event.player.PlayerTeleportEvent.TeleportType; import emu.grasscutter.server.game.GameServer; import emu.grasscutter.server.packet.send.*; import emu.grasscutter.utils.ConversionUtils; +import io.netty.util.concurrent.FastThreadLocalThread; import it.unimi.dsi.fastutil.ints.*; import java.util.*; +import java.util.concurrent.*; import lombok.*; import org.jetbrains.annotations.NotNull; @@ -43,6 +46,16 @@ public class World implements Iterable { @Getter private boolean isPaused = false; @Getter private long currentWorldTime; + private static final ExecutorService eventExecutor = + new ThreadPoolExecutor( + 4, + 4, + 60, + TimeUnit.SECONDS, + new LinkedBlockingDeque<>(1000), + FastThreadLocalThread::new, + new ThreadPoolExecutor.AbortPolicy()); + public World(Player player) { this(player, false); } @@ -311,6 +324,17 @@ public class World implements Iterable { this.getScenes().values().forEach(Scene::saveGroups); } + public void queueTransferPlayerToScene(Player player, int sceneId, Position pos, int delayMs) { + player.setQueuedTeleport(eventExecutor.submit(() -> { + try { + Thread.sleep(delayMs); + transferPlayerToScene(player, sceneId, pos); + } catch (InterruptedException e) { + Grasscutter.getLogger().trace("queueTransferPlayerToScene: teleport to scene {} is interrupted", sceneId); + } + })); + } + public boolean transferPlayerToScene(Player player, int sceneId, Position pos) { return this.transferPlayerToScene(player, sceneId, TeleportType.INTERNAL, null, pos); } @@ -381,6 +405,16 @@ public class World implements Iterable { } public boolean transferPlayerToScene(Player player, TeleportProperties teleportProperties) { + // If a queued teleport already exists, cancel it. This prevents the player from + // becoming stranded in a dungeon due to quitting it by teleporting to a map waypoint. + synchronized (player) { + var queuedTeleport = player.getQueuedTeleport(); + if (queuedTeleport != null) { + player.setQueuedTeleport(null); + queuedTeleport.cancel(true); + } + } + // Check if the teleport properties are valid. if (teleportProperties.getTeleportTo() == null) teleportProperties.setTeleportTo(player.getPosition()); diff --git a/src/main/java/emu/grasscutter/server/packet/recv/HandlerDungeonDieOptionReq.java b/src/main/java/emu/grasscutter/server/packet/recv/HandlerDungeonDieOptionReq.java new file mode 100644 index 000000000..ba57d7e95 --- /dev/null +++ b/src/main/java/emu/grasscutter/server/packet/recv/HandlerDungeonDieOptionReq.java @@ -0,0 +1,22 @@ +package emu.grasscutter.server.packet.recv; + +import emu.grasscutter.net.packet.*; +import emu.grasscutter.net.proto.DungeonDieOptionReqOuterClass.DungeonDieOptionReq; +import emu.grasscutter.net.proto.PlayerDieOptionOuterClass.PlayerDieOption; +import emu.grasscutter.server.game.GameSession; + +@Opcodes(PacketOpcodes.DungeonDieOptionReq) +public class HandlerDungeonDieOptionReq extends PacketHandler { + + @Override + public void handle(GameSession session, byte[] header, byte[] payload) throws Exception { + DungeonDieOptionReq req = DungeonDieOptionReq.parseFrom(payload); + var dieOption = req.getDieOption(); + // TODO Handle other die options + if (req.getIsQuitImmediately()) { + session.getPlayer().getServer().getDungeonSystem().exitDungeon(session.getPlayer()); + } + session.getPlayer().sendPacket(new BasePacket(PacketOpcodes.DungeonDieOptionRsp)); + } +} +