/* * 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; import com.google.common.base.Strings; import illarion.client.graphics.AnimatedMove; import illarion.client.graphics.Avatar; import illarion.client.graphics.AvatarClothManager; import illarion.client.graphics.MoveAnimation; import illarion.client.resources.ItemFactory; import illarion.client.util.Lang; import illarion.client.world.characters.CharacterAttribute; import illarion.client.world.interactive.InteractiveChar; import illarion.common.graphics.CharAnimations; import illarion.common.graphics.Layer; import illarion.common.types.*; import illarion.common.util.FastMath; import org.illarion.engine.graphic.Color; import org.illarion.engine.graphic.LightSource; import org.jetbrains.annotations.Contract; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.NotThreadSafe; import java.lang.ref.Reference; import java.lang.ref.SoftReference; import java.util.EnumMap; import java.util.Map; /** * Represents a character: player, monster or npc. * * @author Martin Karing <nitram@illarion.org> */ @SuppressWarnings("ClassNamingConvention") @NotThreadSafe public final class Char implements AnimatedMove { /** * The speed a animation runs with on default. */ public static final int DEFAULT_ANIMATION_SPEED = 750; /** * The color that is used to show dead characters. */ @Nonnull private static final Color DEAD_COLOR; /** * Light Update status SET light value. */ public static final int LIGHT_SET = 1; /** * Light Update status SOFTly change value. */ public static final int LIGHT_SOFT = 2; /** * Light Update status UPDATE light value. */ public static final int LIGHT_UPDATE = 3; /** * The instance of the logger that is used to write out the data. */ @Nonnull private static final Logger log = LoggerFactory.getLogger(Char.class); /** * Maximal scale value for the character. */ private static final float MAXIMAL_SCALE = 1.2f; /** * Minimal scale value for the character. */ private static final float MINIMAL_SCALE = 0.8f; /** * Maximum value for visibility. */ public static final float VISIBILITY_MAX = 1.f; /** * This color is used to display the name in case the character is a player character. */ @Nonnull private static final Color NAME_COLOR_HUMAN = Color.YELLOW; /** * This color is used to display the name in case the character is a monster. */ @Nonnull private static final Color NAME_COLOR_MONSTER = Color.RED; /** * This color is used to display the name in case the character is a NPC. */ @Nonnull private static final Color NAME_COLOR_NPC = new Color(128, 179, 255); /** * The alive state of the character. {@code true} in case the character is alive. */ private boolean alive; /** * The animation that is currently shown by the character. */ private int animation; /** * Current appearance value. Depends on race and gender of the character. */ private int appearance; /** * Avatar of the character. */ @Nullable private Avatar avatar; /** * ID of the avatar that represents the character. */ private int avatarId; /** * Character ID the the character. */ @Nullable private CharacterId charId; /** * Current looking direction of the character. */ @Nonnull private Direction direction; @Nullable private DisplayCoordinate displayPos; /** * Last visibility value that was shown. Used for fading in and out animations. */ private int lastVisibility; /** * Current light source of the character. */ @Nullable private LightSource lightSrc; /** * Current light value of the character. */ private int lightValue; /** * Current Location of the character on the map. */ @Nullable private ServerCoordinate location; /** * Move animation handler for this character. */ @Nonnull private final MoveAnimation move; /** * Name of the character. */ @Nullable private String name; @Nullable private String customName; /** * Color of the name of the character. default, melee fighting, distance fighting, magic */ @Nullable private Color nameColor; /** * Scale of the character (based on its height). */ private float scale; /** * The custom color of the characters skin. */ @Nullable private Color skinColor; /** * Visibility bonus of the character (for large characters). */ private int visibilityBonus; /** * A list of items this avatar wears. This list is send to the avatar at a update. */ @Nonnull private final int[] wearItems = new int[AvatarClothManager.GROUP_COUNT]; /** * A list of modified colors of the stuff a avatar wears. */ @Nonnull private final Color[] wearItemsColors = new Color[AvatarClothManager.GROUP_COUNT]; /** * This map stores the attribute values of this character. */ @Nonnull private final Map<CharacterAttribute, Integer> attributes; /** * The reference to the interactive character instance that points to this character. */ @Nullable private Reference<InteractiveChar> interactiveCharRef; private static class DelayedMoveData { @Nonnull public final CharMovementMode mode; public final int duration; @Nonnull public final ServerCoordinate targetLocation; private DelayedMoveData(@Nonnull CharMovementMode mode, int duration, @Nonnull ServerCoordinate targetLocation) { this.mode = mode; this.duration = duration; this.targetLocation = targetLocation; } @Override @Nonnull public String toString() { StringBuilder builder = new StringBuilder(); builder.append("DelayedMoveData("); if (mode == null) { builder.append("NULL"); } else { switch (mode) { case Walk: builder.append("Walk"); break; case Run: builder.append("Run"); break; case None: builder.append("None"); break; case Push: builder.append("Push"); break; } } builder.append(", ").append(duration).append("ms, ").append("to ").append(targetLocation); return builder.toString(); } } /** * This stores a delayed move in case there is one. */ @Nullable private DelayedMoveData delayedMove; /** * Constructor to create a new character. */ public Char() { move = new MoveAnimation(this); attributes = new EnumMap<>(CharacterAttribute.class); scale = 0; avatarId = -1; appearance = 0; animation = CharAnimations.STAND; direction = Direction.North; } static { DEAD_COLOR = new Color(1.f, 1.f, 1.f, 0.45f); } /** * Get a specified attribute. * * @param attribute the attribute to fetch * @return the value of the attribute */ public int getAttribute(@Nonnull CharacterAttribute attribute) { if (removedCharacter) { log.warn("Fetching the attributes of a removed character."); } if (attributes.containsKey(attribute)) { return attributes.get(attribute); } return 0; } /** * Set a attribute to a new value. * * @param attribute the attribute value to update * @param value the new value of the attribute */ public void setAttribute(@Nonnull CharacterAttribute attribute, int value) { if (removedCharacter) { return; } attributes.put(attribute, value); if (attribute == CharacterAttribute.HitPoints) { if (avatar != null) { avatar.setHealthPoints(value); } setAlive(value > 0); } if (World.getGameGui().isReady() && World.getPlayer().isPlayer(getCharId())) { World.getGameGui().getPlayerStatusGui().setAttribute(attribute, value); } } /** * Get the character ID of the character. * * @return the ID of the character */ @Nullable public CharacterId getCharId() { return charId; } /** * Set the alive state of this character. * * @param newAliveState set the new alive state. {@code true} in case the character is alive. */ public void setAlive(boolean newAliveState) { if (removedCharacter) { log.warn("Trying to update the alive state of a removed character."); return; } if (alive == newAliveState) { return; } alive = newAliveState; if (avatar == null) { return; } if (alive) { avatar.changeBaseColor(skinColor); for (int i = 0; i < AvatarClothManager.GROUP_COUNT; ++i) { if ((i == AvatarClothManager.GROUP_BEARD) || (i == AvatarClothManager.GROUP_HAIR)) { continue; } avatar.changeClothColor(i, wearItemsColors[i]); } } else { avatar.changeBaseColor(DEAD_COLOR); for (int i = 0; i < AvatarClothManager.GROUP_COUNT; ++i) { avatar.changeClothColor(i, DEAD_COLOR); } } } /** * This flag is used to store if there is currently a animation in progress. */ private boolean animationInProgress; @Override public void animationStarted() { animationInProgress = true; } /** * Stop the walking animation of the character. * * @param finished not in use */ @Override public void animationFinished(boolean finished) { if (location != null) { displayPos = getDisplayCoordinatesAt(location); } if (displayPos != null) { setPosition(displayPos); } animationInProgress = false; DelayedMoveData localDelayedMove = delayedMove; delayedMove = null; if (finished && (localDelayedMove != null)) { log.info("{}: Planning delayed move for execution", this); World.getUpdateTaskManager().addTaskForLater((container, delta) -> { log.info("{}: Executing delayed move", this); moveToInternal(localDelayedMove.targetLocation, localDelayedMove.mode, localDelayedMove.duration); }); } else if (localDelayedMove != null) { World.getUpdateTaskManager().addTaskForLater((container, delta) -> { log.info("{}: Canceled move received while there still was a delayed more. Fixing the location.", this); updateLocation(localDelayedMove.targetLocation); }); } } /** * Stop the execution of the current avatar animation. */ public void stopAnimation() { if (avatar != null) { avatar.stopAnimation(); } move.stop(); } /** * Set the current animation back to its parent, update the avatar and invoke the needed animations. */ public void resetAnimation(boolean finished) { if (delayedMove != null) { log.debug("{}: Resetting the animation skipped. There is a delayed move that may continue soon.", this); } else { log.debug("{}: Resetting the animation. Finished: {}", this, finished); if (finished) { animation = CharAnimations.STAND; if (location != null) { updateAvatar(); if (avatar != null) { avatar.animate(DEFAULT_ANIMATION_SPEED, true); } } } } } /** * Update the graphical appearance of the character. */ private void updateAvatar() { ServerCoordinate currentCoordinate = location; if (currentCoordinate == null) { throw new IllegalStateException("Updating the avatar while the coordinates are not set is not valid."); } if (removedCharacter) { releaseAvatar(); log.warn("{}: Trying to update the avatar of a removed avatar.", this); return; } // nothing to do for invisible folks if ((appearance == 0) || (animation < 0)) { log.debug("{}: Can't show avatar with appearance {} and animation {}", this, appearance, animation); return; } // calculate avatar id int newAvatarId = (((appearance * Direction.values().length) + direction.getServerId()) * CharAnimations.TOTAL_ANIMATIONS) + animation; // no change, return if ((avatarId == newAvatarId) && (avatar != null)) { log.debug("{}: Avatar received update but did not change.", this); return; } int oldAlpha = 0; int oldAlphaTarget; if (avatar != null) { oldAlpha = avatar.getAlpha(); } oldAlphaTarget = (int) (World.getPlayer().canSee(this) * Color.MAX_INT_VALUE); @Nullable Avatar newAvatar = Avatar.create(newAvatarId, this); if (newAvatar == null) { log.error("Failed to change the avatar as the new ID {} is NULL.", newAvatarId); return; } updatePaperdoll(newAvatar); if (alive) { for (int i = 0; i < AvatarClothManager.GROUP_COUNT; ++i) { newAvatar.changeClothColor(i, wearItemsColors[i]); } newAvatar.changeBaseColor(skinColor); } else { for (int i = 0; i < AvatarClothManager.GROUP_COUNT; ++i) { newAvatar.changeClothColor(i, DEAD_COLOR); } newAvatar.changeBaseColor(DEAD_COLOR); } updatePosition(newAvatar, getDisplayCoordinatesAt(currentCoordinate)); updateLight(newAvatar, LIGHT_SET); Integer healthPoints = attributes.get(CharacterAttribute.HitPoints); if (healthPoints == null) { newAvatar.setHealthPoints(10000); } else { newAvatar.setHealthPoints(healthPoints); } newAvatar.setScale(scale); newAvatar.setAlpha(oldAlpha); newAvatar.setAlphaTarget(oldAlphaTarget); newAvatar.setName(getName()); if (nameColor != null) { newAvatar.setNameColor(nameColor); } newAvatar.show(); log.debug("{}: Showing new avatar: {}", this, newAvatar); Avatar oldAvatar = avatar; avatarId = newAvatarId; avatar = newAvatar; if (oldAvatar != null) { oldAvatar.markAsRemoved(); } MapTile tile = World.getMap().getMapAt(currentCoordinate); if (tile != null) { tile.updateQuestMarkerElevation(); } } /** * Update the paper doll, so set all items the characters wears to the avatar. Do this in case many cloth parts * changed or in case the avatar instance changed. * * @param avatar the avatar that is supposed to be updated */ private void updatePaperdoll(@Nullable Avatar avatar) { if (removedCharacter) { log.warn("Trying to update the paperdoll of a removed character."); return; } if (avatar == null) { return; } if (hasWearingItem(avatar, AvatarClothManager.GROUP_FIRST_HAND, wearItems[AvatarClothManager.GROUP_FIRST_HAND]) || hasWearingItem(avatar, AvatarClothManager.GROUP_SECOND_HAND, wearItems[AvatarClothManager.GROUP_SECOND_HAND])) { applyPaperdollingItem(avatar, AvatarClothManager.GROUP_FIRST_HAND, wearItems[AvatarClothManager.GROUP_FIRST_HAND]); applyPaperdollingItem(avatar, AvatarClothManager.GROUP_SECOND_HAND, wearItems[AvatarClothManager.GROUP_SECOND_HAND]); } else { applyPaperdollingItem(avatar, AvatarClothManager.GROUP_FIRST_HAND, wearItems[AvatarClothManager.GROUP_SECOND_HAND]); applyPaperdollingItem(avatar, AvatarClothManager.GROUP_SECOND_HAND, wearItems[AvatarClothManager.GROUP_FIRST_HAND]); } for (int i = 0; i < wearItems.length; ++i) { if ((i == AvatarClothManager.GROUP_FIRST_HAND) || (i == AvatarClothManager.GROUP_SECOND_HAND)) { continue; } applyPaperdollingItem(avatar, i, wearItems[i]); } } /** * Check if a cloth item is defined in a specified group. * * @param avatar the avatar to update * @param slot the slot where the item shall be checked * @param id the id of the item that shall be checked * @return {@code true} in case a item is defined and displayable */ public boolean hasWearingItem(@Nullable Avatar avatar, int slot, int id) { if ((slot < 0) || (slot >= AvatarClothManager.GROUP_COUNT)) { log.warn("Wearing item check on invalid slot: {}", slot); return false; } return (id != 0) && ((avatar == null) || avatar.getTemplate().getClothes().doesClothExists(slot, id)); } private void applyLightValue(@Nullable ItemId itemId) { if (ItemId.isValidItem(itemId)) { int light = ItemFactory.getInstance().getTemplate(itemId.getValue()).getItemInfo().getLight(); if (light > lightValue) { lightValue = light; } } } private static void applyPaperdollingItem(@Nullable Avatar avatar, int slot, int itemId) { if (avatar != null) { if (itemId == 0) { avatar.removeClothItem(slot); } else { avatar.setClothItem(slot, itemId); } } } /** * Update the avatar display position. * * @param avatar the avatar that is altered */ private void updatePosition(@Nullable Avatar avatar, @Nonnull DisplayCoordinate newPosition) { displayPos = newPosition; if (avatar != null) { avatar.setScreenPos(newPosition); } } /** * Update the light source of the character. * * @param avatar the avatar that is updated * @param mode the mode of the update */ private void updateLight(@Nullable Avatar avatar, int mode) { if (removedCharacter) { log.error("Trying to update the light of a removed character."); return; } if (location == null) { return; } ServerCoordinate lightLoc = location; @Nullable MapTile tile = World.getMap().getMapAt(lightLoc); if ((avatar != null) && (tile != null)) { switch (mode) { case LIGHT_SET: avatar.setLight(tile.getTargetLight()); break; case LIGHT_SOFT: avatar.setLightTarget(tile.getTargetLight()); break; case LIGHT_UPDATE: if (avatar.hasAnimatedLight()) { avatar.setLightTarget(tile.getTargetLight()); } else { avatar.setLight(tile.getTargetLight()); } break; default: log.warn("Wrong light update mode."); //$NON-NLS-1$ break; } } } /** * Once this value is turned {@code true} the character is removed from the game. */ private boolean removedCharacter; /** * Mark this character as removed. Calling this function will cause the instance to clean its dependency and then * die gracefully. */ public void markAsRemoved() { removedCharacter = true; move.stop(); if (lightSrc != null) { World.getLights().remove(lightSrc); lightSrc = null; } releaseAvatar(); } /** * Reset the cached light value to start sampling a new value. */ public void resetLightValue() { lightValue = 0; } /** * Release the current avatar and free the resources. */ private void releaseAvatar() { log.debug("{}: Releasing the avatar.", this); Avatar localAvatar = avatar; avatar = null; avatarId = -1; if (localAvatar != null) { localAvatar.markAsRemoved(); } } /** * Set the new position of the character. * * @param position the display position of the character */ @Override public void setPosition(@Nonnull DisplayCoordinate position) { updatePosition(avatar, position); } /** * Change the ID of the character. * * @param newCharId new ID of the character */ @SuppressWarnings("IfStatementWithTooManyBranches") public void setCharId(@Nonnull CharacterId newCharId) { charId = newCharId; if (charId.isHuman()) { setNameColor(NAME_COLOR_HUMAN); } else if (charId.isNPC()) { setNameColor(NAME_COLOR_NPC); } else if (charId.isMonster()) { setNameColor(NAME_COLOR_MONSTER); } else { log.warn("Failed to detect character type for {}", charId); } } /** * Set the color of the name of the character. * * @param color the new color value */ private void setNameColor(@Nonnull Color color) { nameColor = color; if (avatar != null) { avatar.setNameColor(color); } } /** * Set the name of the current character. Pre and suffixes are generated by this function as well * * @param newName the name of the character or null */ public void setName(@Nullable String newName) { name = newName; setAvatarName(); } public void setCustomName(@Nullable String customName) { this.customName = customName; setAvatarName(); } private void setAvatarName() { if (avatar != null) { avatar.setName(getName()); } } /** * Set the scale of the character. * * @param newScale new scale value between 0.5f and 1.2f */ public void setScale(float newScale) { if ((newScale < MINIMAL_SCALE) || (newScale > MAXIMAL_SCALE)) { log.warn("invalid character scale {} ignored for {}", newScale, charId); } scale = FastMath.clamp(newScale, MINIMAL_SCALE, MAXIMAL_SCALE); if (avatar != null) { avatar.setScale(newScale); } } /** * Get the current avatar of the character. * * @return the avatar of the character */ @Nullable @Contract(pure = true) public Avatar getAvatar() { return avatar; } /** * Get the current direction the character is looking at. * * @return the direction value */ @Nonnull @Contract(pure = true) public Direction getDirection() { return direction; } /** * Get a interactive reference to this character. * * @return a interactive reference to this character */ @Nonnull public InteractiveChar getInteractive() { if (interactiveCharRef != null) { @Nullable InteractiveChar interactiveChar = interactiveCharRef.get(); if (interactiveChar != null) { return interactiveChar; } } InteractiveChar interactiveChar = new InteractiveChar(this); interactiveCharRef = new SoftReference<>(interactiveChar); return interactiveChar; } /** * Get the location of the character where the character is currently visible. * * @return the location of the character */ @Nullable @Contract(pure = true) public ServerCoordinate getVisibleLocation() { return location; } /** * Get the location of the character. This location tracks the changes applied by the server as close as possible. * The visible location may differ because the moves have not catched up. * * @return the location of the character */ @Nullable @Contract(pure = true) public ServerCoordinate getLocation() { DelayedMoveData currentDelayedMove = delayedMove; if (currentDelayedMove != null) { return currentDelayedMove.targetLocation; } return location; } /** * Get the name of the character. * * @return the name of the character */ @Nonnull @Contract(pure = true) public String getName() { if (Strings.isNullOrEmpty(name)) { return Strings.isNullOrEmpty(customName) ? getFallbackName() : ('"' + customName + '"'); } else { return Strings.isNullOrEmpty(customName) ? name : (name + " (" + customName + ')'); } } @Nonnull @Contract(pure = true) private String getFallbackName() { String key; switch (appearance) { // humans case 1: key = "character.name.fallback.human.male"; break; case 16: key = "character.name.fallback.human.female"; break; // dwarfs case 12: key = "character.name.fallback.dwarf.male"; break; case 17: key = "character.name.fallback.dwarf.female"; break; // halflings case 24: key = "character.name.fallback.halfling.male"; break; case 25: key = "character.name.fallback.halfling.female"; break; // elves case 20: key = "character.name.fallback.elf.male"; break; case 19: key = "character.name.fallback.elf.female"; break; // orcs case 13: key = "character.name.fallback.orc.male"; break; case 18: key = "character.name.fallback.orc.female"; break; // lizards case 7: key = "character.name.fallback.lizard"; break; // And everyone else default: key = "chat.someone"; break; } return Lang.getMsg(key); } @Nullable @Contract(pure = true) public String getCustomName() { return customName; } /** * Get the visibility bonus value. * * @return visibility bonus value */ @Contract(pure = true) public int getVisibilityBonus() { return visibilityBonus; } /** * Check if the character is a human controlled character. * * @return {@code true} if the character is a human controlled character */ @Contract(pure = true) public boolean isHuman() { return (charId != null) && charId.isHuman(); } /** * Check if the character is a monster. * * @return true if the character is a monster, false if not. */ @Contract(pure = true) public boolean isMonster() { return (charId != null) && charId.isMonster(); } /** * Check if the character is a npc. * * @return true if the character is a npc, false if not. */ @Contract(pure = true) public boolean isNPC() { return (charId != null) && charId.isNPC(); } /** * Move the character to a new position with animation. This function takes absolute coordinates. * * @param newPos the target location of the move * @param mode the mode of the move * @param duration the duration of the animation in milliseconds */ public void moveTo(@Nonnull ServerCoordinate newPos, @Nonnull CharMovementMode mode, int duration) { World.getUpdateTaskManager().addTask((container, delta) -> moveToInternal(newPos, mode, duration)); } public void updateMoveDuration(int newDuration) { World.getUpdateTaskManager().addTask((container, delta) -> updateMoveDurationInternal(newDuration)); } private void updateMoveDurationInternal(int newDuration) { if (move.isRunning()) { move.setDuration(newDuration); if (avatar != null) { avatar.changeAnimationDuration(newDuration); } } } private void moveToInternal(@Nonnull ServerCoordinate newPos, @Nonnull CharMovementMode mode, int duration) { if (mode == CharMovementMode.None) { return; } CharacterId characterId = getCharId(); if (characterId == null) { log.error("Can't move a character without ID around."); return; } if (!World.getPlayer().isPlayer(characterId)) { if (mode == CharMovementMode.Push) { if (delayedMove != null) { if (delayedMove.targetLocation.equals(newPos)) { log.info("{}: Skipping push because there is a delayed move with the same target.", this); return; } } else { if ((location != null) && location.equals(newPos)) { log.info("{}: Skipping push because the character is already on the correct place.", this); return; } } log.info("{}: Executing push move right away.", this); resetAnimation(true); } else if (move.isRunning()) { if (delayedMove == null) { delayedMove = new DelayedMoveData(mode, duration, newPos); log.info("{}: Scheduled move for later execution: {}", this, delayedMove); return; } else { log.warn("{}: Can't delay the move. Spot is already taken. Executing now.", this); resetAnimation(true); } } } delayedMove = null; if (move.isRunning()) { move.stop(); } ServerCoordinate oldPos = location; if (!updateLocation(newPos)) { return; } // determine general visibility by players if (avatar != null) { // calculate movement direction Direction dir = (oldPos == null) ? null : oldPos.getDirection(newPos); // turn only when animating, not when pushed if ((mode != CharMovementMode.Push) && (dir != null)) { setDirection(dir); } // find target elevation int range = 1; if (mode == CharMovementMode.Run) { range = 2; } // start animations only if reasonable distance if ((oldPos == null) || (duration == 0) || (dir == null) || (mode == CharMovementMode.Push)) { // normal reasons for directly setting the avatar to the new location log.debug("{}: Setting avatar to {} without animation.", this, newPos); setPosition(getDisplayCoordinatesAt(newPos)); animationFinished(true); } else if (newPos.getStepDistance(oldPos) > range) { // the locations are too far apart. Report and skip the animation anyway. log.warn("{}: Trying to walk from {} to {}: Out of range! Skipping animation.", this, oldPos, newPos); setPosition(getDisplayCoordinatesAt(newPos)); animationFinished(true); } else { if (mode == CharMovementMode.Walk) { startAnimation(CharAnimations.WALK, duration, true, dir.isDiagonal() ? FastMath.sqrt(2.f) : 1.f); } else if (mode == CharMovementMode.Run) { startAnimation(CharAnimations.RUN, duration, true, dir.isDiagonal() ? FastMath.sqrt(2.f) : 1.f); } DisplayCoordinate oldDisplayPos = getDisplayCoordinatesAt(oldPos); DisplayCoordinate newDisplayPos = getDisplayCoordinatesAt(newPos); if (oldDisplayPos.getLayer() > newDisplayPos.getLayer()) { oldDisplayPos = new DisplayCoordinate(oldDisplayPos.getX(), oldDisplayPos.getY(), newDisplayPos.getLayer()); } else if (oldDisplayPos.getLayer() < newDisplayPos.getLayer()) { newDisplayPos = new DisplayCoordinate(newDisplayPos.getX(), newDisplayPos.getY(), oldDisplayPos.getLayer()); } move.start(oldDisplayPos, newDisplayPos, duration); } updateLight(LIGHT_SOFT); } if (oldPos != null) { MapTile oldTile = World.getMap().getMapAt(oldPos); if (oldTile != null) { oldTile.updateQuestMarkerElevation(); } } MapTile newTile = World.getMap().getMapAt(newPos); if (newTile != null) { newTile.updateQuestMarkerElevation(); } } @Nonnull @Contract(pure = true) private static DisplayCoordinate getDisplayCoordinatesAt(@Nonnull ServerCoordinate coordinate) { int elevation = World.getMap().getElevationAt(coordinate); int x = coordinate.toDisplayX(); int y = coordinate.toDisplayY() - elevation; int layer = coordinate.toDisplayLayer(Layer.Chars); return new DisplayCoordinate(x, y, layer); } private boolean updateLocation(@Nonnull ServerCoordinate newLocation) { if (newLocation.equals(location)) { return false; } ServerCoordinate oldCoordinates = location; location = newLocation; if (oldCoordinates == null) { updateAvatar(); updateLight(); } updateLight(location); return true; } /** * Change the position of the light source of the character and refresh the light. * * @param newLoc the new location of the light source */ public void updateLight(@Nonnull ServerCoordinate newLoc) { LightSource localLightSource = lightSrc; if (localLightSource != null) { World.getLights().updateLightLocation(localLightSource, newLoc); } } /** * Change the direction the character is looking at. * * @param newDirection the new direction value */ public void setDirection(@Nonnull Direction newDirection) { if (direction == newDirection) { log.debug("{}: Skipping direction change, because direction already matches: {}", this, newDirection); return; } log.debug("{}: Applying a new direction to the character: {}", this, newDirection); direction = newDirection; // The update of the direction arrives before the location of the character is set. That is okay because // the update of the character will take the stored direction into account. World.getUpdateTaskManager().addTask((container, delta) -> { if (!move.isRunning() && (location != null)) { if (animation == CharAnimations.STAND) { updateAvatar(); } else { resetAnimation(true); } } }); } public boolean isAnimationAvailable(int animation) { try { return (avatar != null) && avatar.getTemplate().getAvatarInfo().isAnimationAvailable(animation); } catch(IndexOutOfBoundsException e){ log.warn("Tried to perform illegal animation #" + animation + " on character " + getCharId().getAsInteger()); return false; } } /** * Set and start a new animation for this character. The animation is shown and after its done the animation * handler * returns to the normal state. * * @param newAnimation the ID of the new animation * @param duration the duration of the animation in milliseconds */ public void startAnimation(int newAnimation, int duration) { startAnimation(newAnimation, duration, false, 1.f); } /** * Set and start a new animation for this character. The animation is shown and after its done the animation * handler * returns to the normal state. * * @param newAnimation the ID of the new animation * @param duration the duration of the animation in milliseconds */ private void startAnimation(int newAnimation, int duration, boolean shiftAnimation, float length) { if (removedCharacter) { log.warn("Trying to start a animation of a removed character."); return; } if (move.isRunning()) { log.warn("{}: Received new animation {} while move was in progress.", this, newAnimation); } if (avatar == null) { log.debug("{}: Starting a new animation is impossible. No avatar!", this); return; // avatar not ready, discard animation } if (!isAnimationAvailable(newAnimation)) { log.debug("{}: Animation {} is not available.", this, newAnimation); MapTile tile = World.getMap().getMapAt(getLocation()); if (tile == null) { return; } //noinspection SwitchStatementWithoutDefaultBranch switch (newAnimation) { case CharAnimations.ATTACK_1HAND: case CharAnimations.ATTACK_2HAND: tile.showEffect(21); break; case CharAnimations.ATTACK_BOW: tile.showEffect(15); break; case CharAnimations.ATTACK_BLOCK: tile.showEffect(18); break; } return; } animation = newAnimation; log.debug("{}: Starting new animation: {} for {}ms", this, animation, duration); updateAvatar(); if (avatar == null) { log.debug("{}: After updating the avatar, the avatar is gone. Animation impossible.", this); return; // avatar not ready, discard animation } avatar.animate(duration, false, shiftAnimation, length); } /** * Update the light source of the character. * * @param mode the mode of the update */ void updateLight(int mode) { updateLight(avatar, mode); } /** * Change the appearance of the character. * * @param newAppearance the new appearance value */ public void setAppearance(int newAppearance) { if (removedCharacter) { log.warn("Trying to update the appearance of a removed character."); return; } if (appearance != newAppearance) { log.debug("{}: Changing appearance to: {}", this, newAppearance); appearance = newAppearance; if (location != null) { updateAvatar(); } } } /** * Update the color of a specified cloth part. * * @param slot the slot that shall be changed * @param color the color this part shall be displayed in */ public void setClothColor(int slot, @Nonnull Color color) { if (removedCharacter) { log.warn("Trying to change the cloth color of a removed character."); return; } wearItemsColors[slot] = new Color(color); if (avatar != null) { avatar.changeClothColor(slot, color); } } /** * Set a item this character has in its inventory. * * @param slot the slot of the inventory * @param itemId the item id of the item at this slot */ public void setInventoryItem(int slot, @Nonnull ItemId itemId) { if (removedCharacter) { log.warn("Trying to update the inventory of a removed character."); return; } applyLightValue(itemId); switch (slot) { case 1: setWearingItem(AvatarClothManager.GROUP_HAT, itemId.getValue()); break; case 3: setWearingItem(AvatarClothManager.GROUP_CHEST, itemId.getValue()); break; case 5: setWearingItem(AvatarClothManager.GROUP_FIRST_HAND, itemId.getValue()); break; case 6: setWearingItem(AvatarClothManager.GROUP_SECOND_HAND, itemId.getValue()); break; case 9: setWearingItem(AvatarClothManager.GROUP_TROUSERS, itemId.getValue()); break; case 10: setWearingItem(AvatarClothManager.GROUP_SHOES, itemId.getValue()); break; case 11: setWearingItem(AvatarClothManager.GROUP_COAT, itemId.getValue()); break; default: break; } } /** * Add a item the avatar wears to its current list. The changes do not become visible until * {@link #updatePaperdoll(Avatar)} is called. * * @param slot the slot the item is carried at * @param id the ID of the item the character wears */ public void setWearingItem(int slot, int id) { if (removedCharacter) { log.warn("Trying to update the worn items of a removed character."); return; } if ((slot < 0) || (slot >= AvatarClothManager.GROUP_COUNT)) { log.warn("Wearing item set to invalid slot: {}", slot); return; } wearItems[slot] = id; } /** * Set the new location of the character. * * @param newLoc new location of the character */ public void setLocation(@Nonnull ServerCoordinate newLoc) { if (removedCharacter) { log.warn("Trying to update the location of a removed character."); return; } CharacterId characterId = getCharId(); if (characterId == null) { log.error("Trying to change the location of a character without a ID."); return; } if (updateLocation(newLoc)) { log.debug("{}: Setting character location to: {}", this, newLoc); setPosition(getDisplayCoordinatesAt(newLoc)); } } /** * Set the color of the skin of the avatar. * * @param color the color that is used to color the skin */ public void setSkinColor(@Nullable Color color) { if (removedCharacter) { log.warn("Trying to set the skin color of a removed character."); return; } if (color == null) { skinColor = null; } else { skinColor = new Color(color); } if (avatar != null) { avatar.changeBaseColor(color); } } /** * Set the visibility bonus of this character. * * @param newVisibilityBonus the new visibility bonus value */ public void setVisibilityBonus(int newVisibilityBonus) { if (removedCharacter) { log.warn("Trying to set the visibility bonus of a removed character."); return; } visibilityBonus = newVisibilityBonus; } /** * Update the current light source of this character. */ public void updateLight() { if (location == null) { log.debug("The position of the character is not set. The light can't be updated."); return; } if (removedCharacter) { log.warn("Trying to update the light of a removed character."); return; } if (lightValue > 0) { if (lightSrc != null) { if (lightSrc.getEncodedValue() != lightValue) { LightSource newLight = new LightSource(location, lightValue); World.getLights().replace(lightSrc, newLight); lightSrc = newLight; } } else { lightSrc = new LightSource(location, lightValue); World.getLights().addLight(lightSrc); } } else { if (lightSrc != null) { World.getLights().remove(lightSrc); lightSrc = null; } } } /** * Update the paper doll, so set all items the characters wears to the avatar. Do this in case many cloth parts * changed or in case the avatar instance changed. */ public void updatePaperdoll() { if (removedCharacter) { log.warn("Trying to update the paperdoll of a removed character."); return; } updatePaperdoll(avatar); } @Nonnull @Override public String toString() { String charIdString = (charId == null) ? "" : (" (" + charId.getValue() + ')'); return "Character " + name + charIdString; } @Override public int hashCode() { return (charId == null) ? 0 : charId.hashCode(); } @Override @Contract(value = "null->false", pure = true) public boolean equals(@Nullable Object obj) { return (obj instanceof Char) && equals((Char) obj); } @Contract(value = "null->false", pure = true) public boolean equals(@Nullable Char other) { return (other != null) && (charId != null) && charId.equals(other.charId); } }