/* * This file is part of the Illarion project. * * Copyright © 2015 - Illarion e.V. * * Illarion is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Illarion is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. */ package illarion.client.world.movement; import com.google.common.util.concurrent.ThreadFactoryBuilder; import illarion.client.graphics.AnimatedMove; import illarion.client.graphics.MoveAnimation; import illarion.client.net.client.MoveCmd; import illarion.client.net.client.TurnCmd; import illarion.client.world.CharMovementMode; import illarion.client.world.MapTile; import illarion.client.world.Player; import illarion.client.world.World; import illarion.client.world.characters.CharacterAttribute; import illarion.common.graphics.CharAnimations; import illarion.common.types.CharacterId; import illarion.common.types.Direction; import illarion.common.types.ServerCoordinate; import illarion.common.util.FastMath; import org.illarion.engine.input.Input; import org.jetbrains.annotations.Contract; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.Marker; import org.slf4j.MarkerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * This is the main controlling class for the movement. It maintains the references to the different handlers and * makes sure that the movement commands of the handlers are put in action. * * @author Martin Karing <nitram@illarion.org> */ public class Movement { @Nonnull private static final Logger log = LoggerFactory.getLogger(Movement.class); @Nonnull private static final Marker marker = MarkerFactory.getMarker("Movement"); @Nonnull private static final String THEAD_NAME_HEADER = "Movement Thread-"; /** * The instance of the player that is moved around by this class. */ @Nonnull private final Player player; @Nonnull private final ExecutorService executorService; /** * The currently active movement handler. */ @Nullable private MovementHandler activeHandler; @Nonnull private CharMovementMode defaultMovementMode; private boolean stepInProgress; @Nonnull private final MoveAnimator animator; @Nonnull private final MouseMovementHandler followMouseHandler; @Nonnull private final KeyboardMovementHandler keyboardHandler; @Nonnull private final TargetMovementHandler targetMovementHandler; @Nonnull private final MouseTargetMovementHandler targetMouseMovementHandler; @Nonnull private final MoveAnimation moveAnimation; @Nullable private MoveCmd lastSendMoveCommand; /** * This instance of the player location is kept in sync with the location that was last confirmed by the server * to keep track of where the player REALLY is. */ @Nullable private ServerCoordinate playerLocation; public Movement(@Nonnull Player player, @Nonnull Input input, @Nonnull AnimatedMove movementReceiver) { this.player = player; moveAnimation = new MoveAnimation(movementReceiver); defaultMovementMode = CharMovementMode.Walk; stepInProgress = false; animator = new MoveAnimator(this, moveAnimation); moveAnimation.addTarget(animator, false); followMouseHandler = new FollowMouseMovementHandler(this, input); keyboardHandler = new SimpleKeyboardMovementHandler(this, input); targetMovementHandler = new WalkToMovementHandler(this); targetMouseMovementHandler = new WalkToMouseMovementHandler(this, input); executorService = Executors.newSingleThreadExecutor( new ThreadFactoryBuilder() .setNameFormat(THEAD_NAME_HEADER + "%d") .setDaemon(true) .build()); } /** * The location of the player as its known to the server. * * @return the server location of the player */ @Nonnull public ServerCoordinate getServerLocation() { if (playerLocation == null) { throw new IllegalStateException("The location of the player is not known yet."); } return playerLocation; } public boolean isMoving() { return moveAnimation.isRunning(); } void activate(@Nonnull MovementHandler handler) { if (!isActive(handler)) { MovementHandler oldHandler = activeHandler; if (oldHandler != null) { oldHandler.disengage(false); } activeHandler = handler; log.debug(marker, "New movement handler is assuming control: {}", activeHandler); } update(); } void disengage(@Nonnull MovementHandler handler) { if (isActive(handler)) { activeHandler = null; } else { log.debug(marker, "Tried to disengage a movement handler ({}) that was not active!", handler); } } boolean isActive(@Nonnull MovementHandler handler) { return (activeHandler != null) && handler.equals(activeHandler); } public void executeServerRespTurn(@Nonnull Direction direction) { executorService.submit(() -> executeServerRespTurnInternal(direction)); } private void executeServerRespTurnInternal(@Nonnull Direction direction) { animator.scheduleTurn(direction); } private void scheduleEarlyTurn(@Nonnull Direction direction) { animator.scheduleTurn(direction); } public void executeServerRespMoveTooEarly() { executorService.submit(() -> { log.debug( "Response indicates that the request was received too early. A new request is required later."); resendMoveToServer(); }); } public void executeServerRespMove( @Nonnull CharMovementMode mode, @Nonnull ServerCoordinate target, int duration) { ServerCoordinate orgLocation = playerLocation; if (orgLocation == null) { throw new IllegalStateException("The player location is currently unknown."); } executorService.submit(() -> executeServerRespMoveInternal(orgLocation, mode, target, duration)); playerLocation = target; } private void executeServerRespMoveInternal( @Nonnull ServerCoordinate orgLocation, @Nonnull CharMovementMode mode, @Nonnull ServerCoordinate target, int duration) { log.debug("Received response from the server! Mode: {} Target {} Duration {}ms", mode, target, duration); if (orgLocation.equals(target)) { log.debug("Current location and target location match. Cancel any pending move."); animator.cancelMove(target); } else { // confirm a move that was started early animator.confirmMove(mode, target, duration); } } private static final int MAX_WALK_AGI = 20; private static final int MIN_WALK_COST = 300; private static final int MAX_WALK_COST = 800; private void scheduleEarlyMove(@Nonnull CharMovementMode mode, @Nonnull Direction direction) { if (playerLocation == null) { throw new IllegalStateException("The current player location is not known yet."); } int movementDuration = getMovementDuration(playerLocation, mode, direction); if (movementDuration != -1) { animator.scheduleEarlyMove(mode, getTargetLocation(mode, direction), (movementDuration / 100) * 100); } } @Contract(pure = true) public int getMovementDuration(@Nonnull ServerCoordinate current, @Nonnull CharMovementMode mode, @Nonnull Direction dir) { if (player.getCarryLoad().isWalkingPossible()) { ServerCoordinate walkingTarget = new ServerCoordinate(current, dir); MapTile walkingTile = World.getMap().getMapAt(walkingTarget); if ((walkingTile != null) && !walkingTile.isBlocked()) { int agility = Math.min(player.getCharacter().getAttribute(CharacterAttribute.Agility), MAX_WALK_AGI); double agilityMod = (10 - agility) / 100.0; double loadMod = (player.getCarryLoad().getLoadFactor() / 10.0) * 3.0; double mods = agilityMod + loadMod + 1.0; int movementDuration = getMovementDuration(walkingTile.getMovementCost(), mods, dir.isDiagonal(), mode == CharMovementMode.Run); if (mode == CharMovementMode.Run) { ServerCoordinate runTarget = new ServerCoordinate(walkingTarget, dir); MapTile runningTile = World.getMap().getMapAt(runTarget); if ((runningTile != null) && !runningTile.isBlocked()) { movementDuration += getMovementDuration(runningTile.getMovementCost(), mods, dir.isDiagonal(), true); } else { return -1; } } return (movementDuration / 100) * 100; } } return -1; } @Contract(pure = true) private static int getMovementDuration(int tileMovementCost, double mods, boolean diagonal, boolean running) { // do not mess with this function. This one has to match the server function exactly to yield the same results int movementDuration = FastMath.clamp((int) (tileMovementCost * 100.0 * mods), MIN_WALK_COST, MAX_WALK_COST); if (diagonal) { movementDuration = (int) (1.4142135623730951 * movementDuration); // sqrt(2) } if (running) { movementDuration = (int) (0.6 * movementDuration); } return movementDuration; } public void executeServerLocation(@Nonnull ServerCoordinate target) { World.getUpdateTaskManager().addTask((container, delta) -> { MovementHandler currentHandler = activeHandler; if (currentHandler != null) { currentHandler.disengage(false); } }); stepInProgress = false; animator.cancelAll(); playerLocation = target; World.getPlayer().setLocation(target); } /** * Send the movement command to the server. * * @param direction the direction of the requested move * @param mode the mode of the requested move */ private void sendMoveToServer(@Nonnull Direction direction, @Nonnull CharMovementMode mode) { CharacterId playerId = player.getPlayerId(); if (playerId == null) { log.error(marker, "Send move to server while ID is not known."); return; } MoveCmd cmd = new MoveCmd(playerId, mode, direction); lastSendMoveCommand = cmd; log.debug(marker, "Sending move command to server: {}", cmd); World.getNet().sendCommand(cmd); } private void resendMoveToServer() { if (lastSendMoveCommand != null) { log.debug(marker, "Re-Sending move command to server: {}", lastSendMoveCommand); World.getNet().sendCommand(lastSendMoveCommand); } else { log.warn(marker, "Tried to resend a move command to the server, but there was no old move command."); } } private void sendTurnToServer(@Nonnull Direction direction) { if (player.getCharacter().getDirection() != direction) { TurnCmd cmd = new TurnCmd(direction); log.debug(marker, "Sending turn command to server: {}", cmd); World.getNet().sendCommand(cmd); } } /** * Notify the handler that everything is ready to request the next step from the server. */ void reportReadyForNextStep() { log.debug("Reported ready for the next step."); stepInProgress = false; update(); } /** * This function triggers the lifecycle run of the movement handler. Its executed automatically as the movement * handler sees fit. How ever for special events that might require a action of the movement handler this function * can be triggered. * <p/> * Calling it too often causes no harm. The handler checks internally if any actual operations need to be executed * or not. */ public void update() { if (Thread.currentThread().getName().startsWith(THEAD_NAME_HEADER)) { updateImpl(); } else { executorService.submit(this::updateImpl); } } private void updateImpl() { if (playerLocation == null) { // We are not ready set to do anything. Let's wait. log.debug("Received early update on the movement system. Can't do much yet. Standing by."); return; } if (stepInProgress) { return; } MovementHandler handler = activeHandler; if (handler != null) { long start = System.currentTimeMillis(); StepData nextStep = handler.getNextStep(playerLocation); if (log.isDebugEnabled(marker)) { log.debug(marker, "Requesting new step data from handler: {} (took {} milliseconds)", nextStep, System.currentTimeMillis() - start); } if (nextStep != null) { if (nextStep.getDirection() != null) { switch (nextStep.getMovementMode()) { case None: sendTurnToServer(nextStep.getDirection()); scheduleEarlyTurn(nextStep.getDirection()); break; default: stepInProgress = true; sendMoveToServer(nextStep.getDirection(), nextStep.getMovementMode()); scheduleEarlyTurn(nextStep.getDirection()); scheduleEarlyMove(nextStep.getMovementMode(), nextStep.getDirection()); } } if (nextStep.getPostStepAction() != null) { nextStep.getPostStepAction().run(); } } } } @Nonnull @Contract(pure = true) private ServerCoordinate getTargetLocation(@Nonnull CharMovementMode mode, @Nonnull Direction direction) { if (playerLocation == null) { throw new IllegalStateException("The current player location is not known yet."); } switch (mode) { case Run: return new ServerCoordinate(playerLocation, direction.getDirectionVectorX() * 2, direction.getDirectionVectorY() * 2, 0); case Walk: return new ServerCoordinate(playerLocation, direction); case Push: return new ServerCoordinate(playerLocation, direction); default: throw new IllegalArgumentException("Invalid movement mode for selecting the target location"); } } @Nonnull @Contract(pure = true) Player getPlayer() { return player; } @Contract(pure = true) public boolean isMovementModePossible(@Nonnull CharMovementMode mode) { return (mode != CharMovementMode.Run) || World.getPlayer().getCharacter().isAnimationAvailable(CharAnimations.RUN); } @Nonnull @Contract(pure = true) public CharMovementMode getDefaultMovementMode() { return defaultMovementMode; } @Nonnull @Contract(pure = true) public MouseMovementHandler getFollowMouseHandler() { return followMouseHandler; } @Nonnull @Contract(pure = true) public KeyboardMovementHandler getKeyboardHandler() { return keyboardHandler; } @Nonnull @Contract(pure = true) public TargetMovementHandler getTargetMovementHandler() { return targetMovementHandler; } @Nonnull @Contract(pure = true) public MouseTargetMovementHandler getTargetMouseMovementHandler() { return targetMouseMovementHandler; } public void setDefaultMovementMode(@Nonnull CharMovementMode defaultMovementMode) { this.defaultMovementMode = defaultMovementMode; } public void shutdown() { activeHandler = null; executorService.shutdown(); } }