diff --git a/src/main/java/emu/grasscutter/game/managers/StaminaManager/AfterUpdateStaminaListener.java b/src/main/java/emu/grasscutter/game/managers/StaminaManager/AfterUpdateStaminaListener.java new file mode 100644 index 000000000..bb4f0b188 --- /dev/null +++ b/src/main/java/emu/grasscutter/game/managers/StaminaManager/AfterUpdateStaminaListener.java @@ -0,0 +1,12 @@ +package emu.grasscutter.game.managers.StaminaManager; + +public interface AfterUpdateStaminaListener { + /** + * onBeforeUpdateStamina() will be called before StaminaManager attempt to update the player's current stamina. + * This gives listeners a chance to intercept this update. + * + * @param reason Why updating stamina. + * @param newStamina New Stamina value. + */ + void onAfterUpdateStamina(String reason, int newStamina); +} diff --git a/src/main/java/emu/grasscutter/game/managers/StaminaManager/BeforeUpdateStaminaListener.java b/src/main/java/emu/grasscutter/game/managers/StaminaManager/BeforeUpdateStaminaListener.java new file mode 100644 index 000000000..02f1f3522 --- /dev/null +++ b/src/main/java/emu/grasscutter/game/managers/StaminaManager/BeforeUpdateStaminaListener.java @@ -0,0 +1,20 @@ +package emu.grasscutter.game.managers.StaminaManager; + +public interface BeforeUpdateStaminaListener { + /** + * onBeforeUpdateStamina() will be called before StaminaManager attempt to update the player's current stamina. + * This gives listeners a chance to intercept this update. + * @param reason Why updating stamina. + * @param newStamina New ABSOLUTE stamina value. + * @return true if you want to cancel this update, otherwise false. + */ + int onBeforeUpdateStamina(String reason, int newStamina); + /** + * onBeforeUpdateStamina() will be called before StaminaManager attempt to update the player's current stamina. + * This gives listeners a chance to intercept this update. + * @param reason Why updating stamina. + * @param consumption ConsumptionType and RELATIVE stamina change amount. + * @return true if you want to cancel this update, otherwise false. + */ + Consumption onBeforeUpdateStamina(String reason, Consumption consumption); +} \ No newline at end of file diff --git a/src/main/java/emu/grasscutter/game/managers/StaminaManager/ConsumptionType.java b/src/main/java/emu/grasscutter/game/managers/StaminaManager/ConsumptionType.java index 9a2d8ae24..9afb2171c 100644 --- a/src/main/java/emu/grasscutter/game/managers/StaminaManager/ConsumptionType.java +++ b/src/main/java/emu/grasscutter/game/managers/StaminaManager/ConsumptionType.java @@ -10,10 +10,10 @@ public enum ConsumptionType { SPRINT(-1800), DASH(-360), FLY(-60), - SWIM_DASH_START(-200), - SWIM_DASH(-200), - SWIMMING(-80), - FIGHT(0), + SWIM_DASH_START(-20), + SWIM_DASH(-204), + SWIMMING(-80), // TODO: Slow swimming is handled per movement, not per second. Movement frequency depends on gender/age/height. + FIGHT(0), // See StaminaManager.getFightConsumption() // restore STANDBY(500), diff --git a/src/main/java/emu/grasscutter/game/managers/StaminaManager/README.md b/src/main/java/emu/grasscutter/game/managers/StaminaManager/README.md new file mode 100644 index 000000000..39a4e7988 --- /dev/null +++ b/src/main/java/emu/grasscutter/game/managers/StaminaManager/README.md @@ -0,0 +1,73 @@ +# Stamina Manager + +--- +## UpdateStamina +```java +// will use consumption.consumptionType as reason +public int updateStaminaRelative(GameSession session, Consumption consumption); +``` +```java +public int updateStaminaAbsolute(GameSession session, String reason, int newStamina) +``` + +--- +## Pause and Resume +```java +public void startSustainedStaminaHandler() +``` +```java +public void stopSustainedStaminaHandler() +``` + + +--- +## Stamina change listeners and intercepting +### BeforeUpdateStaminaListener +```java + +import emu.grasscutter.game.managers.StaminaManager.BeforeUpdateStaminaListener; + +// Listener sample: plugin disable CLIMB_JUMP stamina cost. +private class MyClass implements BeforeUpdateStaminaListener { + // Make your class implement the listener, and pass in your class as a listener. + + public MyClass() { + getStaminaManager().registerBeforeUpdateStaminaListener("myClass", this); + } + + @Override + public boolean onBeforeUpdateStamina(String reason, int newStamina) { + // do not intercept this update + return false; + } + + @Override + public boolean onBeforeUpdateStamina(String reason, Consumption consumption) { + // Try to intercept if this update is CLIMB_JUMP + if (consumption.consumptionType == ConsumptionType.CLIMB_JUMP) { + return true; + } + // If it is not CLIMB_JUMP, do not intercept. + return false; + } +} +``` +### AfterUpdateStaminaListener +```java + +import emu.grasscutter.game.managers.StaminaManager.AfterUpdateStaminaListener; + +// Listener sample: plugin listens for changes already made. +private class MyClass implements AfterUpdateStaminaListener { + // Make your class implement the listener, and pass in your class as a listener. + + public MyClass() { + registerAfterUpdateStaminaListener("myClass", this); + } + + @Override + public void onAfterUpdateStamina(String reason, int newStamina) { + // ... + } +} +``` \ No newline at end of file diff --git a/src/main/java/emu/grasscutter/game/managers/StaminaManager/StaminaManager.java b/src/main/java/emu/grasscutter/game/managers/StaminaManager/StaminaManager.java index 5065b12b3..72b91c055 100644 --- a/src/main/java/emu/grasscutter/game/managers/StaminaManager/StaminaManager.java +++ b/src/main/java/emu/grasscutter/game/managers/StaminaManager/StaminaManager.java @@ -29,100 +29,174 @@ public class StaminaManager { private Position previousCoordinates = new Position(0, 0, 0); private MotionState currentState = MotionState.MOTION_STANDBY; private MotionState previousState = MotionState.MOTION_STANDBY; - private final Timer sustainedStaminaHandlerTimer = new Timer(); - private final SustainedStaminaHandler handleSustainedStamina = new SustainedStaminaHandler(); - private boolean timerRunning = false; + private Timer sustainedStaminaHandlerTimer; private GameSession cachedSession = null; private GameEntity cachedEntity = null; private int staminaRecoverDelay = 0; - private boolean isInSkillMove = false; - public boolean getIsInSkillMove() { - return isInSkillMove; - } - public void setIsInSkillMove(boolean b) { - isInSkillMove = b; - } + + private HashMap beforeUpdateStaminaListeners = new HashMap<>(); + private HashMap afterUpdateStaminaListeners = new HashMap<>(); public StaminaManager(Player player) { this.player = player; MotionStatesCategorized.put("SWIM", new HashSet<>(Arrays.asList( - MotionState.MOTION_SWIM_MOVE, - MotionState.MOTION_SWIM_IDLE, - MotionState.MOTION_SWIM_DASH, - MotionState.MOTION_SWIM_JUMP + MotionState.MOTION_SWIM_MOVE, + MotionState.MOTION_SWIM_IDLE, + MotionState.MOTION_SWIM_DASH, + MotionState.MOTION_SWIM_JUMP ))); MotionStatesCategorized.put("STANDBY", new HashSet<>(Arrays.asList( - MotionState.MOTION_STANDBY, - MotionState.MOTION_STANDBY_MOVE, - MotionState.MOTION_DANGER_STANDBY, - MotionState.MOTION_DANGER_STANDBY_MOVE, - MotionState.MOTION_LADDER_TO_STANDBY, - MotionState.MOTION_JUMP_UP_WALL_FOR_STANDBY + MotionState.MOTION_STANDBY, + MotionState.MOTION_STANDBY_MOVE, + MotionState.MOTION_DANGER_STANDBY, + MotionState.MOTION_DANGER_STANDBY_MOVE, + MotionState.MOTION_LADDER_TO_STANDBY, + MotionState.MOTION_JUMP_UP_WALL_FOR_STANDBY ))); MotionStatesCategorized.put("CLIMB", new HashSet<>(Arrays.asList( - MotionState.MOTION_CLIMB, - MotionState.MOTION_CLIMB_JUMP, - MotionState.MOTION_STANDBY_TO_CLIMB, - MotionState.MOTION_LADDER_IDLE, - MotionState.MOTION_LADDER_MOVE, - MotionState.MOTION_LADDER_SLIP, - MotionState.MOTION_STANDBY_TO_LADDER + MotionState.MOTION_CLIMB, + MotionState.MOTION_CLIMB_JUMP, + MotionState.MOTION_STANDBY_TO_CLIMB, + MotionState.MOTION_LADDER_IDLE, + MotionState.MOTION_LADDER_MOVE, + MotionState.MOTION_LADDER_SLIP, + MotionState.MOTION_STANDBY_TO_LADDER ))); MotionStatesCategorized.put("FLY", new HashSet<>(Arrays.asList( - MotionState.MOTION_FLY, - MotionState.MOTION_FLY_IDLE, - MotionState.MOTION_FLY_SLOW, - MotionState.MOTION_FLY_FAST, - MotionState.MOTION_POWERED_FLY + MotionState.MOTION_FLY, + MotionState.MOTION_FLY_IDLE, + MotionState.MOTION_FLY_SLOW, + MotionState.MOTION_FLY_FAST, + MotionState.MOTION_POWERED_FLY ))); MotionStatesCategorized.put("RUN", new HashSet<>(Arrays.asList( - MotionState.MOTION_DASH, - MotionState.MOTION_DANGER_DASH, - MotionState.MOTION_DASH_BEFORE_SHAKE, - MotionState.MOTION_RUN, - MotionState.MOTION_DANGER_RUN, - MotionState.MOTION_WALK, - MotionState.MOTION_DANGER_WALK + MotionState.MOTION_DASH, + MotionState.MOTION_DANGER_DASH, + MotionState.MOTION_DASH_BEFORE_SHAKE, + MotionState.MOTION_RUN, + MotionState.MOTION_DANGER_RUN, + MotionState.MOTION_WALK, + MotionState.MOTION_DANGER_WALK ))); MotionStatesCategorized.put("FIGHT", new HashSet<>(Arrays.asList( - MotionState.MOTION_FIGHT + MotionState.MOTION_FIGHT ))); + + MotionStatesCategorized.put("SKIFF", new HashSet<>(Arrays.asList( + MotionState.MOTION_SKIFF_BOARDING, + MotionState.MOTION_SKIFF_NORMAL, + MotionState.MOTION_SKIFF_DASH, + MotionState.MOTION_SKIFF_POWERED_DASH + ))); + } + + // Listeners + + public boolean registerBeforeUpdateStaminaListener(String listenerName, BeforeUpdateStaminaListener listener) { + if (beforeUpdateStaminaListeners.containsKey(listenerName)) { + return false; + } + beforeUpdateStaminaListeners.put(listenerName, listener); + return true; + } + + public boolean unregisterBeforeUpdateStaminaListener(String listenerName) { + if (!beforeUpdateStaminaListeners.containsKey(listenerName)) { + return false; + } + beforeUpdateStaminaListeners.remove(listenerName); + return true; + } + + public boolean registerAfterUpdateStaminaListener(String listenerName, AfterUpdateStaminaListener listener) { + if (afterUpdateStaminaListeners.containsKey(listenerName)) { + return false; + } + afterUpdateStaminaListeners.put(listenerName, listener); + return true; + } + + public boolean unregisterAfterUpdateStaminaListener(String listenerName) { + if (!afterUpdateStaminaListeners.containsKey(listenerName)) { + return false; + } + afterUpdateStaminaListeners.remove(listenerName); + return true; } private boolean isPlayerMoving() { float diffX = currentCoordinates.getX() - previousCoordinates.getX(); float diffY = currentCoordinates.getY() - previousCoordinates.getY(); float diffZ = currentCoordinates.getZ() - previousCoordinates.getZ(); - Grasscutter.getLogger().debug("isPlayerMoving: " + previousCoordinates + ", " + currentCoordinates + + Grasscutter.getLogger().trace("isPlayerMoving: " + previousCoordinates + ", " + currentCoordinates + ", " + diffX + ", " + diffY + ", " + diffZ); return Math.abs(diffX) > 0.3 || Math.abs(diffY) > 0.2 || Math.abs(diffZ) > 0.3; } - // Returns new stamina and sends PlayerPropNotify - public int updateStamina(GameSession session, Consumption consumption) { + public int updateStaminaRelative(GameSession session, Consumption consumption) { int currentStamina = player.getProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA); if (consumption.amount == 0) { return currentStamina; } + // notify will update + for (Map.Entry listener : beforeUpdateStaminaListeners.entrySet()) { + Consumption overriddenConsumption = listener.getValue().onBeforeUpdateStamina(consumption.consumptionType.toString(), consumption); + if ((overriddenConsumption.consumptionType != consumption.consumptionType) && (overriddenConsumption.amount != consumption.amount)) { + Grasscutter.getLogger().debug("[StaminaManager] Stamina update relative(" + + consumption.consumptionType.toString() + ", " + consumption.amount + ") overridden to relative(" + + consumption.consumptionType.toString() + ", " + consumption.amount + ") by: " + listener.getKey()); + return currentStamina; + } + } int playerMaxStamina = player.getProperty(PlayerProperty.PROP_MAX_STAMINA); - Grasscutter.getLogger().debug(currentStamina + "/" + playerMaxStamina + "\t" + currentState + "\t" + + Grasscutter.getLogger().trace(currentStamina + "/" + playerMaxStamina + "\t" + currentState + "\t" + (isPlayerMoving() ? "moving" : " ") + "\t(" + consumption.consumptionType + "," + consumption.amount + ")"); int newStamina = currentStamina + consumption.amount; if (newStamina < 0) { newStamina = 0; - } - if (newStamina > playerMaxStamina) { + } else if (newStamina > playerMaxStamina) { newStamina = playerMaxStamina; } + return setStamina(session, consumption.consumptionType.toString(), newStamina); + } + + public int updateStaminaAbsolute(GameSession session, String reason, int newStamina) { + int currentStamina = player.getProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA); + // notify will update + for (Map.Entry listener : beforeUpdateStaminaListeners.entrySet()) { + int overriddenNewStamina = listener.getValue().onBeforeUpdateStamina(reason, newStamina); + if (overriddenNewStamina != newStamina) { + Grasscutter.getLogger().debug("[StaminaManager] Stamina update absolute(" + + reason + ", " + newStamina + ") overridden to absolute(" + + reason + ", " + newStamina + ") by: " + listener.getKey()); + return currentStamina; + } + } + int playerMaxStamina = player.getProperty(PlayerProperty.PROP_MAX_STAMINA); + if (newStamina < 0) { + newStamina = 0; + } else if (newStamina > playerMaxStamina) { + newStamina = playerMaxStamina; + } + return setStamina(session, reason, newStamina); + } + + // Returns new stamina and sends PlayerPropNotify + public int setStamina(GameSession session, String reason, int newStamina) { + // set stamina player.setProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA, newStamina); session.send(new PacketPlayerPropNotify(player, PlayerProperty.PROP_CUR_PERSIST_STAMINA)); + // notify updated + for (Map.Entry listener : afterUpdateStaminaListeners.entrySet()) { + listener.getValue().onAfterUpdateStamina(reason, newStamina); + } return newStamina; } @@ -136,22 +210,22 @@ public class StaminaManager { entity.getWorld().broadcastPacket(new PacketEntityFightPropUpdateNotify(entity, FightProperty.FIGHT_PROP_CUR_HP)); entity.getWorld().broadcastPacket(new PacketLifeStateChangeNotify(0, entity, LifeState.LIFE_DEAD)); player.getScene().removeEntity(entity); - ((EntityAvatar)entity).onDeath(dieType, 0); + ((EntityAvatar) entity).onDeath(dieType, 0); } public void startSustainedStaminaHandler() { - if (!player.isPaused() && !timerRunning) { - timerRunning = true; - sustainedStaminaHandlerTimer.scheduleAtFixedRate(handleSustainedStamina, 0, 200); - // Grasscutter.getLogger().debug("[MovementManager] SustainedStaminaHandlerTimer started"); + if (!player.isPaused() && sustainedStaminaHandlerTimer == null) { + sustainedStaminaHandlerTimer = new Timer(); + sustainedStaminaHandlerTimer.scheduleAtFixedRate(new SustainedStaminaHandler(), 0, 200); + Grasscutter.getLogger().debug("[MovementManager] SustainedStaminaHandlerTimer started"); } } public void stopSustainedStaminaHandler() { - if (timerRunning) { - timerRunning = false; + if (sustainedStaminaHandlerTimer != null) { sustainedStaminaHandlerTimer.cancel(); - // Grasscutter.getLogger().debug("[MovementManager] SustainedStaminaHandlerTimer stopped"); + sustainedStaminaHandlerTimer = null; + Grasscutter.getLogger().debug("[MovementManager] SustainedStaminaHandlerTimer stopped"); } } @@ -190,17 +264,17 @@ public class StaminaManager { switch (motionState) { case MOTION_DASH_BEFORE_SHAKE: if (previousState != MotionState.MOTION_DASH_BEFORE_SHAKE) { - updateStamina(session, new Consumption(ConsumptionType.SPRINT)); + updateStaminaRelative(session, new Consumption(ConsumptionType.SPRINT)); } break; case MOTION_CLIMB_JUMP: if (previousState != MotionState.MOTION_CLIMB_JUMP) { - updateStamina(session, new Consumption(ConsumptionType.CLIMB_JUMP)); + updateStaminaRelative(session, new Consumption(ConsumptionType.CLIMB_JUMP)); } break; case MOTION_SWIM_DASH: if (previousState != MotionState.MOTION_SWIM_DASH) { - updateStamina(session, new Consumption(ConsumptionType.SWIM_DASH_START)); + updateStaminaRelative(session, new Consumption(ConsumptionType.SWIM_DASH_START)); } break; } @@ -208,7 +282,7 @@ public class StaminaManager { private void handleImmediateStamina(GameSession session, EvtDoSkillSuccNotify notify) { Consumption consumption = getFightConsumption(notify.getSkillId()); - updateStamina(session, consumption); + updateStaminaRelative(session, consumption); } private class SustainedStaminaHandler extends TimerTask { @@ -218,22 +292,30 @@ public class StaminaManager { int currentStamina = player.getProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA); int maxStamina = player.getProperty(PlayerProperty.PROP_MAX_STAMINA); if (moving || (currentStamina < maxStamina)) { - Grasscutter.getLogger().debug("Player moving: " + moving + ", stamina full: " + + Grasscutter.getLogger().trace("Player moving: " + moving + ", stamina full: " + (currentStamina >= maxStamina) + ", recalculate stamina"); + Consumption consumption = new Consumption(ConsumptionType.None); - if (!isInSkillMove) { - if (MotionStatesCategorized.get("CLIMB").contains(currentState)) { - consumption = getClimbSustainedConsumption(); - } else if (MotionStatesCategorized.get("SWIM").contains((currentState))) { - consumption = getSwimSustainedConsumptions(); - } else if (MotionStatesCategorized.get("RUN").contains(currentState)) { - consumption = getRunWalkDashSustainedConsumption(); - } else if (MotionStatesCategorized.get("FLY").contains(currentState)) { - consumption = getFlySustainedConsumption(); - } else if (MotionStatesCategorized.get("STANDBY").contains(currentState)) { - consumption = getStandSustainedConsumption(); - } + if (MotionStatesCategorized.get("CLIMB").contains(currentState)) { + consumption = getClimbSustainedConsumption(); + } else if (MotionStatesCategorized.get("SWIM").contains((currentState))) { + consumption = getSwimSustainedConsumptions(); + } else if (MotionStatesCategorized.get("RUN").contains(currentState)) { + consumption = getRunWalkDashSustainedConsumption(); + } else if (MotionStatesCategorized.get("FLY").contains(currentState)) { + consumption = getFlySustainedConsumption(); + } else if (MotionStatesCategorized.get("STANDBY").contains(currentState)) { + consumption = getStandSustainedConsumption(); } + + /* + TODO: Reductions that apply to all motion types: + Elemental Resonance + Wind: -15% + Skills + Diona E: -10% while shield lasts + Barbara E: -12% while lasts + */ if (cachedSession != null) { if (consumption.amount < 0) { staminaRecoverDelay = 0; @@ -243,12 +325,12 @@ public class StaminaManager { if (staminaRecoverDelay < 10) { // For others recover after 2 seconds (10 ticks) - as official server does. staminaRecoverDelay++; - consumption = new Consumption(ConsumptionType.None); + consumption.amount = 0; + Grasscutter.getLogger().trace("[StaminaManager] Delaying recovery: " + staminaRecoverDelay); } } - updateStamina(cachedSession, consumption); + updateStaminaRelative(cachedSession, consumption); } - handleDrowning(); } } previousState = currentState; @@ -263,10 +345,9 @@ public class StaminaManager { private void handleDrowning() { int stamina = player.getProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA); if (stamina < 10) { - boolean isSwimming = MotionStatesCategorized.get("SWIM").contains(currentState); - Grasscutter.getLogger().debug(player.getProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA) + "/" + - player.getProperty(PlayerProperty.PROP_MAX_STAMINA) + "\t" + currentState + "\t" + isSwimming); - if (isSwimming && currentState != MotionState.MOTION_SWIM_IDLE) { + Grasscutter.getLogger().trace(player.getProperty(PlayerProperty.PROP_CUR_PERSIST_STAMINA) + "/" + + player.getProperty(PlayerProperty.PROP_MAX_STAMINA) + "\t" + currentState); + if (currentState != MotionState.MOTION_SWIM_IDLE) { killAvatar(cachedSession, cachedEntity, PlayerDieType.PLAYER_DIE_DRAWN); } } @@ -274,7 +355,33 @@ public class StaminaManager { // Consumption Calculators + // Stamina Consumption Reduction: https://genshin-impact.fandom.com/wiki/Stamina + private Consumption getFightConsumption(int skillCasting) { + /* TODO: + Instead of handling here, consider call StaminaManager.updateStamina****() with a Consumption object with + type=FIGHT and a modified amount when handling attacks for more accurate attack start/end time and + other info. Handling it here could be very complicated. + Charged attack + Default: + Polearm: (-2500) + Claymore: (-4000 per second, -800 each tick) + Catalyst: (-5000) + Talent: + Ningguang: When Ningguang is in possession of Star Jades, her Charged Attack does not consume Stamina. (Catalyst * 0) + Klee: When Jumpy Dumpty and Normal Attacks deal DMG, Klee has a 50% chance to obtain an Explosive Spark. + This Explosive Spark is consumed by the next Charged Attack, which costs no Stamina. (Catalyst * 0) + Constellations: + Hu Tao: While in a Paramita Papilio state activated by Guide to Afterlife, Hu Tao's Charge Attacks do not consume Stamina. (Polearm * 0) + Character Specific: + Keqing: (-2500) + Diluc: (Claymore * 0.5) + Talent Moving: (Those are skills too) + Ayaka: (-1000 initial) (-1500 per second) When the Cryo application at the end of Kamisato Art: Senho hits an opponent (+1000) + Mona: (-1000 initial) (-1500 per second) + */ + + // TODO: Currently only handling Ayaka and Mona's talent moving initial costs. Consumption consumption = new Consumption(ConsumptionType.None); HashMap fightingCost = new HashMap<>() {{ put(10013, -1000); // Kamisato Ayaka @@ -294,10 +401,12 @@ public class StaminaManager { consumption = new Consumption(ConsumptionType.CLIMB_START); } } + // TODO: Foods return consumption; } private Consumption getSwimSustainedConsumptions() { + handleDrowning(); Consumption consumption = new Consumption(ConsumptionType.None); if (currentState == MotionState.MOTION_SWIM_MOVE) { consumption = new Consumption(ConsumptionType.SWIMMING); @@ -312,6 +421,7 @@ public class StaminaManager { Consumption consumption = new Consumption(ConsumptionType.None); if (currentState == MotionState.MOTION_DASH) { consumption = new Consumption(ConsumptionType.DASH); + // TODO: Foods } if (currentState == MotionState.MOTION_RUN) { consumption = new Consumption(ConsumptionType.RUN); @@ -323,7 +433,12 @@ public class StaminaManager { } private Consumption getFlySustainedConsumption() { + // POWERED_FLY, e.g. wind tunnel + if (currentState == MotionState.MOTION_POWERED_FLY) { + return new Consumption(ConsumptionType.POWERED_FLY); + } Consumption consumption = new Consumption(ConsumptionType.FLY); + // Talent HashMap glidingCostReduction = new HashMap<>() {{ put(212301, 0.8f); // Amber put(222301, 0.8f); // Venti @@ -332,15 +447,15 @@ public class StaminaManager { for (EntityAvatar entity : cachedSession.getPlayer().getTeamManager().getActiveTeam()) { for (int skillId : entity.getAvatar().getProudSkillList()) { if (glidingCostReduction.containsKey(skillId)) { - reduction = glidingCostReduction.get(skillId); + float potentialLowerReduction = glidingCostReduction.get(skillId); + if (potentialLowerReduction < reduction) { + reduction = potentialLowerReduction; + } } } } consumption.amount *= reduction; - // POWERED_FLY, e.g. wind tunnel - if (currentState == MotionState.MOTION_POWERED_FLY) { - consumption = new Consumption(ConsumptionType.POWERED_FLY); - } + // TODO: Foods return consumption; } diff --git a/src/main/java/emu/grasscutter/server/packet/recv/HandlerCombatInvocationsNotify.java b/src/main/java/emu/grasscutter/server/packet/recv/HandlerCombatInvocationsNotify.java index cc9e7b345..36252f828 100644 --- a/src/main/java/emu/grasscutter/server/packet/recv/HandlerCombatInvocationsNotify.java +++ b/src/main/java/emu/grasscutter/server/packet/recv/HandlerCombatInvocationsNotify.java @@ -49,22 +49,23 @@ public class HandlerCombatInvocationsNotify extends PacketHandler { session.getPlayer().getStaminaManager().handleCombatInvocationsNotify(session, moveInfo, entity); - // TODO: handle MOTION_FIGHT landing - // For plunge attacks, LAND_SPEED is always -30 and is not useful. - // May need the height when starting plunge attack. + // TODO: handle MOTION_FIGHT landing which has a different damage factor + // Also, for plunge attacks, LAND_SPEED is always -30 and is not useful. + // May need the height when starting plunge attack. + // MOTION_LAND_SPEED and MOTION_FALL_ON_GROUND arrive in different packets. + // Cache land speed for later use. + if (motionState == MotionState.MOTION_LAND_SPEED) { + cachedLandingSpeed = motionInfo.getSpeed().getY(); + cachedLandingTimeMillisecond = System.currentTimeMillis(); + monitorLandingEvent = true; + } if (monitorLandingEvent) { if (motionState == MotionState.MOTION_FALL_ON_GROUND) { monitorLandingEvent = false; handleFallOnGround(session, entity, motionState); } } - if (motionState == MotionState.MOTION_LAND_SPEED) { - // MOTION_LAND_SPEED and MOTION_FALL_ON_GROUND arrive in different packet. Cache land speed for later use. - cachedLandingSpeed = motionInfo.getSpeed().getY(); - cachedLandingTimeMillisecond = System.currentTimeMillis(); - monitorLandingEvent = true; - } } break; default: @@ -84,33 +85,42 @@ public class HandlerCombatInvocationsNotify extends PacketHandler { } private void handleFallOnGround(GameSession session, GameEntity entity, MotionState motionState) { - // If not received immediately after MOTION_LAND_SPEED, discard this packet. + // People have reported that after plunge attack (client sends a FIGHT instead of FALL_ON_GROUND) they will die + // if they talk to an NPC (this is when the client sends a FALL_ON_GROUND) without jumping again. + // A dirty patch: if not received immediately after MOTION_LAND_SPEED, discard this packet. + // 200ms seems to be a reasonable delay. int maxDelay = 200; long actualDelay = System.currentTimeMillis() - cachedLandingTimeMillisecond; - Grasscutter.getLogger().debug("MOTION_FALL_ON_GROUND received after " + actualDelay + "/" + maxDelay + "ms." + (actualDelay > maxDelay ? " Discard" : "")); + Grasscutter.getLogger().trace("MOTION_FALL_ON_GROUND received after " + actualDelay + "/" + maxDelay + "ms." + (actualDelay > maxDelay ? " Discard" : "")); if (actualDelay > maxDelay) { return; } float currentHP = entity.getFightProperty(FightProperty.FIGHT_PROP_CUR_HP); float maxHP = entity.getFightProperty(FightProperty.FIGHT_PROP_MAX_HP); - float damage = 0; + float damageFactor = 0; if (cachedLandingSpeed < -23.5) { - damage = (float) (maxHP * 0.33); + damageFactor = 0.33f; } if (cachedLandingSpeed < -25) { - damage = (float) (maxHP * 0.5); + damageFactor = 0.5f; } if (cachedLandingSpeed < -26.5) { - damage = (float) (maxHP * 0.66); + damageFactor = 0.66f; } if (cachedLandingSpeed < -28) { - damage = (maxHP * 1); + damageFactor = 1f; } + float damage = maxHP * damageFactor; float newHP = currentHP - damage; if (newHP < 0) { newHP = 0; } - Grasscutter.getLogger().debug(currentHP + "/" + maxHP + "\t" + "\tDamage: " + damage + "\tnewHP: " + newHP); + if (damageFactor > 0) { + Grasscutter.getLogger().debug(currentHP + "/" + maxHP + "\tLandingSpeed: " + cachedLandingSpeed + + "\tDamageFactor: " + damageFactor + "\tDamage: " + damage + "\tNewHP: " + newHP); + } else { + Grasscutter.getLogger().trace(currentHP + "/" + maxHP + "\tLandingSpeed: 0\tNo damage"); + } entity.setFightProperty(FightProperty.FIGHT_PROP_CUR_HP, newHP); entity.getWorld().broadcastPacket(new PacketEntityFightPropUpdateNotify(entity, FightProperty.FIGHT_PROP_CUR_HP)); if (newHP == 0) { diff --git a/src/main/resources/languages/zh-CN.json b/src/main/resources/languages/zh-CN.json index 8faa0e4ae..4e4929aee 100644 --- a/src/main/resources/languages/zh-CN.json +++ b/src/main/resources/languages/zh-CN.json @@ -95,17 +95,20 @@ "create": "已建立账号,UID 为 %s 。", "delete": "账号已刪除。", "no_account": "账号不存在。", - "command_usage": "用法:account [uid]" + "command_usage": "用法:account [uid]", + "description": "创建或删除账号。" }, "broadcast": { "command_usage": "用法:broadcast <消息>", - "message_sent": "公告已发送。" + "message_sent": "公告已发送。", + "description": "向所有玩家发送公告。" }, "changescene": { "usage": "用法:changescene ", "already_in_scene": "你已经在这个秘境中了。", "success": "已切换至秘境 %s.", - "exists_error": "此秘境不存在。" + "exists_error": "此秘境不存在。", + "description": "切换指定秘境。" }, "clear": { "command_usage": "用法: clear ", @@ -115,35 +118,41 @@ "furniture": "已将 %s 的尘歌壶家具清空。", "displays": "已清除 %s 的显示。", "virtuals": "已将 %s 的所有货币和经验值清空。", - "everything": "已将 %s 的所有物品清空。" + "everything": "已将 %s 的所有物品清空。", + "description": "从您的背包中删除所有未装备且已解锁的物品,包括稀有物品。" }, "coop": { "usage": "用法:coop ", - "success": "已强制召唤 %s 到 %s的世界" + "success": "已强制召唤 %s 到 %s的世界", + "description": "强制召唤指定用户到他人的世界。" }, "enter_dungeon": { "usage": "用法:enterdungeon ", "changed": "已进入秘境 %s", "not_found_error": "此秘境不存在。", - "in_dungeon_error": "你已经在秘境中了。" + "in_dungeon_error": "你已经在秘境中了。", + "description": "进入指定秘境。" }, "giveAll": { "usage": "用法:giveall [player] [amount]", "started": "正在给予全部物品...", "success": "已给予全部物品。", - "invalid_amount_or_playerId": "无效的数量/玩家ID。" + "invalid_amount_or_playerId": "无效的数量/玩家ID。", + "description": "给予所有物品。" }, "giveArtifact": { "usage": "用法:giveart|gart [player] [[,]]... [level]", "id_error": "无效的圣遗物ID。", - "success": "已将 %s 给予 %s。" + "success": "已将 %s 给予 %s。", + "description": "给予指定圣遗物。" }, "giveChar": { "usage": "用法:givechar [amount]", "given": "给予角色 %s 等级 %s 向UID %s.", "invalid_avatar_id": "无效的角色ID。", "invalid_avatar_level": "无效的角色等級。.", - "invalid_avatar_or_player_id": "无效的角色ID/玩家ID。" + "invalid_avatar_or_player_id": "无效的角色ID/玩家ID。", + "description": "给予指定角色。" }, "give": { "usage": "用法:give [amount] [level] [refinement]", @@ -151,29 +160,36 @@ "refinement_must_between_1_and_5": "精炼等阶必须在 1 到 5 之间。", "given": "已将 %s 个 %s 给予 %s。", "given_with_level_and_refinement": "已将 %s [等級%s, 精炼%s] %s个给予 %s", - "given_level": "已将 %s 等级 %s %s 个给予UID %s" + "given_level": "已将 %s 等级 %s %s 个给予UID %s", + "description": "给予指定物品。" }, "godmode": { - "success": "上帝模式已被设置为 %s 。 [用户:%s]" + "success": "上帝模式已被设置为 %s 。 [用户:%s]", + "description": "防止你受到伤害。" }, "heal": { - "success": "所有角色已被治疗。" + "success": "所有角色已被治疗。", + "description": "治疗所选队伍的角色。" }, "kick": { "player_kick_player": "玩家 [%s:%s] 已将 [%s:%s] 踢出", - "server_kick_player": "正在踢出玩家 [%s:%s]" + "server_kick_player": "正在踢出玩家 [%s:%s]", + "description": "从服务器内踢出指定玩家。" }, "kill": { "usage": "用法:killall [playerUid] [sceneId]", "scene_not_found_in_player_world": "未在玩家世界中找到此场景", - "kill_monsters_in_scene": "已杀死 %s 个怪物。 [场景ID: %s]" + "kill_monsters_in_scene": "已杀死 %s 个怪物。 [场景ID: %s]", + "description": "杀死所有怪物" }, "killCharacter": { "usage": "用法:/killcharacter [playerId]", - "success": "已杀死 %s 目前使用的角色。" + "success": "已杀死 %s 目前使用的角色。", + "description": "杀死目前使用的角色" }, "list": { - "success": "目前在线人数:%s" + "success": "目前在线人数:%s", + "description": "查看所有玩家" }, "permission": { "usage": "用法:permission ", @@ -181,21 +197,26 @@ "has_error": "此玩家已拥有此权限!", "remove": "权限已移除。", "not_have_error": "此玩家未拥有权限!", - "account_error": "账号不存在!" + "account_error": "账号不存在!", + "description": "给予或移除指定玩家的权限。" }, "position": { - "success": "坐标:%.3f, %.3f, %.3f\n场景ID:%d" + "success": "坐标:%.3f, %.3f, %.3f\n场景ID:%d", + "description": "获取所在位置。" }, "reload": { "reload_start": "正在重载配置文件和数据。", - "reload_done": "重载完毕。" + "reload_done": "重载完毕。", + "description": "重载配置文件和数据。" }, "resetConst": { "reset_all": "重置所有角色的命座。", - "success": "已重置 %s 的命座,重新登录后将会生效。" + "success": "已重置 %s 的命座,重新登录后将会生效。", + "description": "重置当前角色的命之座,执行命令后需重新登录以生效。" }, "resetShopLimit": { - "usage": "用法:/resetshop " + "usage": "用法:/resetshop ", + "description": "重置所选玩家的商店刷新时间。" }, "sendMail": { "usage": "用法:give [player] [amount]", @@ -217,17 +238,20 @@ "message": "<正文>", "sender": "<发件人>", "arguments": " [数量] [等级]", - "error": "错误:无效的编写阶段 %s。需要 StackTrace 请查看服务器控制台。" + "error": "错误:无效的编写阶段 %s。需要 StackTrace 请查看服务器控制台。", + "description": "向指定用户发送邮件。 此命令的用法可根据附加的参数而变化。" }, "sendMessage": { "usage": "用法:sendmessage ", - "success": "消息已发送。" + "success": "消息已发送。", + "description": "向指定玩家发送消息" }, "setFetterLevel": { "usage": "用法:setfetterlevel ", "range_error": "好感度等级必须在 0 到 10 之间。", "fetter_set_level": "好感度已设置为 %s 级", - "level_error": "无效的好感度等级。" + "level_error": "无效的好感度等级。", + "description": "设置当前角色的好感度等级。" }, "setStats": { "usage_console": "用法:setstats|stats @ ", @@ -238,20 +262,24 @@ "player_error": "玩家不存在或已离线。", "set_self": "%s 已经设置为 %s。", "set_for_uid": "%s 的使用者 %s 更改为 %s。", - "set_max_hp": "最大生命值更改为 %s。" + "set_max_hp": "最大生命值更改为 %s。", + "description": "设置当前角色的属性。" }, "setWorldLevel": { "usage": "用法:setworldlevel ", "value_error": "世界等级必须设置在0-8之间。", "success": "已将世界等级设为%s。", - "invalid_world_level": "无效的世界等级。" + "invalid_world_level": "无效的世界等级。", + "description": "设置世界等级,执行命令后需重新登录以生效。" }, "spawn": { "usage": "用法:spawn [amount] [level(仅限怪物]", - "success": "已生成 %s 个 %s。" + "success": "已生成 %s 个 %s。", + "description": "在你附近生成一个生物。" }, "stop": { - "success": "正在关闭服务器..." + "success": "正在关闭服务器...", + "description": "停止服务器" }, "talent": { "usage_1": "设置天赋等级:/talent set ", @@ -267,32 +295,41 @@ "invalid_level": "无效的天赋等级。", "normal_attack_id": "普通攻击的 ID 为 %s。", "e_skill_id": "元素战技ID %s。", - "q_skill_id": "元素爆发ID %s。" + "q_skill_id": "元素爆发ID %s。", + "description": "设置当前角色的天赋等级。" }, "teleportAll": { "success": "已将全部玩家传送到你的位置", - "error": "命令仅限处于多人游戏状态下使用。" + "error": "命令仅限处于多人游戏状态下使用。", + "description": "将你世界中的所有玩家传送到你所在的位置。" }, "teleport": { "usage_server": "用法:/tp @ [scene id]", "usage": "用法:/tp [@] [scene id]", "specify_player_id": "你必须指定一个玩家ID。", "invalid_position": "无效的位置。", - "success": "传送 %s 到坐标 %s,%s,%s,场景为 %s" + "success": "传送 %s 到坐标 %s,%s,%s,场景为 %s", + "description": "改变指定玩家的位置。" }, "weather": { "usage": "用法:weather [climateId]", "success": "已将当前天气设定为 %s,气候为 %s。", - "invalid_id": "无效的天气ID。" + "invalid_id": "无效的天气ID。", + "description": "改变天气" }, "drop": { "command_usage": "用法:drop [amount]", - "success": "已将 %s x %s 丟在附近。" + "success": "已将 %s x %s 丟在附近。", + "description": "在你附近丢一个物品。" }, "help": { "usage": "用法:", "aliases": "別名:", - "available_commands": "可用指令:" + "available_commands": "可用指令:", + "description": "发送帮助信息或显示指定命令的信息。" + }, + "restart": { + "description": "重新启动服务器。" } } } diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml index 1fc6831cb..5ab1957e5 100644 --- a/src/main/resources/logback.xml +++ b/src/main/resources/logback.xml @@ -1,4 +1,6 @@ + + [%d{HH:mm:ss}] [%highlight(%level)] %msg%n @@ -14,7 +16,10 @@ %d{yyyy-MM-dd'T'HH:mm:ss'Z'} - %m%n - + + + +