package org.mafagafogigante.dungeon.entity.creatures; import static org.mafagafogigante.dungeon.date.DungeonTimeUnit.HOUR; import static org.mafagafogigante.dungeon.date.DungeonTimeUnit.SECOND; import org.mafagafogigante.dungeon.achievements.AchievementTracker; import org.mafagafogigante.dungeon.date.Date; import org.mafagafogigante.dungeon.date.Duration; import org.mafagafogigante.dungeon.entity.Entity; import org.mafagafogigante.dungeon.entity.items.BaseInventory; import org.mafagafogigante.dungeon.entity.items.BookComponent; import org.mafagafogigante.dungeon.entity.items.CreatureInventory.SimulationResult; import org.mafagafogigante.dungeon.entity.items.DrinkableComponent; import org.mafagafogigante.dungeon.entity.items.FoodComponent; import org.mafagafogigante.dungeon.entity.items.Item; import org.mafagafogigante.dungeon.game.DungeonString; import org.mafagafogigante.dungeon.game.Engine; import org.mafagafogigante.dungeon.game.Game; import org.mafagafogigante.dungeon.game.Name; import org.mafagafogigante.dungeon.game.NameFactory; import org.mafagafogigante.dungeon.game.PartOfDay; import org.mafagafogigante.dungeon.game.QuantificationMode; import org.mafagafogigante.dungeon.game.Random; import org.mafagafogigante.dungeon.game.World; import org.mafagafogigante.dungeon.io.Sleeper; import org.mafagafogigante.dungeon.io.Writer; import org.mafagafogigante.dungeon.spells.Spell; import org.mafagafogigante.dungeon.spells.SpellData; import org.mafagafogigante.dungeon.util.DungeonMath; import org.mafagafogigante.dungeon.util.Matches; import org.mafagafogigante.dungeon.util.Messenger; import org.mafagafogigante.dungeon.util.Utils; import org.mafagafogigante.dungeon.util.library.Libraries; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.awt.Color; import java.util.Collections; import java.util.List; import java.util.Locale; /** * Hero class that defines the creature that the player controls. */ public class Hero extends Creature { private static final long serialVersionUID = 640405734043893761L; // The longest possible sleep starts at 19:00 and ends at 05:15 (takes 10 hours and 15 minutes). // It seems a good idea to let the Hero have one dream every 4 hours. private static final int DREAM_DURATION_IN_SECONDS = 4 * DungeonMath.safeCastLongToInteger(HOUR.as(SECOND)); private static final int MILLISECONDS_TO_SLEEP_AN_HOUR = 500; private static final int SECONDS_TO_PICK_UP_AN_ITEM = 10; private static final int SECONDS_TO_HIT_AN_ITEM = 4; private static final int SECONDS_TO_EAT_AN_ITEM = 30; private static final int SECONDS_TO_DRINK_AN_ITEM = 10; private static final int SECONDS_TO_DROP_AN_ITEM = 2; private static final int SECONDS_TO_UNEQUIP = 4; private static final int SECONDS_TO_EQUIP = 6; private static final int SECONDS_TO_MILK_A_CREATURE = 45; private static final int SECONDS_TO_READ_EQUIPPED_CLOCK = 4; private static final int SECONDS_TO_READ_UNEQUIPPED_CLOCK = 10; private static final double MAXIMUM_HEALTH_THROUGH_REST = 0.6; private static final int SECONDS_TO_REGENERATE_FULL_HEALTH = 30000; // 500 minutes (or 8 hours and 20 minutes). private static final int MILK_NUTRITION = 12; private final Walker walker = new Walker(); private final Observer observer = new Observer(this); private final Spellcaster spellcaster = new HeroSpellcaster(this); private final AchievementTracker achievementTracker; private final Date dateOfBirth; Hero(CreaturePreset preset, AchievementTracker achievementTracker, Date dateOfBirth) { super(preset); this.achievementTracker = achievementTracker; this.dateOfBirth = dateOfBirth; } public Observer getObserver() { return observer; } public Spellcaster getSpellcaster() { return spellcaster; } public AchievementTracker getAchievementTracker() { return achievementTracker; } /** * Increments the Hero's health by a certain amount, without exceeding its maximum health. If at the end the Hero is * completely healed, a messaging about this is written. */ private void addHealth(int amount) { getHealth().incrementBy(amount); if (getHealth().isFull()) { Writer.write("You are completely healed."); } } /** * Rests until the hero is considered to be rested. */ public void rest() { int maximumHealthFromRest = (int) (MAXIMUM_HEALTH_THROUGH_REST * getHealth().getMaximum()); if (getHealth().getCurrent() >= maximumHealthFromRest) { Writer.write("You are already rested."); } else { int healthRecovered = maximumHealthFromRest - getHealth().getCurrent(); // A positive integer. // The fraction SECONDS_TO_REGENERATE_FULL_HEALTH / getHealth().getMaximum() may be smaller than 1. // Therefore, the following expression may evaluate to 0 if we do not use Math.max to secure the call to // Engine.rollDateAndRefresh. int timeResting = Math.max(1, healthRecovered * SECONDS_TO_REGENERATE_FULL_HEALTH / getHealth().getMaximum()); Engine.rollDateAndRefresh(timeResting); Writer.write("Resting..."); getHealth().incrementBy(healthRecovered); Writer.write("You feel rested."); } } /** * Sleep until the sun rises. * * <p>Depending on how much the Hero will sleep, this method may print a few dreams. */ public void sleep() { int seconds; World world = getLocation().getWorld(); PartOfDay pod = world.getPartOfDay(); if (pod == PartOfDay.EVENING || pod == PartOfDay.MIDNIGHT || pod == PartOfDay.NIGHT) { Writer.write("You fall asleep."); seconds = PartOfDay.getSecondsToNext(world.getWorldDate(), PartOfDay.DAWN); // In order to increase realism, add up to 15 minutes to the time it would take to wake up exactly at dawn. seconds += Random.nextInteger(15 * 60 + 1); while (seconds > 0) { final int cycleDuration = Math.min(DREAM_DURATION_IN_SECONDS, seconds); Engine.rollDateAndRefresh(cycleDuration); // Cast to long because it is considered best practice. We are going to end with a long anyway, so start doing // long arithmetic at the first multiplication. Reported by ICAST_INTEGER_MULTIPLY_CAST_TO_LONG in FindBugs. Sleeper.sleep((long) MILLISECONDS_TO_SLEEP_AN_HOUR * cycleDuration / HOUR.as(SECOND)); if (cycleDuration == DREAM_DURATION_IN_SECONDS) { Writer.write(Libraries.getDreamLibrary().next()); } seconds -= cycleDuration; if (!getHealth().isFull()) { int healing = getHealth().getMaximum() * cycleDuration / SECONDS_TO_REGENERATE_FULL_HEALTH; getHealth().incrementBy(healing); } } Writer.write("You wake up."); } else { Writer.write("You can only sleep at night."); } } /** * Returns whether any Item of the current Location is visible to the Hero. */ private boolean canSeeAnItem() { for (Item item : getLocation().getItemList()) { if (canSee(item)) { return true; } } return false; } private <T extends Entity> Matches<T> filterByVisibility(Matches<T> matches) { return Matches.fromCollection(filterByVisibility(matches.toList())); } /** * Prints the name of the player's current location and lists all creatures and items the character sees. */ public void look() { observer.look(); } /** * Selects multiple items from the inventory. */ private List<Item> selectInventoryItems(String[] arguments) { if (getInventory().getItemCount() == 0) { Writer.write("Your inventory is empty."); return Collections.emptyList(); } return selectItems(arguments, getInventory(), false); } /** * Selects a single item from the inventory. */ private Item selectInventoryItem(String[] arguments) { List<Item> selectedItems = selectInventoryItems(arguments); if (selectedItems.size() == 1) { return selectedItems.get(0); } if (selectedItems.size() > 1) { Writer.write("The query matched multiple items."); } return null; } /** * Select a list of items of the current location based on the arguments of a command. */ private List<Item> selectLocationItems(String[] arguments) { if (filterByVisibility(getLocation().getItemList()).isEmpty()) { Writer.write("You don't see any items here."); return Collections.emptyList(); } else { return selectItems(arguments, getLocation().getInventory(), true); } } /** * Selects items of the specified {@code BaseInventory} based on the arguments of a command. * * @param arguments an array of arguments that will determine the item search * @param inventory an object of a subclass of {@code BaseInventory} * @param checkForVisibility true if only visible items should be selectable * @return a List of items */ private List<Item> selectItems(String[] arguments, BaseInventory inventory, boolean checkForVisibility) { List<Item> visibleItems; if (checkForVisibility) { visibleItems = filterByVisibility(inventory.getItems()); } else { visibleItems = inventory.getItems(); } if (arguments.length != 0 || HeroUtils.checkIfAllEntitiesHaveTheSameName(visibleItems)) { return HeroUtils.findItems(visibleItems, arguments); } else { Writer.write("You must specify an item."); return Collections.emptyList(); } } /** * Issues this Hero to attack a target. */ public void attackTarget(String[] arguments) { Creature target = selectTarget(arguments); if (target != null) { Engine.battle(this, target); } } /** * Attempts to select a target from the current location using the player input. * * @return a target Creature or {@code null} */ private Creature selectTarget(String[] arguments) { List<Creature> visibleCreatures = filterByVisibility(getLocation().getCreatures()); if (arguments.length != 0 || HeroUtils.checkIfAllEntitiesHaveTheSameName(visibleCreatures, this)) { return findCreature(arguments); } else { Writer.write("You must specify a target."); return null; } } /** * Attempts to find a creature in the current location comparing its name to an array of string tokens. * * <p>If there are no matches, {@code null} is returned. * * <p>If there is one match, it is returned. * * <p>If there are multiple matches but all have the same name, the first one is returned. * * <p>If there are multiple matches with only two different names and one of these names is the Hero's name, the first * creature match is returned. * * <p>Lastly, if there are multiple matches that do not fall in one of the two categories above, {@code null} is * returned. * * @param tokens an array of string tokens. * @return a Creature or null. */ public Creature findCreature(String[] tokens) { Matches<Creature> result = Matches.findBestCompleteMatches(getLocation().getCreatures(), tokens); result = filterByVisibility(result); if (result.size() == 0) { Writer.write("Creature not found."); } else if (result.size() == 1 || result.getDifferentNames() == 1) { return result.getMatch(0); } else if (result.getDifferentNames() == 2 && result.hasMatchWithName(getName())) { return result.getMatch(0).getName().equals(getName()) ? result.getMatch(1) : result.getMatch(0); } else { Messenger.printAmbiguousSelectionMessage(); } return null; } /** * Attempts to pick up items from the current location. */ public void pickItems(String[] arguments) { if (canSeeAnItem()) { List<Item> selectedItems = selectLocationItems(arguments); for (Item item : selectedItems) { final SimulationResult result = getInventory().simulateItemAddition(item); // We stop adding items as soon as we hit the first one which would exceed the amount or weight limit. if (result == SimulationResult.AMOUNT_LIMIT) { Writer.write("Your inventory is full."); break; } else if (result == SimulationResult.WEIGHT_LIMIT) { Writer.write("You can't carry more weight."); // This may not be ideal, as there may be a selection which has lighter items after this item. break; } else if (result == SimulationResult.SUCCESSFUL) { Engine.rollDateAndRefresh(SECONDS_TO_PICK_UP_AN_ITEM); if (getLocation().getInventory().hasItem(item)) { getLocation().removeItem(item); addItem(item); } else { HeroUtils.writeNoLongerInLocationMessage(item); } } } } else { Writer.write("You do not see any item you could pick up."); } } /** * Adds an Item object to the inventory. As a precondition, simulateItemAddition(Item) should return SUCCESSFUL. * * <p>Writes a message about this to the screen. * * @param item the Item to be added, not null */ public void addItem(Item item) { if (getInventory().simulateItemAddition(item) == SimulationResult.SUCCESSFUL) { getInventory().addItem(item); Writer.write(String.format("Added %s to the inventory.", item.getQualifiedName())); } else { throw new IllegalStateException("simulateItemAddition did not return SUCCESSFUL."); } } /** * Tries to equip an item from the inventory. */ public void parseEquip(String[] arguments) { Item selectedItem = selectInventoryItem(arguments); if (selectedItem != null) { if (selectedItem.hasTag(Item.Tag.WEAPON)) { equipWeapon(selectedItem); } else { Writer.write("You cannot equip that."); } } } /** * Attempts to drop items from the inventory. */ public void dropItems(String[] arguments) { List<Item> selectedItems = selectInventoryItems(arguments); for (Item item : selectedItems) { if (item == getWeapon()) { unsetWeapon(); // Just unset the weapon, it does not need to be moved to the inventory before being dropped. } // Take the time to drop the item. Engine.rollDateAndRefresh(SECONDS_TO_DROP_AN_ITEM); if (getInventory().hasItem(item)) { // The item may have disappeared while dropping. dropItem(item); // Just drop it if has not disappeared. } // The character "dropped" the item even if it disappeared while doing it, so write about it. Writer.write(String.format("Dropped %s.", item.getQualifiedName())); } } /** * Writes the Hero's inventory to the screen. */ public void writeInventory() { Name item = NameFactory.newInstance("item"); String firstLine; if (getInventory().getItemCount() == 0) { firstLine = "Your inventory is empty."; } else { String itemCount = item.getQuantifiedName(getInventory().getItemCount(), QuantificationMode.NUMBER); firstLine = "You are carrying " + itemCount + ". Your inventory weights " + getInventory().getWeight() + "."; } Writer.write(firstLine); // Local variable to improve readability. String itemLimit = item.getQuantifiedName(getInventory().getItemLimit(), QuantificationMode.NUMBER); Writer.write("Your maximum carrying capacity is " + itemLimit + " and " + getInventory().getWeightLimit() + "."); if (getInventory().getItemCount() != 0) { printItems(); } } /** * Prints all items in the Hero's inventory. This function should only be called if the inventory is not empty. */ private void printItems() { if (getInventory().getItemCount() == 0) { throw new IllegalStateException("inventory item count is 0."); } Writer.write("You are carrying:"); for (Item item : getInventory().getItems()) { String name = String.format("%s (%s)", item.getQualifiedName(), item.getWeight()); if (hasWeapon() && getWeapon() == item) { Writer.write(" [Equipped] " + name); } else { Writer.write(" " + name); } } } /** * Attempts to eat an item. */ public void eatItem(String[] arguments) { Item selectedItem = selectInventoryItem(arguments); if (selectedItem != null) { if (selectedItem.hasTag(Item.Tag.FOOD)) { Engine.rollDateAndRefresh(SECONDS_TO_EAT_AN_ITEM); if (getInventory().hasItem(selectedItem)) { FoodComponent food = selectedItem.getFoodComponent(); double remainingBites = selectedItem.getIntegrity().getCurrent() / (double) food.getIntegrityDecrementOnEat(); int healthChange; if (remainingBites >= 1.0) { healthChange = food.getNutrition(); } else { // The absolute value of the healthChange will never be equal to nutrition, only smaller. healthChange = (int) (food.getNutrition() * remainingBites); } selectedItem.decrementIntegrityByEat(); if (selectedItem.isBroken() && !selectedItem.hasTag(Item.Tag.REPAIRABLE)) { Writer.write("You ate " + selectedItem.getName() + "."); } else { Writer.write("You ate a bit of " + selectedItem.getName() + "."); } addHealth(healthChange); } else { HeroUtils.writeNoLongerInInventoryMessage(selectedItem); } } else { Writer.write("You can only eat food."); } } } /** * Attempts to drink an item. */ public void drinkItem(String[] arguments) { Item selectedItem = selectInventoryItem(arguments); if (selectedItem != null) { if (selectedItem.hasTag(Item.Tag.DRINKABLE)) { Engine.rollDateAndRefresh(SECONDS_TO_DRINK_AN_ITEM); if (getInventory().hasItem(selectedItem)) { DrinkableComponent component = selectedItem.getDrinkableComponent(); if (!component.isDepleted()) { component.decrementDoses(); selectedItem.decrementIntegrityByDrinking(); if (component.isDepleted()) { Writer.write("You drank the last dose of " + selectedItem.getName() + "."); } else { Writer.write("You drank a dose of " + selectedItem.getName() + "."); } addHealth(component.getEffect().getHealing()); } else { Writer.write("This item is depleted."); } } else { HeroUtils.writeNoLongerInInventoryMessage(selectedItem); } } else { Writer.write("This item is not drinkable."); } } } /** * The method that enables a Hero to drink milk from a Creature. */ public void parseMilk(String[] arguments) { if (arguments.length != 0) { // Specified which creature to milk from. Creature selectedCreature = selectTarget(arguments); // Finds the best match for the specified arguments. if (selectedCreature != null) { if (selectedCreature.hasTag(Creature.Tag.MILKABLE)) { milk(selectedCreature); } else { Writer.write("This creature is not milkable."); } } } else { // Filter milkable creatures. List<Creature> visibleCreatures = filterByVisibility(getLocation().getCreatures()); List<Creature> milkableCreatures = HeroUtils.filterByTag(visibleCreatures, Tag.MILKABLE); if (milkableCreatures.isEmpty()) { Writer.write("You can't find a milkable creature."); } else { if (Matches.fromCollection(milkableCreatures).getDifferentNames() == 1) { milk(milkableCreatures.get(0)); } else { Writer.write("You need to be more specific."); } } } } private void milk(Creature creature) { Engine.rollDateAndRefresh(SECONDS_TO_MILK_A_CREATURE); Writer.write("You drink milk directly from " + creature.getName().getSingular() + "."); addHealth(MILK_NUTRITION); } /** * Attempts to read an Item. */ public void readItem(String[] arguments) { Item selectedItem = selectInventoryItem(arguments); if (selectedItem != null) { BookComponent book = selectedItem.getBookComponent(); if (book != null) { Engine.rollDateAndRefresh(book.getTimeToRead()); if (getInventory().hasItem(selectedItem)) { // Just in case if a readable item eventually decomposes. DungeonString string = new DungeonString(book.getText()); string.append("\n\n"); Writer.write(string); if (book.isDidactic()) { learnSpell(book); } } else { HeroUtils.writeNoLongerInInventoryMessage(selectedItem); } } else { Writer.write("You can only read books."); } } } /** * Attempts to learn a spell from a BookComponent object. As a precondition, book must be didactic (teach a spell). * * @param book a BookComponent that returns true to isDidactic, not null */ private void learnSpell(@NotNull BookComponent book) { if (!book.isDidactic()) { throw new IllegalArgumentException("book should be didactic."); } Spell spell = SpellData.getSpellMap().get(book.getSpellId()); if (getSpellcaster().knowsSpell(spell)) { Writer.write("You already knew " + spell.getName().getSingular() + "."); } else { getSpellcaster().learnSpell(spell); Writer.write("You learned " + spell.getName().getSingular() + "."); } } private void destroyItem(@NotNull Item target) { if (target.isBroken()) { Writer.write(target.getName() + " is already crashed."); } else { while (getLocation().getInventory().hasItem(target) && !target.isBroken()) { // Simulate item-on-item damage by decrementing an item's integrity by its own hit decrement. target.decrementIntegrityByHit(); if (hasWeapon() && !getWeapon().isBroken()) { getWeapon().decrementIntegrityByHit(); } Engine.rollDateAndRefresh(SECONDS_TO_HIT_AN_ITEM); } String verb = target.hasTag(Item.Tag.REPAIRABLE) ? "crashed" : "destroyed"; Writer.write(getName() + " " + verb + " " + target.getName() + "."); } } /** * Tries to destroy an item from the current location. */ public void destroyItems(String[] arguments) { final List<Item> selectedItems = selectLocationItems(arguments); for (Item target : selectedItems) { if (target != null) { destroyItem(target); } } } private void equipWeapon(Item weapon) { if (hasWeapon()) { if (getWeapon() == weapon) { Writer.write(getName() + " is already equipping " + weapon.getName() + "."); return; } else { unequipWeapon(); } } Engine.rollDateAndRefresh(SECONDS_TO_EQUIP); if (getInventory().hasItem(weapon)) { setWeapon(weapon); DungeonString string = new DungeonString(); string.append(getName() + " equipped " + weapon.getQualifiedName() + "."); string.append(" " + "Your total damage is now " + getTotalDamage() + "."); Writer.write(string); } else { HeroUtils.writeNoLongerInInventoryMessage(weapon); } } /** * Unequips the currently equipped weapon. */ public void unequipWeapon() { if (hasWeapon()) { Engine.rollDateAndRefresh(SECONDS_TO_UNEQUIP); } if (hasWeapon()) { // The weapon may have disappeared. Item equippedWeapon = getWeapon(); unsetWeapon(); Writer.write(getName() + " unequipped " + equippedWeapon.getName() + "."); } else { Writer.write("You are not equipping a weapon."); } } /** * Prints a message with the current status of the Hero. */ public void printAllStatus() { DungeonString string = new DungeonString(); string.append("Your name is "); string.append(getName().getSingular()); string.append("."); string.append(" "); string.append("You are now "); string.append(getAgeString()); string.append(" old"); string.append(".\n"); string.append("You are "); string.append(getHealth().getHealthState().toString().toLowerCase(Locale.ENGLISH)); string.append(".\n"); string.append("Your base attack is "); string.append(String.valueOf(getAttack())); string.append(".\n"); if (hasWeapon()) { string.append("You are currently equipping "); string.append(getWeapon().getQualifiedName()); string.append(", whose base damage is "); string.append(String.valueOf(getWeapon().getWeaponComponent().getDamage())); string.append(". This makes your total damage "); string.append(String.valueOf(getTotalDamage())); string.append(".\n"); } else { string.append("You are fighting bare-handed.\n"); } Writer.write(string); } private int getTotalDamage() { return getAttack() + getWeapon().getWeaponComponent().getDamage(); } /** * Prints the Hero's age. */ public void printAge() { Writer.write(new DungeonString("You are " + getAgeString() + " old.", Color.CYAN)); } private String getAgeString() { return new Duration(dateOfBirth, Game.getGameState().getWorld().getWorldDate()).toString(); } /** * Makes the Hero read the current date and time as well as he can. */ public void readTime() { Item clock = getBestClock(); if (clock != null) { Writer.write(clock.getClockComponent().getTimeString()); // Assume that the hero takes the same time to read the clock and to put it back where it was. Engine.rollDateAndRefresh(getTimeToReadFromClock(clock)); } World world = getLocation().getWorld(); Date worldDate = getLocation().getWorld().getWorldDate(); Writer.write("You think it is " + worldDate.toDateString() + "."); if (worldDate.getMonth() == dateOfBirth.getMonth() && worldDate.getDay() == dateOfBirth.getDay()) { Writer.write("Today is your birthday."); } if (canSeeTheSky()) { Writer.write("You can see that it is " + world.getPartOfDay().toString().toLowerCase(Locale.ENGLISH) + "."); } else { Writer.write("You can't see the sky."); } } /** * Attempts to walk according to the provided arguments. * * @param arguments an array of string arguments */ public void walk(String[] arguments) { walker.parseHeroWalk(arguments); } /** * Gets the easiest-to-access unbroken clock of the Hero. If the Hero has no unbroken clock, the easiest-to-access * broken clock. Lastly, if the Hero does not have a clock at all, null is returned. * * @return an Item object of the clock Item (or null) */ @Nullable private Item getBestClock() { Item clock = null; if (hasWeapon() && getWeapon().hasTag(Item.Tag.CLOCK)) { if (!getWeapon().isBroken()) { clock = getWeapon(); } else { // The Hero is equipping a broken clock: check if he has a working one in his inventory. for (Item item : getInventory().getItems()) { if (item.hasTag(Item.Tag.CLOCK) && !item.isBroken()) { clock = item; break; } } if (clock == null) { clock = getWeapon(); // The Hero does not have a working clock in his inventory: use the equipped one. } } } else { // The Hero is not equipping a clock. Item brokenClock = null; for (Item item : getInventory().getItems()) { if (item.hasTag(Item.Tag.CLOCK)) { if (item.isBroken() && brokenClock == null) { brokenClock = item; } else { clock = item; break; } } } if (brokenClock != null) { clock = brokenClock; } } if (clock != null) { Engine.rollDateAndRefresh(getTimeToReadFromClock(clock)); } return clock; } private int getTimeToReadFromClock(@NotNull Item clock) { return clock == getWeapon() ? SECONDS_TO_READ_EQUIPPED_CLOCK : SECONDS_TO_READ_UNEQUIPPED_CLOCK; } /** * Writes a list with all the Spells that the Hero knows. */ public void writeSpellList() { DungeonString string = new DungeonString(); if (getSpellcaster().getSpellList().isEmpty()) { string.append("You have not learned any spells yet."); } else { string.append("You know "); string.append(Utils.enumerate(getSpellcaster().getSpellList())); string.append("."); } Writer.write(string); } }