diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index e24f9437e..1408712fa 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -106,6 +106,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(remote_spell_tests tests/remote_spell.cpp) target_include_directories(remote_spell_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME remote_spell COMMAND remote_spell_tests) + add_executable(hit_point_tests tests/hit_point.cpp) + target_include_directories(hit_point_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME hit_point COMMAND hit_point_tests) add_executable(remote_shot_tests tests/remote_shot.cpp) target_include_directories(remote_shot_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME remote_shot COMMAND remote_shot_tests) @@ -115,6 +118,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(spell_area_tests tests/spell_area.cpp) target_include_directories(spell_area_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME spell_area COMMAND spell_area_tests) + add_executable(heal_tests tests/heal.cpp) + target_include_directories(heal_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME heal COMMAND heal_tests) add_executable(teleport_tests tests/teleport.cpp) target_include_directories(teleport_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME teleport COMMAND teleport_tests) diff --git a/HeavenClient/Character/Char.cpp b/HeavenClient/Character/Char.cpp index afb334642..dc4834256 100644 --- a/HeavenClient/Character/Char.cpp +++ b/HeavenClient/Character/Char.cpp @@ -224,6 +224,9 @@ namespace ms { // v83 stretches each frame of an action to delay * (speed + 10) / 16, // holding the speed to 2..10 (FUN_00453ad1). + if (fixed_action_speed > 0) + return fixed_action_speed; + return std::clamp(get_integer_attackspeed(), 2, 10); } @@ -386,6 +389,7 @@ namespace ms look.set_action(action); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -394,6 +398,7 @@ namespace ms look.attack(stance); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -402,6 +407,7 @@ namespace ms look.attack(degenerate); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -410,6 +416,7 @@ namespace ms look.attack(degenerate, stance); attacking = true; + fixed_action_speed = 0; look.set_alerted(5000); } @@ -418,6 +425,11 @@ namespace ms look.hold(frame); } + void Char::fix_action_speed(int8_t speed) + { + fixed_action_speed = speed; + } + void Char::end_attack() { look.end_attack(); diff --git a/HeavenClient/Character/Char.h b/HeavenClient/Character/Char.h index 36665f0ea..1da31e20f 100644 --- a/HeavenClient/Character/Char.h +++ b/HeavenClient/Character/Char.h @@ -128,6 +128,11 @@ namespace ms // Hold the attack on this frame until end_attack. void hold_attack(uint8_t frame); + // Play the action just started at this speed rather than the + // character's own, until the next one. v83 plays the action of a + // skill used without attacking at 6 (0x96d4a6). + void fix_action_speed(int8_t speed); + // End the attack now, held or not. void end_attack(); @@ -250,6 +255,8 @@ namespace ms State state = State::STAND; bool attacking; + // The speed fixed for the current action, or 0 for the character's own. + int8_t fixed_action_speed = 0; // The milliseconds of animation time a speed left over from its last // whole step. float stance_carry = 0.0f; diff --git a/HeavenClient/Character/Look/BodyDrawInfo.cpp b/HeavenClient/Character/Look/BodyDrawInfo.cpp index 4fc78ec65..df662cda4 100644 --- a/HeavenClient/Character/Look/BodyDrawInfo.cpp +++ b/HeavenClient/Character/Look/BodyDrawInfo.cpp @@ -51,10 +51,11 @@ namespace ms } else { Stance::Id stance = Stance::by_string(ststr); - int16_t delay = framenode["delay"]; + int16_t delay = framenode["delay"].data_type() == nl::node::type::integer + ? static_cast(framenode["delay"]) : static_cast(150); if (delay <= 0) - delay = 100; + delay = 150; stance_delays[stance][frame] = delay; diff --git a/HeavenClient/Character/Look/BodyDrawInfo.h b/HeavenClient/Character/Look/BodyDrawInfo.h index 4b2b1189b..dec36b636 100644 --- a/HeavenClient/Character/Look/BodyDrawInfo.h +++ b/HeavenClient/Character/Look/BodyDrawInfo.h @@ -43,20 +43,12 @@ namespace ms frame = src["frame"]; move = src["move"]; - int16_t sgndelay = src["delay"]; - - if (sgndelay == 0) - sgndelay = 100; - - if (sgndelay > 0) - { - delay = sgndelay; - attackframe = true; - } else if (sgndelay < 0) - { - delay = -sgndelay; - attackframe = false; - } + // v83 takes 150 for a missing delay, but keeps a 0 (FUN_00414d40). + int16_t sgndelay = src["delay"].data_type() == nl::node::type::integer + ? static_cast(src["delay"]) : static_cast(150); + + delay = sgndelay < 0 ? -sgndelay : sgndelay; + attackframe = sgndelay >= 0; } BodyAction() diff --git a/HeavenClient/Character/Look/CharLook.cpp b/HeavenClient/Character/Look/CharLook.cpp index 5274d5323..f46e3cffe 100644 --- a/HeavenClient/Character/Look/CharLook.cpp +++ b/HeavenClient/Character/Look/CharLook.cpp @@ -294,8 +294,9 @@ namespace ms } } else { + // A frame of no delay passes at once. uint16_t delay = action->get_delay(); - uint16_t delta = delay - stelapsed; + uint16_t delta = delay > stelapsed ? delay - stelapsed : 0; if (timestep >= delta) { diff --git a/HeavenClient/Data/SkillData.cpp b/HeavenClient/Data/SkillData.cpp index 824092345..bfcbf683a 100644 --- a/HeavenClient/Data/SkillData.cpp +++ b/HeavenClient/Data/SkillData.cpp @@ -190,6 +190,7 @@ namespace ms 4111004, 4121003, 4221003, 5121002, 11101004, 13101005, 14101006, 15111007, 21100004, 21110004, 21120006 }; + // Heal has its own case (0x96a69d), but casts as these do (0x95571f). static const std::unordered_set spells = { 2001004, 2001005, @@ -199,7 +200,7 @@ namespace ms 2201004, 2201005, 2211002, 2211003, 2211006, 2221001, 2221003, 2221006, 2221007, - 2301005, + 2301002, 2301005, 2311004, 2321001, 2321007, 2321008, 12001003, diff --git a/HeavenClient/Gameplay/Combat/Attack.h b/HeavenClient/Gameplay/Combat/Attack.h index 6594ad04e..5c8e29536 100644 --- a/HeavenClient/Gameplay/Combat/Attack.h +++ b/HeavenClient/Gameplay/Combat/Attack.h @@ -60,6 +60,10 @@ namespace ms int32_t mastery = 0; int32_t amplify = 100; uint8_t element = 0; + // Heal's `hp` percent, which it hurts the undead with in place of a + // magic attack, and how many it reaches, mobs and party members. + int32_t heal = 0; + int32_t healed = 0; uint8_t hitcount = 0; uint8_t mobcount = 0; diff --git a/HeavenClient/Gameplay/Combat/Combat.cpp b/HeavenClient/Gameplay/Combat/Combat.cpp index 4c829c7b0..653ba6e26 100644 --- a/HeavenClient/Gameplay/Combat/Combat.cpp +++ b/HeavenClient/Gameplay/Combat/Combat.cpp @@ -19,6 +19,7 @@ #include "ChainLightning.h" #include "DamageLayout.h" +#include "Heal.h" #include "HitTiming.h" #include "PointBlank.h" #include "Prepare.h" @@ -312,9 +313,9 @@ namespace ms return false; move.apply_useeffects(player); - move.apply_actions(player, Attack::Type::MAGIC); + std::string action = move.apply_actions(player, Attack::Type::MAGIC); + player.fix_action_speed(6); - std::string action = move.get_action(player); uint16_t lock = action.empty() ? 0 : player.get_action_lock(action); player.request(UseSkillPacket(moveid, level, lock, *reach), ExclRequest::SKILL_WAIT); @@ -359,55 +360,114 @@ namespace ms if (kinds & skill_use::PARTY) { - const Party &party = player.get_party(); + members = find_party(skill, range, reach.party); - if (!party.exists()) + if (!skill_use::sends(skill, reach.party)) + return std::nullopt; + } + + if (kinds & skill_use::MOBS) + reach.mobs = find_targets(mobs.get_mobs(), range, position, skill_use::mob_limit(stats.mobcount, members), + true, false); + + return reach; + } + + int32_t Combat::find_party(int32_t skill, Rectangle range, uint8_t &mask) const + { + const Party &party = player.get_party(); + int32_t members = 0; + + if (!party.exists()) + { + mask = skill_use::ALONE; + members = 1; + } + + for (const PartyMember &member : party.get_members()) + { + bool self = member.id == player.get_oid(); + bool dead = false; + + if (self) { - reach.party = skill_use::ALONE; - members = 1; + dead = player.is_dead(); } + else + { + Optional other = chars.get_char(member.id); + + if (!other || !range.contains(other->get_position())) + continue; - for (const PartyMember &member : party.get_members()) + dead = other->is_dead(); + } + + if (skill_use::reaches(skill, self, dead)) { - bool self = member.id == player.get_oid(); - bool dead = false; + mask |= skill_use::party_bit(member.slot); + members++; + } + } - if (self) - { - dead = player.is_dead(); - } - else - { - Optional other = chars.get_char(member.id); + return members; + } - if (!other || !range.contains(other->get_position())) - continue; + int32_t Combat::request_heal() + { + int32_t level = player.get_skills().get_level(heal::ID); + const SkillData::Stats &stats = SkillData::get(heal::ID).get_stats(level); + Rectangle range = place_range(player.get_position(), stats.range, 1.0f, !player.getflip()); - dead = other->is_dead(); - } + UseSkillPacket::Reach reach; + int32_t members = find_party(heal::ID, range, reach.party); - if (skill_use::reaches(skill, self, dead)) - { - reach.party |= skill_use::party_bit(member.slot); - members++; - } - } + player.request(UseSkillPacket(heal::ID, level, 0, reach), ExclRequest::SKILL_WAIT); - if (!skill_use::sends(skill, reach.party)) - return std::nullopt; + return members; + } + + std::vector Combat::find_undead(Rectangle range, uint8_t count) const + { + MapObjects *objs = mobs.get_mobs(); + std::multimap candidates; + + for (auto &mmo : *objs) + { + const Mob *mob = static_cast(mmo.second.get()); + + if (mob && mob->is_alive() && mob->is_undead() && mob->is_in_range(range)) + candidates.emplace(objs->entry_order(mob->get_oid()), mob->get_oid()); } - if (kinds & skill_use::MOBS) - reach.mobs = find_targets(mobs.get_mobs(), range, position, skill_use::mob_limit(stats.mobcount, members), - true, false); + std::vector targets; - return reach; + for (auto &candidate : candidates) + { + if (targets.size() >= count) + break; + + targets.push_back(candidate.second); + } + + return targets; } void Combat::apply_move(const SpecialMove &move, int32_t charge, bool held) { if (move.is_attack()) { + // Heal asks the server first, as a skill that doesn't attack. + int32_t healed = 0; + + if (move.get_id() == heal::ID) + { + if (!player.can_request(ExclRequest::SKILL_WAIT)) + return; + + healed = request_heal(); + } + Attack attack = player.prepare_attack(move.is_skill() ? move.get_id() : 0); attack.charge = charge; Stance::Id whack = Stance::Id::NONE; @@ -527,6 +587,13 @@ namespace ms } } } + } else if (attack.skill == heal::ID) + { + // It hurts fewer mobs the more of the party it heals, and each + // less the more it reaches. + range = place_range(origin, heal::area(attack.range), 1.0f, attack.toleft); + mob_targets = find_undead(range, skill_use::mob_limit(mobcount, healed)); + attack.healed = healed + static_cast(mob_targets.size()); } else { mob_targets = find_targets(mob_objs, range, origin, mobcount, true, closest_first); diff --git a/HeavenClient/Gameplay/Combat/Combat.h b/HeavenClient/Gameplay/Combat/Combat.h index f53f52e3c..beea861c7 100644 --- a/HeavenClient/Gameplay/Combat/Combat.h +++ b/HeavenClient/Gameplay/Combat/Combat.h @@ -126,6 +126,18 @@ namespace ms // to raise. std::optional find_reach(int32_t skill, int32_t level); + // The party members a skill reaches in `range`, a bit for each in + // `mask`, and how many (0x96db3f). + int32_t find_party(int32_t skill, Rectangle range, uint8_t &mask) const; + + // Tell the server whom in the party Heal heals (0x96a69d), and how + // many. + int32_t request_heal(); + + // The undead in `range` that Heal hurts, at most `count`, in the + // order they entered the map (FUN_00678760). + std::vector find_undead(Rectangle range, uint8_t count) const; + // Use the move. A held skill hits with its `charge`, and its shots // while held keep the body's action (`held`). void apply_move(const SpecialMove &move, int32_t charge = 0, bool held = false); diff --git a/HeavenClient/Gameplay/Combat/Heal.h b/HeavenClient/Gameplay/Combat/Heal.h new file mode 100644 index 000000000..22e13cefc --- /dev/null +++ b/HeavenClient/Gameplay/Combat/Heal.h @@ -0,0 +1,48 @@ +#pragma once + +#include "MagicDamage.h" + +#include "../../Template/Rectangle.h" + +#include + +namespace ms +{ + // Heal, as in the MapleRoyals client (FUN_0096a69d): it tells the server + // whom in the party it heals, then hurts the undead around the user as + // a spell. + namespace heal + { + constexpr int32_t ID = 2301002; + + // It hits the undead in 60% of the skill's area (0x9563a8). + inline Rectangle area(const Rectangle &range) + { + auto scale = [](int16_t side) + { + return static_cast(side * 0.6); + }; + + return { + scale(range.left()), + scale(range.right()), + scale(range.top()), + scale(range.bottom()) + }; + } + + // What a hit on an undead monster comes from in place of the spell's + // magic attack (0x791aa8): the skill's `hp` percent, the player's + // magic, INT and LUK, and how many it reaches, mobs and party members + // together. The more it reaches, the less each gets. + inline double base(int32_t hp, int32_t magic, int32_t intl, int32_t luk, int32_t reached, double roll) + { + if (reached < 1) + reached = 1; + + double rolled = magic_damage::between(intl * 0.2, intl * 0.8, roll); + + return (reached * 0.3 + 1.0) * hp * 0.01 * (rolled * 1.5 + luk) * magic * 0.005 / reached; + } + } +} diff --git a/HeavenClient/Gameplay/Combat/HitPoint.h b/HeavenClient/Gameplay/Combat/HitPoint.h new file mode 100644 index 000000000..5263b4cce --- /dev/null +++ b/HeavenClient/Gameplay/Combat/HitPoint.h @@ -0,0 +1,103 @@ +#pragma once + +#include "../../Template/Rectangle.h" + +#include +#include +#include + +namespace ms +{ + // Where a hit lands on a mob, and where a skill's hit effect is drawn, + // as in the MapleRoyals client. Rectangles and points are in map + // coordinates. + namespace hit_point + { + // The middle of the overlap of the attack and the body + // (FUN_00664603), first stretching an attack that falls short to + // touch the body by a pixel. + inline Point centre(const Rectangle &range, const Rectangle &body) + { + int16_t left = range.left(); + int16_t right = range.right(); + int16_t top = range.top(); + int16_t bottom = range.bottom(); + + if (left >= body.right()) + left = body.right() - 1; + else if (right <= body.left()) + right = body.left() + 1; + + if (top >= body.bottom()) + top = body.bottom() - 1; + else if (bottom <= body.top()) + bottom = body.top() + 1; + + return { + static_cast((std::max(left, body.left()) + std::min(right, body.right())) / 2), + static_cast((std::max(top, body.top()) + std::min(bottom, body.bottom())) / 2) + }; + } + + // What a hit effect's `pos` attaches it to (FUN_0066936e). + enum class Anchor + { + // The mob's feet, following it. + FEET, + // The current frame's head, following the mob and its frames. + HEAD, + // A point in the field that stays put if the mob moves. + FIELD + }; + + struct Placement + { + Anchor anchor; + // From the feet for FEET, from the head for HEAD, and the point + // itself for FIELD. + Point offset; + }; + + // An effect needs a body to land on, except at the feet or head. + inline bool hollow(const Rectangle &body) + { + return body.right() <= body.left() || body.bottom() <= body.top(); + } + + inline std::optional place(int8_t pos, const Rectangle &body, Point mob, + Point hit) + { + switch (pos) + { + case 1: + return Placement{ Anchor::FEET, {}}; + case 2: + return Placement{ Anchor::HEAD, {}}; + case 3: + if (hollow(body)) + return std::nullopt; + + return Placement{ + Anchor::FEET, + Point( + static_cast((body.left() + body.right()) / 2 - mob.x()), + static_cast((body.top() + body.bottom()) / 2 - mob.y()) + ) + }; + case 4: + return Placement{ Anchor::FEET, hit - mob }; + default: + if (hollow(body)) + return std::nullopt; + + return Placement{ + Anchor::FIELD, + Point( + std::clamp(hit.x(), body.left(), body.right()), + std::clamp(hit.y(), body.top(), body.bottom()) + ) + }; + } + } + } +} diff --git a/HeavenClient/Gameplay/Combat/RegularAttack.cpp b/HeavenClient/Gameplay/Combat/RegularAttack.cpp index 641c552e1..4db4f90e9 100644 --- a/HeavenClient/Gameplay/Combat/RegularAttack.cpp +++ b/HeavenClient/Gameplay/Combat/RegularAttack.cpp @@ -26,9 +26,9 @@ namespace ms void RegularAttack::apply_useeffects(Char &) const {} - void RegularAttack::apply_actions(Char &user, Attack::Type type) const + std::string RegularAttack::apply_actions(Char &user, Attack::Type type) const { - action.apply(user, type); + return action.apply(user, type); } void RegularAttack::apply_stats(const Char &user, Attack &attack) const diff --git a/HeavenClient/Gameplay/Combat/RegularAttack.h b/HeavenClient/Gameplay/Combat/RegularAttack.h index 528919ff0..ad5cef0e2 100644 --- a/HeavenClient/Gameplay/Combat/RegularAttack.h +++ b/HeavenClient/Gameplay/Combat/RegularAttack.h @@ -33,7 +33,7 @@ namespace ms public: void apply_useeffects(Char &user) const override; - void apply_actions(Char &user, Attack::Type type) const override; + std::string apply_actions(Char &user, Attack::Type type) const override; void apply_stats(const Char &user, Attack &attack) const override; diff --git a/HeavenClient/Gameplay/Combat/Skill.cpp b/HeavenClient/Gameplay/Combat/Skill.cpp index a41b2e6e0..9eebf1cd0 100644 --- a/HeavenClient/Gameplay/Combat/Skill.cpp +++ b/HeavenClient/Gameplay/Combat/Skill.cpp @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #include "Skill.h" +#include "Heal.h" #include "Prepare.h" #include "../../Character/SkillId.h" @@ -132,7 +133,6 @@ namespace ms } bool hasaction0 = src["action"]["0"].data_type() == nl::node::type::string; - bool hasaction1 = src["action"]["1"].data_type() == nl::node::type::string; // A thrown grenade hits with no action, and flies as no bullet: it // shows its own effect where it lands (0x950921, 0x936528). @@ -141,12 +141,9 @@ namespace ms if (thrown) { action = std::make_unique(); - } else if (hasaction0 && hasaction1) - { - action = std::make_unique(src); } else if (hasaction0) { - action = std::make_unique(src); + action = std::make_unique(src); } else if (data.is_attack()) { bool bylevel = src["level"]["1"]["action"].data_type() == nl::node::type::string; @@ -211,14 +208,9 @@ namespace ms sound->stop_loop(); } - void Skill::apply_actions(Char &user, Attack::Type type) const - { - action->apply(user, type); - } - - std::string Skill::get_action(const Char &user) const + std::string Skill::apply_actions(Char &user, Attack::Type type) const { - return action->get_action(user); + return action->apply(user, type); } void Skill::apply_stats(const Char &user, Attack &attack) const @@ -232,6 +224,11 @@ namespace ms { attack.fixdamage = stats.fixdamage; attack.damagetype = Attack::DMG_FIXED; + } else if (skillid == heal::ID) + { + attack.heal = stats.hp; + attack.element = SkillData::get(skillid).get_element(); + attack.damagetype = Attack::DMG_MAGIC; } else if (stats.matk) { attack.matk += stats.matk; diff --git a/HeavenClient/Gameplay/Combat/Skill.h b/HeavenClient/Gameplay/Combat/Skill.h index afdfd0ffe..263011d25 100644 --- a/HeavenClient/Gameplay/Combat/Skill.h +++ b/HeavenClient/Gameplay/Combat/Skill.h @@ -70,9 +70,7 @@ namespace ms void apply_useeffects(Char &user) const override; - void apply_actions(Char &user, Attack::Type type) const override; - - std::string get_action(const Char &user) const override; + std::string apply_actions(Char &user, Attack::Type type) const override; void apply_stats(const Char &user, Attack &attack) const override; diff --git a/HeavenClient/Gameplay/Combat/SkillAction.cpp b/HeavenClient/Gameplay/Combat/SkillAction.cpp index 0f22bdb9b..6355e2553 100644 --- a/HeavenClient/Gameplay/Combat/SkillAction.cpp +++ b/HeavenClient/Gameplay/Combat/SkillAction.cpp @@ -18,10 +18,11 @@ #include "SkillAction.h" #include "../../Util/Misc.h" +#include "../../Util/Randomizer.h" namespace ms { - void RegularAction::apply(Char &target, Attack::Type atype) const + std::string RegularAction::apply(Char &target, Attack::Type atype) const { Weapon::Type weapontype = target.get_weapontype(); bool degenerate; @@ -40,37 +41,30 @@ namespace ms } target.attack(degenerate); - } - SingleAction::SingleAction(nl::node src) - { - action = std::string(src["action"]["0"]); + return ""; } - void SingleAction::apply(Char &target, Attack::Type) const + RandomAction::RandomAction(nl::node src) { - target.attack(action); - } + for (size_t i = 0; ; i++) + { + nl::node sub = src["action"][std::to_string(i)]; - std::string SingleAction::get_action(const Char &) const - { - return action; - } + if (sub.data_type() != nl::node::type::string) + break; - TwoHandedAction::TwoHandedAction(nl::node src) - { - actions[false] = std::string(src["action"]["0"]); - actions[true] = std::string(src["action"]["1"]); + actions.push_back(std::string(sub)); + } } - void TwoHandedAction::apply(Char& target, Attack::Type) const + std::string RandomAction::apply(Char &target, Attack::Type) const { - target.attack(get_action(target)); - } + Randomizer randomizer; + const std::string &action = actions[randomizer.next_int(actions.size())]; + target.attack(action); - std::string TwoHandedAction::get_action(const Char &target) const - { - return actions[target.is_twohanded()]; + return action; } ByLevelAction::ByLevelAction(nl::node src, int32_t id) @@ -84,18 +78,15 @@ namespace ms skillid = id; } - void ByLevelAction::apply(Char &target, Attack::Type) const + std::string ByLevelAction::apply(Char &target, Attack::Type) const { - std::string action = get_action(target); + auto iter = actions.find(target.get_skilllevel(skillid)); - if (!action.empty()) - target.attack(action); - } + if (iter == actions.end()) + return ""; - std::string ByLevelAction::get_action(const Char &target) const - { - auto iter = actions.find(target.get_skilllevel(skillid)); + target.attack(iter->second); - return iter != actions.end() ? iter->second : ""; + return iter->second; } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Combat/SkillAction.h b/HeavenClient/Gameplay/Combat/SkillAction.h index 1cda248e6..1ebaa93ef 100644 --- a/HeavenClient/Gameplay/Combat/SkillAction.h +++ b/HeavenClient/Gameplay/Combat/SkillAction.h @@ -18,13 +18,13 @@ #pragma once #include +#include #include #include "Attack.h" #include "../../Character/Char.h" -#include "../../Template/BoolPair.h" namespace ms { @@ -34,52 +34,37 @@ namespace ms virtual ~SkillAction() {} - virtual void apply(Char &target, Attack::Type atype) const = 0; - - // The body action the skill takes up, or "" for none of its own. - virtual std::string get_action(const Char &) const - { - return ""; - } + // Plays the skill's body action, and returns it, or "" if it has + // none of its own. + virtual std::string apply(Char &target, Attack::Type atype) const = 0; }; class NoAction : public SkillAction { public: - void apply(Char &, Attack::Type) const override - {} + std::string apply(Char &, Attack::Type) const override + { + return ""; + } }; class RegularAction : public SkillAction { public: - void apply(Char &target, Attack::Type atype) const override; - }; - - class SingleAction : public SkillAction - { - public: - SingleAction(nl::node src); - - void apply(Char &target, Attack::Type atype) const override; - - std::string get_action(const Char &target) const override; - - private: - std::string action; + std::string apply(Char &target, Attack::Type atype) const override; }; - class TwoHandedAction : public SkillAction + // v83 takes one of a skill's actions at random each time + // (FUN_007660b6), so most skills, having one, always take it. + class RandomAction : public SkillAction { public: - TwoHandedAction(nl::node src); + RandomAction(nl::node src); - void apply(Char &target, Attack::Type atype) const override; - - std::string get_action(const Char &target) const override; + std::string apply(Char &target, Attack::Type atype) const override; private: - BoolPair actions; + std::vector actions; }; class ByLevelAction : public SkillAction @@ -87,9 +72,7 @@ namespace ms public: ByLevelAction(nl::node src, int32_t skillid); - void apply(Char &target, Attack::Type atype) const override; - - std::string get_action(const Char &target) const override; + std::string apply(Char &target, Attack::Type atype) const override; private: std::map actions; diff --git a/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp b/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp index 84fb44d11..d89383f2c 100644 --- a/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp +++ b/HeavenClient/Gameplay/Combat/SkillHitEffect.cpp @@ -26,14 +26,14 @@ namespace ms void SingleHitEffect::apply(const AttackUser &user, Mob &target) const { - effect.apply(target, user.flip); + effect.apply(target, user); } TwoHandedHitEffect::TwoHandedHitEffect(nl::node src) : effects(src["hit"]["0"], src["hit"]["1"]) {} void TwoHandedHitEffect::apply(const AttackUser& user, Mob& target) const { - effects[user.secondweapon].apply(target, user.flip); + effects[user.secondweapon].apply(target, user); } ByLevelHitEffect::ByLevelHitEffect(nl::node src) @@ -57,7 +57,7 @@ namespace ms if (iter != effects.begin()) iter--; - iter->second.apply(target, user.flip); + iter->second.apply(target, user); } ByLevelTwoHandedHitEffect::ByLevelTwoHandedHitEffect(nl::node src) @@ -85,7 +85,7 @@ namespace ms if (iter != effects.begin()) iter--; - iter->second[user.secondweapon].apply(target, user.flip); + iter->second[user.secondweapon].apply(target, user); } BySkillLevelHitEffect::BySkillLevelHitEffect(nl::node src) @@ -102,6 +102,6 @@ namespace ms auto iter = effects.find(user.skilllevel); if (iter != effects.end()) - iter->second.apply(target, user.flip); + iter->second.apply(target, user); } } \ No newline at end of file diff --git a/HeavenClient/Gameplay/Combat/SkillHitEffect.h b/HeavenClient/Gameplay/Combat/SkillHitEffect.h index 433099c91..f949361ce 100644 --- a/HeavenClient/Gameplay/Combat/SkillHitEffect.h +++ b/HeavenClient/Gameplay/Combat/SkillHitEffect.h @@ -41,12 +41,15 @@ namespace ms { animation = src; pos = src["pos"]; - z = src["z"]; + // As in the MapleRoyals client, above the mob unless the + // node says otherwise (0x669773). + z = src["z"].data_type() == nl::node::type::integer ? static_cast(src["z"]) : 1; } - void apply(Mob &target, bool flip) const + // Flipped when the attacker faces right. + void apply(Mob &target, const AttackUser &user) const { - target.show_effect(animation, pos, z, flip); + target.show_effect(animation, pos, z, user.flip, user.range); } private: diff --git a/HeavenClient/Gameplay/Combat/SpecialMove.h b/HeavenClient/Gameplay/Combat/SpecialMove.h index 916e1bff1..be28bcdb8 100644 --- a/HeavenClient/Gameplay/Combat/SpecialMove.h +++ b/HeavenClient/Gameplay/Combat/SpecialMove.h @@ -46,13 +46,9 @@ namespace ms virtual void apply_useeffects(Char &user) const = 0; - virtual void apply_actions(Char &user, Attack::Type type) const = 0; - - // The body action the move takes up, or "" for none of its own. - virtual std::string get_action(const Char &) const - { - return ""; - } + // Plays the move's body action, and returns it, or "" if it has + // none of its own. + virtual std::string apply_actions(Char &user, Attack::Type type) const = 0; virtual void apply_stats(const Char &user, Attack &attack) const = 0; diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp index 4520a48f6..dcff6896c 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.cpp @@ -84,6 +84,12 @@ namespace ms mob->set_control(mode); } + void MapMobs::acknowledge(int32_t oid, int16_t moveid, bool nextattack) + { + if (Optional mob = mobs.get(oid)) + mob->acknowledge(moveid, nextattack); + } + void MapMobs::send_mobhp(int32_t oid, int8_t percent, uint16_t playerlevel) { if (Optional mob = mobs.get(oid)) diff --git a/HeavenClient/Gameplay/MapleMap/MapMobs.h b/HeavenClient/Gameplay/MapleMap/MapMobs.h index 94f1d3a28..18f368639 100644 --- a/HeavenClient/Gameplay/MapleMap/MapMobs.h +++ b/HeavenClient/Gameplay/MapleMap/MapMobs.h @@ -55,6 +55,9 @@ namespace ms // Update a mob's hp display. void send_mobhp(int32_t oid, int8_t percent, uint16_t playerlevel); + // The server got a mob's move. + void acknowledge(int32_t oid, int16_t moveid, bool nextattack); + // Update a mob's movements. void send_movement(int32_t oid, Point start, std::vector &&movements); diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index d749718ad..4913e558a 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -18,6 +18,8 @@ #include "Mob.h" #include "Layer.h" +#include "../Combat/Heal.h" +#include "../Combat/HitPoint.h" #include "../Combat/MonsterMagnet.h" #include "../Combat/Prepare.h" #include "../../Character/TouchDamage.h" @@ -257,6 +259,8 @@ namespace ms } effects.update(); + headeffects.update(); + fieldeffects.update(); showhp.update(); // Every client, controller or not, tells the server once a second how @@ -752,17 +756,53 @@ namespace ms void Mob::update_movement() { + // The controller sends nothing new until its last move is acked, + // then its latest (0x6685ac). + if (control && ctrlstate == 2) + { + movepending = true; + return; + } + + movepending = false; + + // It asks to attack when chasing, unless controlling, then 70% of + // the time. + bool wantattack = hastarget && (ctrlstate < 0 || randomizer.next_int(100) < 70); + int8_t flags = (ctrlstate < 0 ? 0x10 : 0) | (wantattack ? 1 : 0); + MoveMobPacket( - oid, 1, 0, 0, 0, 0, 0, 0, get_position(), + oid, ++moveid, flags, -1, false, 0, canfly, get_position(), Movement(phobj, value_of(stance, flip)) ).dispatch(); + + ctrlstate = ctrlstate == -2 || ctrlstate == 1 ? 1 : 2; + } + + void Mob::acknowledge(int16_t ackid, bool nextattack) + { + if (!control) + set_control(1); + + if (ackid < moveid) + ctrlstate = 2; + else + ctrlstate = nextattack ? 4 : 3; + + if (ctrlstate != 2 && movepending) + update_movement(); } void Mob::draw(double viewx, double viewy, float alpha) const { Point absp = phobj.get_absolute(viewx, viewy, alpha); + Point headp = get_head_position(absp); + Point viewp = Point(static_cast(viewx), static_cast(viewy)); + effects.drawbelow(absp, alpha); + headeffects.drawbelow(headp, alpha); + fieldeffects.drawbelow(viewp, alpha); if (!dead) { @@ -798,6 +838,8 @@ namespace ms } effects.drawabove(absp, alpha); + headeffects.drawabove(headp, alpha); + fieldeffects.drawabove(viewp, alpha); } void Mob::draw_hp_bar(Point position) const @@ -819,6 +861,13 @@ namespace ms void Mob::set_control(int8_t mode) { + // Taking control lets it move at once; it may attack only once a + // move is acked. Letting go remembers whether it could have. + if (mode > 0 && !control) + ctrlstate = 3; + else if (mode == 0 && control) + ctrlstate = ctrlstate == 3 ? -2 : ctrlstate == 4 ? -3 : -1; + control = mode > 0; // v83 (FUN_005d511d) has a mob given with aggro chase the local @@ -976,29 +1025,33 @@ namespace ms showhp.set_for(5000); } - void Mob::show_effect(const Animation &animation, int8_t pos, int8_t z, bool f) + void Mob::show_effect(const Animation &animation, int8_t pos, int8_t z, bool f, const Rectangle &range) { if (!active) return; - Point shift; + Rectangle body = get_body(); + Point position = get_position(); + std::optional placement = + hit_point::place(pos, body, position, hit_point::centre(range, body)); + + if (!placement) + return; + + DrawArgument args(placement->offset, f); - switch (pos) + switch (placement->anchor) { - case 0: - shift = get_head_position(Point()); + case hit_point::Anchor::FEET: + effects.add(animation, args, z); break; - case 1: + case hit_point::Anchor::HEAD: + headeffects.add(animation, args, z); break; - case 2: - break; - case 3: - break; - case 4: + case hit_point::Anchor::FIELD: + fieldeffects.add(animation, args, z); break; } - - effects.add(animation, DrawArgument(shift, f), z); } void Mob::show_hit(const Animation &animation, const Rectangle &range, bool f) @@ -1006,30 +1059,7 @@ namespace ms if (!active) return; - Rectangle body = animations.at(stance).get_bounds(); - body.shift(get_position()); - - // As v83 does, centre the spark on the overlap of the attack and the - // body, first stretching an attack that falls short to touch it. - int16_t left = range.left(); - int16_t right = range.right(); - int16_t top = range.top(); - int16_t bottom = range.bottom(); - - if (left >= body.right()) - left = body.right() - 1; - else if (right <= body.left()) - right = body.left() + 1; - - if (top >= body.bottom()) - top = body.bottom() - 1; - else if (bottom <= body.top()) - bottom = body.top() + 1; - - int16_t x = (std::max(left, body.left()) + std::min(right, body.right())) / 2; - int16_t y = (std::max(top, body.top()) + std::min(bottom, body.bottom())) / 2; - - effects.add(animation, DrawArgument(Point(x, y) - get_position(), f), 0); + effects.add(animation, DrawArgument(hit_point::centre(range, get_body()) - get_position(), f), 0); } float Mob::calculate_hitchance(int16_t leveldelta, int32_t player_accuracy) const @@ -1152,7 +1182,10 @@ namespace ms if (!hits(accuracy, avoid, hitroll)) return 0; - double damage = base(attack.magic, attack.intelligence, attack.mastery, attack.matk, randomizer.next_real(1.0)); + double roll = randomizer.next_real(1.0); + double damage = attack.heal + ? heal::base(attack.heal, attack.magic, attack.intelligence, attack.luck, attack.healed, roll) + : base(attack.magic, attack.intelligence, attack.mastery, attack.matk, roll); damage = damage * attack.amplify * 0.01; damage = elemental(damage, attack.skill, attack.element, elements); damage = defend(damage, mdef, randomizer.next_real(1.0)); @@ -1247,6 +1280,11 @@ namespace ms return blocksteleport; } + bool Mob::is_undead() const + { + return undead; + } + bool Mob::is_boss() const { return boss; diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 0173b9dee..0d53d8054 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -92,6 +92,10 @@ namespace ms // Tell the mob where the local player is, whom it chases with aggro. void set_target(Point position, bool dead); + // The server got the move with this id (FUN_0066c23b), saying whether + // the mob may attack or cast next. + void acknowledge(int16_t moveid, bool nextattack); + // Send movement to the mob. void send_movement(Point start, std::vector &&movements); @@ -108,8 +112,10 @@ namespace ms // Use the playerlevel to determine color of nametag. void show_hp(int8_t percentage, uint16_t playerlevel); - // Show an effect at the mob's position. - void show_effect(const Animation &animation, int8_t pos, int8_t z, bool flip); + // Show a skill's hit effect where its `pos` puts it, given where the + // attack reached. + void show_effect(const Animation &animation, int8_t pos, int8_t z, bool flip, + const Rectangle &range); // Show a weapon's hit spark where the attack meets the mob's body. void show_hit(const Animation &animation, const Rectangle &range, bool flip); @@ -143,6 +149,9 @@ namespace ms // Whether Monster Magnet can't catch the mob. bool is_boss() const; + // Whether Heal hurts the mob. + bool is_undead() const; + // Whether the mob walks or flies. bool can_move() const; @@ -254,6 +263,10 @@ namespace ms int16_t standwidth; EffectLayer effects; + // Hit effects on the current frame's head, and those left at a point + // in the field. + EffectLayer headeffects; + EffectLayer fieldeffects; Text namelabel; // "Lv. N", drawn left of the name tag (Basic.img/ShowLevel). std::vector leveltag; @@ -276,7 +289,16 @@ namespace ms int8_t team; bool dying; bool dead; - bool control; + bool control = false; + // v83's control state (CMob +0x130): 3 when the controller may send + // a move, 4 when it may also attack or cast, 2 while a move awaits + // the server's ack, 1 after a move sent without control, and below 0 + // when not controlling (FUN_006637ec). + int8_t ctrlstate = -1; + // The id of the last move sent, one more each move. + int16_t moveid = 0; + // Whether a move came up while awaiting an ack, to be sent with it. + bool movepending = false; // Whether the mob chases the local player, and for how many more ms. bool hastarget; int32_t targettime; diff --git a/HeavenClient/IO/UITypes/UIWorldSelect.cpp b/HeavenClient/IO/UITypes/UIWorldSelect.cpp index 8d8c24004..935f5e4ee 100644 --- a/HeavenClient/IO/UITypes/UIWorldSelect.cpp +++ b/HeavenClient/IO/UITypes/UIWorldSelect.cpp @@ -420,6 +420,18 @@ namespace ms recommended_worlds.emplace_back(std::move(world)); } + void UIWorldSelect::focus_world(int32_t worldid) + { + for (size_t i = 0; i < worlds.size() && i < PLANK_SLOTS; i++) + { + if (worlds[i].wid == worldid) + { + focused = static_cast(i); + return; + } + } + } + void UIWorldSelect::remove_selected() { deactivate(); diff --git a/HeavenClient/IO/UITypes/UIWorldSelect.h b/HeavenClient/IO/UITypes/UIWorldSelect.h index 277be5415..6936c9900 100644 --- a/HeavenClient/IO/UITypes/UIWorldSelect.h +++ b/HeavenClient/IO/UITypes/UIWorldSelect.h @@ -55,6 +55,9 @@ namespace ms void add_recommended_world(RecommendedWorld world); + // Put the key focus on the world the account last played in. + void focus_world(int32_t worldid); + void remove_selected(); // The v83 art for a world is keyed by the world id itself. diff --git a/HeavenClient/Net/Handlers/LoginHandlers.cpp b/HeavenClient/Net/Handlers/LoginHandlers.cpp index 64a3be7b8..ab514d537 100644 --- a/HeavenClient/Net/Handlers/LoginHandlers.cpp +++ b/HeavenClient/Net/Handlers/LoginHandlers.cpp @@ -253,6 +253,22 @@ namespace ms } } + void LastConnectedWorldHandler::handle(InPacket& recv) const + { + static int32_t last = 254; + + int32_t world = recv.read_int(); + + if (world != 253) + last = world; + + if (last == 254 || last == 255) + return; + + if (auto worldselect = UI::get().get_element()) + worldselect->focus_world(last); + } + void RecommendedWorldsHandler::handle(InPacket& recv) const { if (auto worldselect = UI::get().get_element()) diff --git a/HeavenClient/Net/Handlers/LoginHandlers.h b/HeavenClient/Net/Handlers/LoginHandlers.h index 990cbaed1..56551ec19 100644 --- a/HeavenClient/Net/Handlers/LoginHandlers.h +++ b/HeavenClient/Net/Handlers/LoginHandlers.h @@ -69,6 +69,14 @@ namespace ms void handle(InPacket& recv) const override; }; + // The world the account last played in, as MapleRoyals does (0x5f82f4): + // its plank gets the key focus. 253 keeps the last one; 254 and 255 + // name none. + class LastConnectedWorldHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Handles the packet that contains information on recommended worlds class RecommendedWorldsHandler : public PacketHandler { diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 8eba8ab6e..49cb6d8b3 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -346,6 +346,20 @@ namespace ms Stage::get().get_mobs().send_movement(oid, position, std::move(movements)); } + void MoveMobResponseHandler::handle(InPacket& recv) const + { + int32_t oid = recv.read_int(); + int16_t moveid = recv.read_short(); + bool nextattack = recv.read_bool(); + + // Its MP and a skill to cast next: mob skills aren't modeled yet. + recv.read_short(); + recv.read_byte(); + recv.read_byte(); + + Stage::get().get_mobs().acknowledge(oid, moveid, nextattack); + } + void ShowMobHpHandler::handle(InPacket& recv) const { int32_t oid = recv.read_int(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 25e993da6..1a9f0c65d 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -106,6 +106,13 @@ namespace ms void handle(InPacket& recv) const override; }; + // The server got the controller's move of a mob (FUN_0066c23b). + // Opcode: MOVE_MOB_RESPONSE(240) + class MoveMobResponseHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Update mob state and position with the client // Opcode: MOB_MOVED(239) class MobMovedHandler : public PacketHandler diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index c25738183..d5f7a43a3 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -52,6 +52,7 @@ namespace ms PING = 17, /// Login 2 + LAST_CONNECTED_WORLD = 26, RECOMMENDED_WORLDS = 27, CHECK_SPW_RESULT = 28, @@ -127,6 +128,7 @@ namespace ms KILL_MOB = 237, SPAWN_MOB_C = 238, MOB_MOVED = 239, + MOVE_MOB_RESPONSE = 240, SHOW_MOB_HP = 250, SHOW_MAGNET = 251, SPAWN_NPC = 257, @@ -172,6 +174,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); // SetField handlers @@ -192,6 +195,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index ee69aff97..80a562686 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -413,19 +413,23 @@ namespace ms class MoveMobPacket : public MovementPacket { public: - // Updates a mob's position with the server - MoveMobPacket(int32_t oid, int16_t type, int8_t skillb, int8_t skill0, int8_t skill1, int8_t skill2, int8_t skill3, int8_t skill4, Point startpos, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) + // Updates a mob's position with the server, as v83 does + // (FUN_0066b6fc): its move id, flags, its action (-1 for none) and + // facing, skill data, an anti-cheat byte and reason, and two ints a + // flyer fills in but others leave at 0xFFDDCC. + MoveMobPacket(int32_t oid, int16_t moveid, int8_t flags, int8_t action, bool left, int32_t skilldata, bool flying, Point startpos, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) { write_int(oid); - write_short(type); - write_byte(skillb); - write_byte(skill0); - write_byte(skill1); - write_byte(skill2); - write_byte(skill3); - write_byte(skill4); - - skip(13); + write_short(moveid); + write_byte(flags); + write_byte(action < 0 ? -1 : (action << 1) | left); + write_int(skilldata); + write_byte(0); + write_int(0); + + int32_t unknown = flying ? 0 : 0x00FFDDCC; + write_int(unknown); + write_int(unknown); write_point(startpos); diff --git a/HeavenClient/tests/heal.cpp b/HeavenClient/tests/heal.cpp new file mode 100644 index 000000000..e38b69ae4 --- /dev/null +++ b/HeavenClient/tests/heal.cpp @@ -0,0 +1,43 @@ +#include "../Gameplay/Combat/Heal.h" + +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +bool same(ms::Rectangle a, ms::Rectangle b) +{ + return a.left() == b.left() && a.right() == b.right() && a.top() == b.top() && a.bottom() == b.bottom(); +} + +bool near(double a, double b) +{ + return std::fabs(a - b) < 1e-6; +} + +int main() +{ + try + { + using namespace ms; + using namespace ms::heal; + + require(same(area({ -300, 300, -200, 200 }), { -180, 180, -120, 120 }), "60% of the skill's area"); + require(same(area({ -251, 251, -151, 149 }), { -150, 150, -90, 89 }), "cut towards the middle"); + + require(near(base(300, 500, 400, 100, 1, 0.0), 2145.0), "the least, alone"); + require(near(base(300, 500, 400, 100, 1, 1.0), 5655.0), "the most rolls INT * 0.8"); + require(near(base(300, 500, 400, 100, 4, 0.0), 907.5), "reaching more, each gets less"); + require(near(base(300, 500, 400, 100, 0, 0.0), 2145.0), "the user always counts"); + } catch (const std::exception &e) + { + std::cerr << e.what() << '\n'; + return 1; + } + + return 0; +} diff --git a/HeavenClient/tests/hit_point.cpp b/HeavenClient/tests/hit_point.cpp new file mode 100644 index 000000000..ce29a3a8b --- /dev/null +++ b/HeavenClient/tests/hit_point.cpp @@ -0,0 +1,65 @@ +#include "../Gameplay/Combat/HitPoint.h" + +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +int main() +{ + try + { + using namespace ms; + using namespace ms::hit_point; + + // Left, right, top, bottom. + Rectangle body{ 80, 120, 140, 200 }; + + require(centre({ 100, 200, 150, 170 }, body) == Point(110, 160), "the middle of the overlap"); + require(centre({ 0, 50, 150, 170 }, body) == Point(80, 160), "a short attack touches the body"); + require(centre({ 130, 180, 150, 170 }, body) == Point(119, 160), "from either side"); + + Point mob{ 100, 200 }; + Point hit{ 110, 160 }; + + auto feet = place(1, body, mob, hit); + require(feet && feet->anchor == Anchor::FEET && feet->offset == Point(), "pos 1 at the feet"); + + auto head = place(2, body, mob, hit); + require(head && head->anchor == Anchor::HEAD && head->offset == Point(), "pos 2 on the head"); + + auto middle = place(3, body, mob, hit); + require(middle && middle->anchor == Anchor::FEET && middle->offset == Point(0, -30), + "pos 3 in the middle of the body"); + + auto struck = place(4, body, mob, hit); + require(struck && struck->anchor == Anchor::FEET && struck->offset == Point(10, -40), + "pos 4 where the hit landed, following the mob"); + + auto field = place(0, body, mob, hit); + require(field && field->anchor == Anchor::FIELD && field->offset == hit, "no pos: where the hit landed"); + + auto clamped = place(0, body, mob, { 300, 100 }); + require(clamped && clamped->offset == Point(120, 140), "kept on the body, edges included"); + + Rectangle hollow{ 100, 100, 150, 200 }; + + require(!place(0, hollow, mob, hit), "no body, no effect"); + require(!place(3, hollow, mob, hit), "nor in its middle"); + require(place(1, hollow, mob, hit) && place(2, hollow, mob, hit) && place(4, hollow, mob, hit), + "but the feet and head still show one"); + + std::cout << "hit point tests passed\n"; + } + catch (const std::exception &error) + { + std::cerr << error.what() << '\n'; + + return 1; + } + + return 0; +} diff --git a/net/src/combat.rs b/net/src/combat.rs index 3092296c7..133af5b9c 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -280,6 +280,15 @@ pub enum AttackRejection { Ammo, } +/// The MP a skill's attack spends: none for Heal, whose USE_SKILL paid. +pub fn attack_mp_cost(skill: &Learned) -> i32 { + if skill.skill_id == skills::HEAL { + 0 + } else { + skill.effect.mp_cost + } +} + /// Basic attacks hit one mob once; each hit must stay under twice the base /// maximum (room for the client's own rolls, never a multiple of it). A /// skill attack takes its limits from the skill's level: its mob and hit @@ -305,12 +314,19 @@ pub fn validate(attack: &Attack, player: &Player) -> Result { + let mp = ctx.player.stats.mp; + Ok(HandlerResult::reply(stat::build_stat_update( + true, + &[StatUpdate::Hp(hp), StatUpdate::Mp(mp)], + )?)) + } + Err(refusal) => { + debug!(skill_id, level, ?refusal, "Heal refused"); + Ok(HandlerResult::reply(stat::build_stat_update(true, &[])?)) + } + }; + } match use_skill(ctx.player, skill_id, level, Instant::now()) { Ok(used) => announce(ctx.player, &used), Err(refusal) => { @@ -205,6 +221,26 @@ fn announce_magnet( .with(WorldAction::Field(FieldRequest::Magnet { effect, caught }))) } +/// Heal comes before its attack, with a bit for each party member it +/// reaches (0x96a69d), or 0x80 without a party. It heals the cleric by each +/// one's share; the party members besides them aren't healed yet. It spends +/// the MP, so the attack doesn't. +fn heal(player: &mut Player, level: i32, party: u8, roll: f64) -> Result { + if player.stats.hp <= 0 { + return Err(Refusal::Dead); + } + let skill = skills::learned(player, skills::HEAL) + .filter(|skill| skill.level == level) + .ok_or(Refusal::NotLearned)?; + if i32::from(player.stats.mp) < skill.effect.mp_cost { + return Err(Refusal::NotEnoughMp); + } + player.add_mp(-skill.effect.mp_cost); + let reached = party.count_ones() as i32; + let amount = skills::heal_amount(player, skill.effect.hp, reached, roll); + Ok(player.add_hp(amount as i32)) +} + /// The buff a skill gives, and its value at this level. fn buff_of(skill: &Learned) -> Option<(BuffStat, i32)> { match skill.skill_id % 10_000_000 { @@ -430,6 +466,40 @@ mod tests { ); } + #[test] + fn heal_heals_the_cleric_by_their_share() { + if skills::data().is_err() { + return; + } + let cleric = || { + let mut player = beginner_with(skills::HEAL, 30); + player.stats.int = 400; + player.stats.luk = 100; + player.stats.max_hp = 10_000; + player.stats.mp = 100; + player.stats.max_mp = 100; + player + }; + // Alone, the lowest roll: (0.3 + 1) * 300% * (80 * 1.5 + 100) * 400 + // * 0.005. + let mut player = cleric(); + assert_eq!(heal(&mut player, 30, 0x80, 0.0), Ok(50 + 1716)); + // Reaching two, each gets (0.6 + 1) / 2 of it. + let mut player = cleric(); + assert_eq!(heal(&mut player, 30, 0x30, 0.0), Ok(50 + 1056)); + // Heal spends the MP, not its attack. + let cost = skills::learned(&player, skills::HEAL) + .unwrap() + .effect + .mp_cost; + assert!(cost > 0); + assert_eq!(i32::from(player.stats.mp), 100 - cost); + + assert_eq!(heal(&mut player, 29, 0x80, 0.0), Err(Refusal::NotLearned)); + player.stats.mp = 0; + assert_eq!(heal(&mut player, 30, 0x80, 0.0), Err(Refusal::NotEnoughMp)); + } + #[test] fn using_or_refusing_a_skill_unlocks_the_client() { let mut player = beginner_with(1000, 1); diff --git a/net/src/skills.rs b/net/src/skills.rs index b80904c8d..4fa3734c4 100644 --- a/net/src/skills.rs +++ b/net/src/skills.rs @@ -1,7 +1,7 @@ //! Skill templates (`Skill.nx`), loaded once. use crate::load::{self, asset_path}; -use game::Player; +use game::{InventoryType, Player}; use game_data::skills::{SkillData, SkillInfo, SkillLevel}; use std::sync::OnceLock; @@ -109,6 +109,48 @@ pub fn amplification(player: &Player) -> i32 { .map_or(100, |skill| skill.effect.y) } +/// Heal: it heals the party around the cleric and hurts the undead. +pub const HEAL: i32 = 2301002; + +/// What a spell's damage comes from, with the player's equipment: their INT, +/// their LUK, and their magic, INT plus magic attack, at most 1999. +pub fn spell_stats(player: &Player) -> (i32, i32, i32) { + let (int, luk, matk) = player + .inventory + .items(InventoryType::Equipped) + .filter_map(|(_, item)| item.equip.as_ref()) + .fold( + (i32::from(player.stats.int), i32::from(player.stats.luk), 0), + |(int, luk, matk), equip| { + ( + int + i32::from(equip.int), + luk + i32::from(equip.luk), + matk + i32::from(equip.matk), + ) + }, + ); + (int, luk, (int + matk).clamp(0, 1999)) +} + +/// What Heal does to each of the `reached` it reaches, party members and +/// undead together, before the undead's defense: the v83 client's formula +/// for its hurt (FUN_00791617, 0x791aa8), from the skill's `hp` percent. +/// `roll`, from 0 to 1, picks between 20% and 80% of the INT. The more it +/// reaches, the less each gets. +pub fn heal_amount(player: &Player, hp: i32, reached: i32, roll: f64) -> f64 { + let (int, luk, magic) = spell_stats(player); + let reached = f64::from(reached.max(1)); + let int = f64::from(int); + let rolled = int * 0.2 + (int * 0.8 - int * 0.2) * roll; + (reached * 0.3 + 1.0) + * f64::from(hp) + * 0.01 + * (rolled * 1.5 + f64::from(luk)) + * f64::from(magic) + * 0.005 + / reached +} + /// Whether `job` is `base` or advanced from it, as the v83 client checks: /// 410 covers 410 to 412, and 100 every warrior. fn in_line(job: i32, base: i32) -> bool {