diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 1408712fa..5af8875ac 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -136,6 +136,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(shop_list_tests tests/shop_list.cpp Net/InPacket.cpp) target_include_directories(shop_list_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME shop_list COMMAND shop_list_tests) + add_executable(movement_path_tests tests/movement_path.cpp Net/InPacket.cpp Net/Handlers/Helpers/MovementParser.cpp) + target_include_directories(movement_path_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME movement_path COMMAND movement_path_tests) add_executable(status_messages_tests tests/status_messages.cpp Net/InPacket.cpp) target_include_directories(status_messages_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME status_messages COMMAND status_messages_tests) diff --git a/HeavenClient/Character/OtherChar.cpp b/HeavenClient/Character/OtherChar.cpp index b96d5bbdb..09cd54607 100644 --- a/HeavenClient/Character/OtherChar.cpp +++ b/HeavenClient/Character/OtherChar.cpp @@ -22,46 +22,29 @@ namespace ms { OtherChar::OtherChar(int32_t id, const CharLook &lk, uint8_t lvl, int16_t jb, const std::string &nm, int8_t st, - Point pos) : Char(id, lk, nm) + Point pos) : Char(id, lk, nm), + path({ static_cast(pos.x()), static_cast(pos.y()), 0.0, 0.0, 0, + static_cast(st) }) { level = lvl; job = jb; set_position(pos); - lastmove.xpos = pos.x(); - lastmove.ypos = pos.y(); - lastmove.newstate = st; - timer = 0; - attackspeed = 6; attacking = false; } int8_t OtherChar::update(const Physics &physics) { - if (timer > 1) - { - timer--; - } else if (timer == 1) - { - if (!movements.empty()) - { - lastmove = movements.front(); - movements.pop(); - } else - { - timer = 0; - } - } + path.step(Constants::TIMESTEP); + + const move_path::Player::State &now = path.get_state(); if (!attacking) - { - uint8_t laststate = lastmove.newstate; - set_state(laststate); - } + set_state(now.stance); - phobj.hspeed = lastmove.xpos - phobj.crnt_x(); - phobj.vspeed = lastmove.ypos - phobj.crnt_y(); + phobj.hspeed = now.x - phobj.crnt_x(); + phobj.vspeed = now.y - phobj.crnt_y(); phobj.move(); physics.get_fht().update_fh(phobj); @@ -76,13 +59,7 @@ namespace ms void OtherChar::send_movement(const std::vector &newmoves) { - movements.push(newmoves.back()); - - if (timer == 0) - { - constexpr uint16_t DELAY = 50; - timer = DELAY; - } + path.receive(newmoves); } void OtherChar::update_skill(int32_t skillid, uint8_t skilllevel) @@ -99,8 +76,7 @@ namespace ms { look = newlook; - uint8_t laststate = lastmove.newstate; - set_state(laststate); + set_state(path.get_state().stance); } int8_t OtherChar::get_integer_attackspeed() const diff --git a/HeavenClient/Character/OtherChar.h b/HeavenClient/Character/OtherChar.h index 15dc99c77..5992b0137 100644 --- a/HeavenClient/Character/OtherChar.h +++ b/HeavenClient/Character/OtherChar.h @@ -25,9 +25,8 @@ #include "Look/CharLook.h" -#include "../Gameplay/Movement.h" +#include "../Gameplay/MovePath.h" -#include #include namespace ms @@ -42,7 +41,7 @@ namespace ms // Update the character. int8_t update(const Physics &physics) override; - // Add the movements which this character will go through next. + // Queue a path for the character to play. void send_movement(const std::vector &movements); // Update a skill level. @@ -66,9 +65,7 @@ namespace ms private: uint16_t level; int16_t job; - std::queue movements; - Movement lastmove; - uint16_t timer; + move_path::Player path; std::unordered_map skilllevels; uint8_t attackspeed; diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index fd0c9a11a..2826ddc05 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -111,6 +111,7 @@ namespace ms ladder = nullptr; set_chair(0); nullstate.update_state(*this); + path.reset(current_move(0)); } void Player::send_action(KeyAction::Id action, bool down) @@ -259,19 +260,43 @@ namespace ms update_recovery(); - uint8_t stancebyte = facing_right ? state : state + 1; - Movement newmove(phobj, stancebyte); - bool needupdate = lastmove.hasmoved(newmove); + path.step(current_move(0), Constants::TIMESTEP); - if (needupdate) + if (path.ready()) { - MovePlayerPacket(newmove).dispatch(); - lastmove = newmove; + move_path::Path sent = path.flush(); + + if (!sent.idle) + MovePlayerPacket(sent).dispatch(); } return get_layer(); } + uint8_t Player::get_stancebyte() const + { + return facing_right ? state : state + 1; + } + + void Player::record_move(uint8_t command) + { + path.add(current_move(command)); + } + + Movement Player::current_move(uint8_t command) const + { + Movement move(phobj, get_stancebyte(), command, 0); + + if (is_dead()) + move.fh = 0; + else if (ladder) + move.fh = -ladder->get_id(); + else if (!phobj.onground) + move.fh = 0; + + return move; + } + void Player::update_death() { bool dead = stats.get_stat(MapleStat::Id::HP) <= 0; diff --git a/HeavenClient/Character/Player.h b/HeavenClient/Character/Player.h index be90ec8f9..1c0039377 100644 --- a/HeavenClient/Character/Player.h +++ b/HeavenClient/Character/Player.h @@ -31,6 +31,7 @@ #include "Inventory/Inventory.h" +#include "../Gameplay/MovePath.h" #include "../Gameplay/Playable.h" #include "../Gameplay/Combat/Skill.h" @@ -64,6 +65,10 @@ namespace ms // Respawn the player at the given position void respawn(Point position, bool underwater); + // Mark where the player is now in the path to the server, as + // element `command` of no duration (v83 FUN_0052ef17). + void record_move(uint8_t command); + // Sends a Keyaction to the player's state, to apply forces, change the state and other behaviour. void send_action(KeyAction::Id action, bool pressed); @@ -304,7 +309,12 @@ namespace ms std::map keysdown; - Movement lastmove; + move_path::Recorder path; + + uint8_t get_stancebyte() const; + + // Where the player is and how it moves, as path element `command`. + Movement current_move(uint8_t command) const; Randomizer randomizer; diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp index 584716fbb..9c7702e53 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp @@ -156,6 +156,9 @@ namespace ms Ladder::Ladder(nl::node src) { + std::string name = src.name(); + id = 0; + std::from_chars(name.data(), name.data() + name.size(), id); x = src["x"]; y1 = src["y1"]; y2 = src["y2"]; @@ -163,6 +166,11 @@ namespace ms uf = src["uf"].get_bool(); } + int16_t Ladder::get_id() const + { + return id; + } + bool Ladder::is_ladder() const { return ladder; diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.h b/HeavenClient/Gameplay/MapleMap/MapInfo.h index e37df1c4c..5ab0f3723 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.h +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.h @@ -49,6 +49,9 @@ namespace ms bool is_ladder() const; + // Its number in the map. + int16_t get_id() const; + // Whether climbing past the top steps onto a platform (uf). bool leads_up() const; @@ -57,6 +60,7 @@ namespace ms int16_t get_bottom() const; private: + int16_t id; int16_t x; int16_t y1; int16_t y2; diff --git a/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp b/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp index 1c1ab6cec..2255c4de9 100644 --- a/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp @@ -45,6 +45,14 @@ namespace ms npcs.add(spawn.instantiate(physics)); } + for (; !controls.empty(); controls.pop()) + { + auto [oid, control] = controls.front(); + + if (auto npc = npcs.get(oid)) + static_cast(npc.get())->set_control(control); + } + npcs.update(physics); } @@ -59,6 +67,17 @@ namespace ms npc->deactivate(); } + void MapNpcs::control(int32_t oid, bool control) + { + controls.emplace(oid, control); + } + + void MapNpcs::act(int32_t oid, int8_t action, int8_t line) + { + if (auto npc = npcs.get(oid)) + static_cast(npc.get())->act(action, line); + } + void MapNpcs::clear() { npcs.clear(); diff --git a/HeavenClient/Gameplay/MapleMap/MapNpcs.h b/HeavenClient/Gameplay/MapleMap/MapNpcs.h index d5f2fb901..d89b8629e 100644 --- a/HeavenClient/Gameplay/MapleMap/MapNpcs.h +++ b/HeavenClient/Gameplay/MapleMap/MapNpcs.h @@ -46,6 +46,12 @@ namespace ms // Remove the npc with the specified oid; void remove(int32_t oid); + // Take control of an NPC, or give it up, once it has spawned. + void control(int32_t oid, bool control); + + // Play the action the NPC's controller chose. + void act(int32_t oid, int8_t action, int8_t line); + // Remove all NPCs. void clear(); @@ -62,5 +68,6 @@ namespace ms MapObjects npcs; std::queue spawns; + std::queue> controls; }; } \ No newline at end of file diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index 4913e558a..c463fe518 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -39,7 +39,7 @@ namespace ms { Mob::Mob(int32_t oi, int32_t mid, int8_t mode, int8_t st, uint16_t fh, uint16_t homefh, bool newspawn, int8_t tm, - Point position, const Physics &physics) : MapObject(oi) + Point position, const Physics &physics) : MapObject(oi), shown(move_path::Player::State{}) { std::string strid = string_format::extend_id(mid, 7); nl::node src = nl::nx::mob[strid + ".img"]; @@ -103,7 +103,6 @@ namespace ms phobj.shoe = true; phobj.walkforce = info["fs"].get_real(1.0); phobj.walkdrag = phobj.walkforce; - update_shoe(false); id = mid; team = tm; @@ -116,7 +115,6 @@ namespace ms jumpdir = 0; checkjump = false; jumpy = 0; - set_control(mode); phobj.fhid = fh; // v83 stands a new mob on the foothold it enters on (FUN_007f115f), @@ -154,16 +152,26 @@ namespace ms dying = false; dead = false; fading = false; - set_stance(st); + stance = Stance::STAND; + show_stance(st); haswaypoint = false; flygroup = -1; flycount = 0; flyx = 0; flyy = 0; - flysteps = 0; pending_hits = 0; death_timeout = 0; + // It shows where it enters until a path comes, and the AI starts + // from there. + shown = move_path::Player({ phobj.crnt_x(), phobj.crnt_y(), 0.0, 0.0, + static_cast(phobj.onground ? phobj.fhid : 0), value_of(stance, flip) }); + body = phobj; + bodystance = stance; + bodyflip = flip; + update_shoe(false); + set_control(mode); + namelabel = Text(Text::Font::A13M, Text::Alignment::CENTER, Color::Name::WHITE, Text::Background::NAMETAG, name); set_level_tag(0); @@ -179,11 +187,11 @@ namespace ms } } - void Mob::set_stance(uint8_t stancebyte) + Mob::Stance Mob::stance_of(uint8_t stancebyte, bool &right) const { - flip = (stancebyte % 2) == 0; + right = (stancebyte % 2) == 0; - if (!flip) + if (!right) stancebyte -= 1; if (stancebyte < Stance::MOVE) @@ -194,30 +202,50 @@ namespace ms if (animations.count(newstance) == 0) newstance = Stance::MOVE; - set_stance(newstance); + return newstance; + } + + void Mob::show_stance(uint8_t stancebyte) + { + bool right; + Stance newstance = stance_of(stancebyte, right); + + show_stance(newstance, right); } - void Mob::set_stance(Stance newstance) + void Mob::show_stance(Stance newstance, bool right) + { + flip = right; + + if (stance != newstance) + { + stance = newstance; + + animations.at(stance).reset(); + } + } + + void Mob::set_body_stance(Stance newstance) { if (newstance != Stance::HIT) { - phobj.clear_flag(PhysicsObject::Flag::NOFRICTION); + body.clear_flag(PhysicsObject::Flag::NOFRICTION); // Monster Magnet's pull ends with the flinch. if (pull) { pull.reset(); - phobj.hspeed = 0.0; - phobj.vspeed = 0.0; + body.hspeed = 0.0; + body.vspeed = 0.0; } } - if (stance != newstance) - { - stance = newstance; + bodystance = newstance; + } - animations.at(stance).reset(); - } + Point Mob::body_position() const + { + return { body.get_x(), body.get_y() }; } int8_t Mob::update(const Physics &physics) @@ -284,190 +312,310 @@ namespace ms if (!dying) { - if (!canfly) + if (control) { - if (phobj.is_flag_not_set(PhysicsObject::Flag::TURNATEDGES)) + // A firstAttack mob that can attack (FUN_005ce8c6) starts + // chasing the local player once they come within + // |dx| / 10 + |dy| / 3 <= 40 of it. + if (firstattack && !notattack && !hastarget) { - // A knocked back mob stops at the edge, still facing its - // attacker. - if (stance != Stance::HIT) - flip = !flip; + Point position = get_position(); + int32_t dx = std::abs(targetpos.x() - position.x()); + int32_t dy = std::abs(targetpos.y() - position.y()); - phobj.set_flag(PhysicsObject::Flag::TURNATEDGES); + if (dx / 10 + dy / 3 <= 40) + chase(); } - } - if (canfly) - { - // Another client's flier glides the way it faces between the - // controller's updates. - if (!control) + if (flinch) + { + flinch_body(physics); + } else if (!shown.playing() && ctrlstate >= 3) { - flyx = stance == Stance::MOVE ? (flip ? 1 : -1) : 0; - flyy = stance == Stance::HIT ? 0 : -1; + // The next path goes once the last has played and been + // acked (FUN_006675a8). + plan(physics); } - - phobj.walkdir = flyx; - phobj.flydir = flyy; } - switch (stance) - { - case Stance::MOVE: - case Stance::CHASE: - if (!canfly) - phobj.walkdir = flip ? 1 : -1; - - break; - case Stance::HIT: - if (pull) - { - // Monster Magnet's pull (0x9bbdfd) slows each 30 ms - // step once the mob nears the user, and a flier rises - // or sinks toward them too. - Point position = get_position(); + shown.step(Constants::TIMESTEP); - pull->elapsed += Constants::TIMESTEP; + const move_path::Player::State &now = shown.get_state(); - while (pull->elapsed >= magnet::STEP) - { - pull->elapsed -= magnet::STEP; - pull->speed = magnet::step_speed(pull->speed, position.x(), pull->user.x(), pull->reach); - } + // A hit shows at once, over the path, until it has played. + if (acting && aniend) + acting = false; - phobj.hspeed = pull->speed * Constants::TIMESTEP / 1000.0; + if (!acting) + show_stance(now.stance); - if (canfly) - phobj.vspeed = magnet::fly_speed(position.y(), pull->user.y()) * Constants::TIMESTEP / 1000.0; - } else if (phobj.is_flag_set(PhysicsObject::Flag::NOFRICTION) && phobj.onground) - { - // v83 slows the push by 40000 / mass (100) px/s^2 - // along the ground; walls and edges zero it. - constexpr double KBDECEL = 400.0 * Constants::TIMESTEP * Constants::TIMESTEP / 1000000.0; - - double kbspeed = std::abs(phobj.hspeed) - KBDECEL; - - if (kbspeed > 0.0) - { - phobj.hspeed = phobj.hspeed < 0.0 ? -kbspeed : kbspeed; - } else - { - phobj.hspeed = 0.0; - phobj.clear_flag(PhysicsObject::Flag::NOFRICTION); - } - } + phobj.hspeed = now.x - phobj.crnt_x(); + phobj.vspeed = now.y - phobj.crnt_y(); + phobj.move(); - break; - case Stance::JUMP: - // CVecCtrl::Jump (FUN_007f1bb0) speeds the mob up in the - // direction it holds, and it keeps steering in the air. - if (phobj.onground) - Physics::jump(phobj, Physics::jump_speed(1.0), jumpdir); + if (!canfly) + physics.get_fht().update_fh(phobj); + } else + { + phobj.normalize(); + physics.get_fht().update_fh(phobj); - phobj.walkdir = jumpdir; + if (death_timeout > 0 && --death_timeout == 0 && stance != Stance::DIE) + apply_death(); + } - break; - } + return phobj.fhlayer; + } + + void Mob::seat_body(const Physics &physics) + { + move_path::Player::State end = shown.end(); + + body.set_x(end.x); + body.set_y(end.y); + body.hspeed = end.vx * Constants::TIMESTEP / 1000.0; + body.vspeed = end.vy * Constants::TIMESTEP / 1000.0; + + if (end.fh > 0 && !canfly) + physics.get_fht().place_on_fh(body, end.fh); + else + body.onground = false; + + bool right; + Stance endstance = stance_of(end.stance, right); + + // It may stop mid-flinch, but only a flinch it takes holds it. + set_body_stance(endstance == Stance::HIT ? Stance::STAND : endstance); + bodyflip = right; + hittime = 0; + + recorder.reset(Movement(Movement::ABSOLUTE, 0, body.get_x(), body.get_y(), Movement::velocity(body.hspeed), + Movement::velocity(body.vspeed), end.fh, value_of(bodystance, bodyflip), 0)); + seated = true; + } + + void Mob::plan(const Physics &physics) + { + if (!seated) + seat_body(physics); + + // Up to 55 of v83's 90 ms steps (66bbb8). + for (int32_t ms = 0; ms < 55 * 90 && !recorder.ready(canfly); ms += Constants::TIMESTEP) + step_body(physics); + + move_path::Path path = recorder.flush(canfly); + + // 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, ++moveid, flags, -1, false, 0, canfly, path).dispatch(); + + // It plays what it sends, as others will (FUN_0068a88d). + shown.receive(path.elements); + + ctrlstate = ctrlstate == -2 || ctrlstate == 1 ? 1 : 2; + } + + void Mob::flinch_body(const Physics &physics) + { + // v83 stops what it shows at the next foothold and starts the AI + // again from there (FUN_0068b16f, FUN_009bbd3d). Only walkers and + // jumpers go on to one. + shown.land(canfly || !canmove); + seat_body(physics); - if (phobj.is_flag_set(PhysicsObject::Flag::JUMPER)) + Point position = body_position(); + + bodyflip = flinch->user ? flinch->user->x() > position.x() : flinch->left; + set_body_stance(Stance::HIT); + hittime = animations.at(Stance::HIT).get_duration(); + + // It stops steering while hit, so a flier dives (FUN_007f7f02). + flyx = 0; + flyy = 0; + + if (flinch->user) + { + // Monster Magnet pulls it in, then it chases the user (0x66b562). + int16_t reach = standwidth + magnet::REACH; + + pull = Pull{ *flinch->user, reach, magnet::start_speed(position.x(), flinch->user->x(), reach), 0 }; + body.set_flag(PhysicsObject::Flag::NOFRICTION); + chase(); + } else if (canmove) + { + // v83 pushes a flinching mob away from its attacker at 130 px/s. + constexpr double KBSPEED = 130.0 * Constants::TIMESTEP / 1000.0; + + body.hspeed = bodyflip ? -KBSPEED : KBSPEED; + body.set_flag(PhysicsObject::Flag::NOFRICTION); + } + + flinch.reset(); + plan(physics); + } + + void Mob::step_body(const Physics &physics) + { + if (!canfly) + { + if (body.is_flag_not_set(PhysicsObject::Flag::TURNATEDGES)) { - // At a loose end or a wall (FUN_007f8d64), a jumper jumps unless - // it is chasing a target below it, and then only half the time. - // Toward a target on its own group it walks down a step instead. - const FootholdTree &fht = physics.get_fht(); - bool chasing = control && hastarget && stance != Stance::HIT; - - phobj.edgejump = !chasing || targetpos.y() <= phobj.get_y() || randomizer.next_bool(); - phobj.stepdown = !chasing || fht.get_group_below(targetpos) == fht.get_group(phobj.fhid); + // A knocked back mob stops at the edge, still facing its + // attacker. + if (bodystance != Stance::HIT) + bodyflip = !bodyflip; + + body.set_flag(PhysicsObject::Flag::TURNATEDGES); } + } - physics.move_object(phobj); + if (canfly) + { + body.walkdir = flyx; + body.flydir = flyy; + } - bool jumprequest = phobj.jumprequest; - phobj.jumprequest = false; + switch (bodystance) + { + case Stance::MOVE: + case Stance::CHASE: + if (!canfly) + body.walkdir = bodyflip ? 1 : -1; - if (control) - { - // v83 gives up a chase 3 to 4 minutes after aggro (FUN_007f7f02). - if (hastarget) + break; + case Stance::HIT: + if (pull) { - targettime -= Constants::TIMESTEP; + // Monster Magnet's pull (0x9bbdfd) slows each 30 ms + // step once the mob nears the user, and a flier rises + // or sinks toward them too. + Point position = body_position(); - if (targettime < 0) - hastarget = false; - } + pull->elapsed += Constants::TIMESTEP; - // A firstAttack mob that can attack (FUN_005ce8c6) starts - // chasing the local player once they come within - // |dx| / 10 + |dy| / 3 <= 40 of it. - if (firstattack && !notattack && !hastarget) + while (pull->elapsed >= magnet::STEP) + { + pull->elapsed -= magnet::STEP; + pull->speed = magnet::step_speed(pull->speed, position.x(), pull->user.x(), pull->reach); + } + + body.hspeed = pull->speed * Constants::TIMESTEP / 1000.0; + + if (canfly) + body.vspeed = magnet::fly_speed(position.y(), pull->user.y()) * Constants::TIMESTEP / 1000.0; + } else if (body.is_flag_set(PhysicsObject::Flag::NOFRICTION) && body.onground) { - Point position = get_position(); - int32_t dx = std::abs(targetpos.x() - position.x()); - int32_t dy = std::abs(targetpos.y() - position.y()); + // v83 slows the push by 40000 / mass (100) px/s^2 + // along the ground; walls and edges zero it. + constexpr double KBDECEL = 400.0 * Constants::TIMESTEP * Constants::TIMESTEP / 1000000.0; - if (dx / 10 + dy / 3 <= 40) - chase(); + double kbspeed = std::abs(body.hspeed) - KBDECEL; + + if (kbspeed > 0.0) + { + body.hspeed = body.hspeed < 0.0 ? -kbspeed : kbspeed; + } else + { + body.hspeed = 0.0; + body.clear_flag(PhysicsObject::Flag::NOFRICTION); + } } - // v83 (FUN_005cc410) picks a walking mob's action as it goes: - // its chase animation for as long as it has a target. - if (!canfly && (stance == Stance::MOVE || stance == Stance::CHASE)) - walk(input()); + break; + case Stance::JUMP: + // CVecCtrl::Jump (FUN_007f1bb0) speeds the mob up in the + // direction it holds, and it keeps steering in the air. + if (body.onground) + Physics::jump(body, Physics::jump_speed(1.0), jumpdir); + + body.walkdir = jumpdir; + + break; + } + + if (body.is_flag_set(PhysicsObject::Flag::JUMPER)) + { + // At a loose end or a wall (FUN_007f8d64), a jumper jumps unless + // it is chasing a target below it, and then only half the time. + // Toward a target on its own group it walks down a step instead. + const FootholdTree &fht = physics.get_fht(); + bool chasing = hastarget && bodystance != Stance::HIT; + + body.edgejump = !chasing || targetpos.y() <= body.get_y() || randomizer.next_bool(); + body.stepdown = !chasing || fht.get_group_below(targetpos) == fht.get_group(body.fhid); + } + + physics.move_object(body); + + bool jumprequest = body.jumprequest; + body.jumprequest = false; + + // v83 gives up a chase 3 to 4 minutes after aggro (FUN_007f7f02). + if (hastarget) + { + targettime -= Constants::TIMESTEP; + + if (targettime < 0) + hastarget = false; + } + + // v83 (FUN_005cc410) picks a walking mob's action as it goes: + // its chase animation for as long as it has a target. + if (!canfly && (bodystance == Stance::MOVE || bodystance == Stance::CHASE)) + walk(input()); + + switch (bodystance) + { + case Stance::HIT: + // v83 holds a hit mob still for its hit, then leaves it + // standing until its AI next decides. + hittime -= Constants::TIMESTEP; + + if (hittime <= 0) + set_body_stance(Stance::STAND); - switch (stance) + break; + case Stance::JUMP: + // It lands still holding the direction it jumped in, + // and its AI only counts down in the air. + if (body.onground) { - case Stance::HIT: - // v83 holds a hit mob still, then leaves it standing - // until its AI next decides. - if (aniend) - { - set_stance(Stance::STAND); - update_movement(); - } - - break; - case Stance::JUMP: - // It lands still holding the direction it jumped in, - // and its AI only counts down in the air. - if (phobj.onground) - { - walk(jumpdir); - } else - { - for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) - thinktimer--; - } - - break; - default: - // The mob vecctrl steps its AI in 90 ms steps (FUN_007f72d9). - for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) - { - if (canfly) - think_fly(physics); - else - think(physics); - } - - break; + walk(jumpdir); + } else + { + for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) + thinktimer--; } - // A jumper that stopped at the end of its foothold jumps on the - // next tick (FUN_007f1fec). - if (jumprequest && (stance == Stance::MOVE || stance == Stance::CHASE || stance == Stance::STAND)) - jump(input(), false); - } - } else - { - phobj.normalize(); - physics.get_fht().update_fh(phobj); + break; + default: + // The mob vecctrl steps its AI in 90 ms steps (FUN_007f72d9). + for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) + { + if (canfly) + think_fly(physics); + else + think(physics); + } - if (death_timeout > 0 && --death_timeout == 0 && stance != Stance::DIE) - apply_death(); + break; } - return phobj.fhlayer; + // A jumper that stopped at the end of its foothold jumps on the + // next tick (FUN_007f1fec). + if (jumprequest && + (bodystance == Stance::MOVE || bodystance == Stance::CHASE || bodystance == Stance::STAND)) + jump(input(), false); + + // Footholds are recorded only while on them (FUN_009b108a). + int16_t fh = body.onground ? static_cast(body.fhid) : 0; + + recorder.step(Movement(Movement::ABSOLUTE, 0, body.get_x(), body.get_y(), Movement::velocity(body.hspeed), + Movement::velocity(body.vspeed), fh, value_of(bodystance, bodyflip), 0), + Constants::TIMESTEP); } void Mob::think_fly(const Physics &physics) @@ -477,8 +625,8 @@ namespace ms // it is higher. if (haswaypoint) { - double x = phobj.crnt_x(); - double y = phobj.crnt_y(); + double x = body.crnt_x(); + double y = body.crnt_y(); int8_t xdir = 0; int8_t ydir = 0; @@ -488,7 +636,7 @@ namespace ms if (std::abs(static_cast(y - waypoint.y())) < 10) ydir = -1; else if (waypoint.y() < y) - Physics::fly_jump(phobj); + Physics::fly_jump(body); fly(xdir, ydir); @@ -506,15 +654,8 @@ namespace ms haswaypoint = false; } - // It tells the server where it is at each new waypoint, and at - // least once a second. if (!haswaypoint) pick_waypoint(physics); - else if (++flysteps < 11) - return; - - update_movement(); - flysteps = 0; } void Mob::pick_waypoint(const Physics &physics) @@ -576,17 +717,17 @@ namespace ms if (x == 0) { - set_stance(Stance::STAND); + set_body_stance(Stance::STAND); } else { - set_stance(Stance::MOVE); - flip = x > 0; + set_body_stance(Stance::MOVE); + bodyflip = x > 0; } } void Mob::think(const Physics &physics) { - if (phobj.is_flag_set(PhysicsObject::Flag::JUMPER) && think_jump(physics)) + if (body.is_flag_set(PhysicsObject::Flag::JUMPER) && think_jump(physics)) return; // FUN_007f8025 (standing mobs), FUN_007f81c8 (walking) and @@ -598,7 +739,7 @@ namespace ms return; } - Point position = get_position(); + Point position = body_position(); int16_t dx = targetpos.x() - position.x(); if (!canmove) @@ -607,11 +748,7 @@ namespace ms { thinktimer = (randomizer.next_int(400) + 800) / 90; - if (flip != (dx >= 0)) - { - flip = dx >= 0; - update_movement(); - } + bodyflip = dx >= 0; // It forgets a target that leaves the 2000 x 1200 box around it. int16_t dy = targetpos.y() - position.y(); @@ -629,7 +766,7 @@ namespace ms movedir = dx >= 0 ? 1 : -1; walk(movedir); - } else if (canjump && phobj.onground && randomizer.next_int(5) == 0) + } else if (canjump && body.onground && randomizer.next_int(5) == 0) { // A fifth of a jumping mob's idle decisions are jumps, after // which it decides again. @@ -648,7 +785,7 @@ namespace ms bool Mob::think_jump(const Physics &physics) { // FUN_007f835e only looks around on the ground. - if (!phobj.onground) + if (!body.onground) { thinktimer--; return true; @@ -662,7 +799,7 @@ namespace ms { checkjump = false; - if (dir != 0 && phobj.get_y() == jumpy) + if (dir != 0 && body.get_y() == jumpy) { dir = -dir; walk(dir); @@ -673,7 +810,7 @@ namespace ms // lasts, or turns back once pressed against it. const FootholdTree &fht = physics.get_fht(); - switch (fht.get_wall_ahead(phobj, dir)) + switch (fht.get_wall_ahead(body, dir)) { case FootholdTree::WallAhead::AGAINST: dir = -dir; @@ -698,9 +835,9 @@ namespace ms { uint16_t group = fht.get_group_below(targetpos); Range reach = fht.get_group_reach(group); - int16_t x = phobj.get_x(); + int16_t x = body.get_x(); - if (group != fht.get_group(phobj.fhid) && targetpos.y() < phobj.get_y() && + if (group != fht.get_group(body.fhid) && targetpos.y() < body.get_y() && x > reach.first() && x < reach.second() && randomizer.next_int(3) == 0) { movedir = 0; @@ -717,18 +854,17 @@ namespace ms { jumpdir = dir; checkjump = check; - jumpy = phobj.get_y(); - set_stance(Stance::JUMP); - update_movement(); + jumpy = body.get_y(); + set_body_stance(Stance::JUMP); } int8_t Mob::input() const { - switch (stance) + switch (bodystance) { case Stance::MOVE: case Stance::CHASE: - return flip ? 1 : -1; + return bodyflip ? 1 : -1; case Stance::JUMP: return jumpdir; default: @@ -738,45 +874,14 @@ namespace ms void Mob::walk(int8_t direction) { - Stance before = stance; - bool faced = flip; - if (direction == 0) { - set_stance(Stance::STAND); + set_body_stance(Stance::STAND); } else { - set_stance(hastarget && canchase ? Stance::CHASE : Stance::MOVE); - flip = direction > 0; + set_body_stance(hastarget && canchase ? Stance::CHASE : Stance::MOVE); + bodyflip = direction > 0; } - - if (stance != before || flip != faced) - update_movement(); - } - - 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, ++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) @@ -788,9 +893,6 @@ namespace ms ctrlstate = 2; else ctrlstate = nextattack ? 4 : 3; - - if (ctrlstate != 2 && movepending) - update_movement(); } void Mob::draw(double viewx, double viewy, float alpha) const @@ -863,9 +965,12 @@ namespace ms { // Taking control lets it move at once; it may attack only once a // move is acked. Letting go remembers whether it could have. + // The AI starts where the mob is shown going (FUN_006637ec). if (mode > 0 && !control) + { ctrlstate = 3; - else if (mode == 0 && control) + seated = false; + } else if (mode == 0 && control) ctrlstate = ctrlstate == 3 ? -2 : ctrlstate == 4 ? -3 : -1; control = mode > 0; @@ -878,6 +983,7 @@ namespace ms } else if (mode == 0) { hastarget = false; + flinch.reset(); update_shoe(false); } } @@ -907,10 +1013,10 @@ namespace ms float base = canfly ? flyspeed : speed; - phobj.walkspeed = canmove ? shoespeed(chasing && canchase ? chasespeed : base) : 0.0; + body.walkspeed = canmove ? shoespeed(chasing && canchase ? chasespeed : base) : 0.0; if (canfly) - phobj.flyspeed = shoespeed(base); + body.flyspeed = shoespeed(base); } void Mob::set_target(Point position, bool dead) @@ -919,26 +1025,14 @@ namespace ms targetdead = dead; } - void Mob::send_movement(Point start, std::vector &&in_movements) + void Mob::send_movement(Point, std::vector &&in_movements) { + // v83 plays the path on from where the mob is shown; its start is + // not used (FUN_0068b371). if (control) return; - set_position(start); - - movements = std::forward(in_movements); - - if (movements.empty()) - return; - - const Movement &lastmove = movements.front(); - - uint8_t laststance = lastmove.newstate; - set_stance(laststance); - update_shoe(stance == Stance::CHASE); - jumpdir = 0; - - phobj.fhid = lastmove.fh; + shown.receive(in_movements); } Point Mob::get_head_position(Point position) const @@ -1108,8 +1202,6 @@ namespace ms for (auto &hit : result) hit = {next_magic_damage(attack), false}; - update_movement(); - return result; } @@ -1138,8 +1230,6 @@ namespace ms } ); - update_movement(); - return result; } @@ -1205,50 +1295,24 @@ namespace ms { if (pending_hits == 0) apply_death(); - } else if (control && is_alive() && damage >= knockback && stance != Stance::HIT) + } else if (control && is_alive() && damage >= knockback && !acting) { - flip = toleft; - set_stance(Stance::HIT); - - // It stops steering while hit, so a flier dives (FUN_007f7f02). - flyx = 0; - flyy = 0; - - // v83 pushes a flinching mob away from its attacker at 130 px/s. - if (canmove) - { - constexpr double KBSPEED = 130.0 * Constants::TIMESTEP / 1000.0; - - phobj.hspeed = flip ? -KBSPEED : KBSPEED; - phobj.set_flag(PhysicsObject::Flag::NOFRICTION); - } - - update_movement(); + // It shows the hit at once; the AI flinches next update. + show_stance(Stance::HIT, toleft); + acting = true; + flinch = Flinch{ toleft, std::nullopt }; } } void Mob::catch_by(Point user) { - if (!control || !is_alive() || stance == Stance::HIT || !canmove) + if (!control || !is_alive() || acting || !canmove) return; - Point position = get_position(); - // It turns to the user and flinches (0x66b6fc). - flip = user.x() > position.x(); - set_stance(Stance::HIT); - - flyx = 0; - flyy = 0; - - int16_t reach = standwidth + magnet::REACH; - - pull = Pull{ user, reach, magnet::start_speed(position.x(), user.x(), reach), 0 }; - phobj.set_flag(PhysicsObject::Flag::NOFRICTION); - - // Then it chases them (0x66b562). - chase(); - update_movement(); + show_stance(Stance::HIT, user.x() > get_position().x()); + acting = true; + flinch = Flinch{ false, user }; } MobAttack Mob::create_touch_attack() const @@ -1265,7 +1329,7 @@ namespace ms void Mob::apply_death() { - set_stance(Stance::DIE); + show_stance(Stance::DIE, flip); diesound.play(); dying = true; } diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 0d53d8054..c5ada86e3 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -27,6 +27,7 @@ #include "MapObject.h" #include "../Movement.h" +#include "../MovePath.h" #include "../Combat/Attack.h" #include "../Combat/Bullet.h" @@ -96,7 +97,7 @@ namespace ms // the mob may attack or cast next. void acknowledge(int16_t moveid, bool nextattack); - // Send movement to the mob. + // Queue the controller's path to play (0xEF). void send_movement(Point start, std::vector &&movements); // Kill the mob with the appropriate type: @@ -162,11 +163,34 @@ namespace ms Rectangle get_body() const; private: - // Set the stance by byte value - void set_stance(uint8_t stancebyte); + // The stance and facing a stance byte stands for. + Stance stance_of(uint8_t stancebyte, bool &left) const; - // Set the stance by enum value. - void set_stance(Stance newstance); + // Show the stance a path gives. + void show_stance(uint8_t stancebyte); + + // Show a stance, facing right if `right`. + void show_stance(Stance newstance, bool right); + + // Set the stance the AI moves in. + void set_body_stance(Stance newstance); + + // Where the AI has the mob. + Point body_position() const; + + // Start the AI where the shown paths end (FUN_009bbd3d). + void seat_body(const Physics &physics); + + // Step the AI ahead until it has a path to send, then send it and + // play it (FUN_0066b6fc). + void plan(const Physics &physics); + + // One step of the AI and its physics, recorded into the path. + void step_body(const Physics &physics); + + // Stop what is shown at the next foothold, flinch from there, and + // send the path at once. + void flinch_body(const Physics &physics); // Start the death animation. void apply_death(); @@ -200,13 +224,9 @@ namespace ms // Which way the mob is pushing: -1 left, 0 not at all, 1 right. int8_t input() const; - // Stand (0) or walk left (-1) or right (1), telling the server if - // that changes anything. + // Stand (0) or walk left (-1) or right (1). void walk(int8_t direction); - // Send the current position and state to the server. - void update_movement(); - // Calculate the hit chance float calculate_hitchance(int16_t leveldelta, int32_t accuracy) const; @@ -278,7 +298,6 @@ namespace ms TimedBool showhp; - std::vector movements; // Hits queued against this mob that haven't landed yet. uint16_t pending_hits; // Ticks before a killed mob dies even if its pending hits never land. @@ -297,8 +316,31 @@ namespace ms 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; + // What the mob shows (phobj, stance, flip) plays its paths, as v83's + // displayed vecctrl (CMob +0x118) does. The controller runs the AI + // on its own body ahead of that (+0x11c) and records the path it + // sends next. + move_path::Player shown; + PhysicsObject body; + Stance bodystance; + bool bodyflip; + move_path::Recorder recorder; + // Whether the body starts over where the shown paths end. + bool seated = false; + // Whether a hit shows over the path until its animation ends. + bool acting = false; + // Ms more a hit mob holds still. + int16_t hittime = 0; + + // A flinch for the AI to take next update: away from its attacker, + // or toward the user Monster Magnet caught it for. + struct Flinch + { + bool left; + std::optional> user; + }; + + std::optional flinch; // Whether the mob chases the local player, and for how many more ms. bool hastarget; int32_t targettime; @@ -327,8 +369,6 @@ namespace ms int32_t flycount; int8_t flyx; int8_t flyy; - // AI steps since the flier last told the server where it is. - uint16_t flysteps; float walkforce; int8_t hppercent; bool fading; diff --git a/HeavenClient/Gameplay/MapleMap/Npc.cpp b/HeavenClient/Gameplay/MapleMap/Npc.cpp index 2367c395c..f2dc0ab9c 100644 --- a/HeavenClient/Gameplay/MapleMap/Npc.cpp +++ b/HeavenClient/Gameplay/MapleMap/Npc.cpp @@ -19,6 +19,7 @@ #include "NpcIdle.h" #include "../Stage.h" #include "../../Constants.h" +#include "../../Net/Packets/NpcInteractionPackets.h" #include "../../Util/NxFiles.h" #include @@ -81,8 +82,6 @@ namespace ms idle_lines = lines["info"]; speech = ChatBalloon("npc", true); - // v83 starts the wait when the client takes control of the NPC. - wait_idle(); name = std::string(strsrc["name"]); func = std::string(strsrc["func"]); @@ -94,8 +93,8 @@ namespace ms npcid = id; flip = !fl; - control = cnt; stance = "stand"; + set_control(cnt); phobj.fhid = f; set_position(position); @@ -233,13 +232,53 @@ namespace ms return Point(20, -head_height() - 15); } + void Npc::set_control(bool cnt) + { + control = cnt; + + // The first choice also waits. + if (control) + { + wait_idle(); + pending = false; + } + } + + void Npc::act(int8_t action, int8_t line) + { + pending = false; + + if (action == -1) + { + if (line >= 0 && line < static_cast(idle_lines.size())) + speech.change_text(idle_lines[line]); + + return; + } + + int32_t index = action - 2; + + if (index < 0 || index >= static_cast(actions.size())) + return; + + const std::string &name = actions[index]; + set_stance(name); + + if (line >= 0 && line < static_cast(lines[name].size())) + speech.change_text(lines[name][line]); + } + void Npc::update_idle() { + // Only the controller chooses, and everyone plays what comes back. + if (!control) + return; + if (idle_cooldown > 0) idle_cooldown -= Constants::TIMESTEP; // An action plays out before the next choice. - if (idle_cooldown > 0 || stance != "stand") + if (pending || idle_cooldown > 0 || stance != "stand") return; wait_idle(); @@ -252,17 +291,9 @@ namespace ms choice)) return; - if (choice.action < 0) - { - speech.change_text(idle_lines[choice.line]); - return; - } - - const std::string& action = actions[choice.action]; - set_stance(action); - - if (choice.line >= 0) - speech.change_text(lines[action][choice.line]); + int8_t action = choice.action < 0 ? -1 : static_cast(choice.action + 2); + NpcActionPacket(oid, action, static_cast(choice.line)).dispatch(); + pending = true; } void Npc::wait_idle() diff --git a/HeavenClient/Gameplay/MapleMap/Npc.h b/HeavenClient/Gameplay/MapleMap/Npc.h index c65dd351b..aa2a4c4f9 100644 --- a/HeavenClient/Gameplay/MapleMap/Npc.h +++ b/HeavenClient/Gameplay/MapleMap/Npc.h @@ -49,6 +49,14 @@ namespace ms // Changes stance and resets animation. void set_stance(const std::string &stance); + // Pick idle actions for everyone, or stop (FUN_006d2024). + void set_control(bool control); + + // Play an idle action, which the controller chose (FUN_006d2e07): + // `action` is an index into the actions from 2, or -1 for `line` + // of the info/speak lines alone; `line` is -1 for none. + void act(int8_t action, int8_t line); + // Check whether this is a server-sided NPC bool isscripted() const; @@ -98,6 +106,8 @@ namespace ms bool flip; std::string stance; bool control; + // The controller waits for its last choice to come back. + bool pending = false; Randomizer random; Text namelabel; diff --git a/HeavenClient/Gameplay/MapleMap/NpcIdle.h b/HeavenClient/Gameplay/MapleMap/NpcIdle.h index 487e2d681..8aee60527 100644 --- a/HeavenClient/Gameplay/MapleMap/NpcIdle.h +++ b/HeavenClient/Gameplay/MapleMap/NpcIdle.h @@ -9,7 +9,9 @@ namespace ms // FUN_005eaf3e and its chooser FUN_005eb2b4). NPCs never walk. Every 3 to // 9 s, an NPC that is standing either plays one of its actions once, // perhaps saying one of that action's lines, or says one of its - // info/speak lines. + // info/speak lines. Only the NPC's controller chooses, and everyone plays + // the choice when the server sends it back (MapleRoyals: FUN_006d2918, + // 006d2c8c, 006d3273, 006d2e07). namespace npc_idle { // The pause before the next choice is PAUSE_MIN + rand() % PAUSE_SPREAD ms. diff --git a/HeavenClient/Gameplay/MovePath.h b/HeavenClient/Gameplay/MovePath.h new file mode 100644 index 000000000..351382e92 --- /dev/null +++ b/HeavenClient/Gameplay/MovePath.h @@ -0,0 +1,340 @@ +#pragma once + +#include "Movement.h" + +#include "../Template/Point.h" + +#include +#include +#include +#include +#include + +namespace ms +{ + // How the MapleRoyals client batches the user's movement into paths + // (FUN_0068ab85, FUN_0068a88d) and plays other characters' paths back + // (FUN_0068b371, FUN_0068adcc). + namespace move_path + { + // A path goes out once it holds this much movement (FUN_0068a828). + constexpr int32_t SEND_MS = 1000; + + // Elements on a foothold. The rest are in the air (0) or on a ladder + // or rope (minus its number). + inline bool grounded(const Movement &element) + { + return element.fh > 0; + } + + struct Path + { + Point start; + std::vector elements; + // Whether it ends as the last path did, with no type 10 element: + // the user sends no such path. + bool idle; + }; + + // The user's movement since the last path went out. + class Recorder + { + public: + // Start over where the user stands, as on entering a map + // (FUN_0068aab2). + void reset(const Movement &now) + { + elements.clear(); + recorded = 0; + start = { now.xpos, now.ypos }; + last = now; + } + + // One step of `ms` milliseconds that ended in `now`. A step that + // goes on the same way as the last one only makes that one + // longer. + void step(Movement now, int16_t ms) + { + recorded += ms; + + if (!elements.empty() && extends(elements.back(), now)) + { + now.duration = elements.back().duration + ms; + elements.back() = now; + } else + { + now.duration = ms; + append(now); + } + } + + // An element of its own, such as a teleport (4), which takes no + // time. + void add(Movement element) + { + element.duration = 0; + append(element); + } + + // Whether a path should go out: a second of movement with + // something on a foothold in it, or with anything for a flier + // (FUN_0068a828). + bool ready(bool flying = false) const + { + return !elements.empty() && recorded >= SEND_MS && + (flying || std::any_of(elements.begin(), elements.end(), grounded)); + } + + // The path to send. What comes after the last element on a + // foothold waits for the next path, except for a flier, or when + // nothing is on one. + Path flush(bool flying = false) + { + auto held = elements.end(); + + if (!flying) + { + held = std::find_if(elements.rbegin(), elements.rend(), grounded).base(); + + if (held == elements.begin()) + held = elements.end(); + } + + Path path{ start, std::vector(elements.begin(), held), false }; + elements.erase(elements.begin(), held); + + recorded = 0; + + for (const Movement &element : elements) + recorded += element.duration; + + const Movement &tail = path.elements.back(); + path.idle = tail.command == 0 && !last.hasmoved(tail) && tail.fh == last.fh && + std::none_of(path.elements.begin(), path.elements.end(), [](const Movement &element) + { + return element.command == 10; + }); + + last = tail; + start = { tail.xpos, tail.ypos }; + + return path; + } + + private: + static int sign(int16_t value) + { + return (value > 0) - (value < 0); + } + + static bool extends(const Movement &tail, const Movement &now) + { + bool walks = now.command == 0 || now.command == 15 || now.command == 17; + + return walks && tail.command == now.command && tail.fh == now.fh && tail.newstate == now.newstate && + sign(tail.vx) == sign(now.vx) && sign(tail.vy) == sign(now.vy); + } + + // An empty walk at the end is taken over rather than kept. + void append(const Movement &element) + { + if (!elements.empty() && elements.back().command == 0 && elements.back().duration == 0) + elements.back() = element; + else + elements.push_back(element); + } + + std::vector elements; + int32_t recorded = 0; + Point start; + Movement last; + }; + + // Plays another character's paths back in order, as received. + class Player + { + public: + struct State + { + double x; + double y; + // Pixels per second. + double vx; + double vy; + int16_t fh; + uint8_t stance; + }; + + explicit Player(State state) : state(state), from(state) + {} + + // Queue a path. One that falls more than five seconds behind is + // dropped for a jump to where it ends. + void receive(const std::vector &elements) + { + if (elements.empty()) + return; + + for (const Movement &element : elements) + { + queue.push_back(element); + queued += element.duration * TICK; + } + + rate = queued < BEHIND * TICK ? TICK : FAST; + + if (queued > (BEHIND + 5000) * TICK) + { + Movement snap = elements.back(); + snap.command = 3; + snap.duration = 0; + + queue.assign(1, snap); + queued = 0; + } + } + + // Advance `ms` milliseconds, a tenth faster while a second or + // more is queued. + void step(int32_t ms) + { + if (queue.empty()) + return; + + int32_t advance = ms * rate; + elapsed += advance; + + while (!queue.empty()) + { + const Movement &head = queue.front(); + + if (head.command != 10) + { + if (elapsed < head.duration * TICK) + break; + + elapsed -= head.duration * TICK; + arrive(head); + } + + queue.pop_front(); + } + + queued -= advance; + + if (queue.empty() || queued <= 0) + { + queue.clear(); + elapsed = 0; + queued = 0; + + return; + } + + const Movement &head = queue.front(); + double x = state.x; + double y = state.y; + + if (head.command == 3) + { + state.x = head.xpos; + state.y = head.ypos; + state.vx = head.vx; + state.vy = head.vy; + } else + { + state.x = curve(from.x, from.vx, head.xpos, head.vx, head.duration); + state.y = curve(from.y, from.vy, head.ypos, head.vy, head.duration); + state.vx = (state.x - x) * 1000 / ms; + state.vy = (state.y - y) * 1000 / ms; + } + + state.fh = head.fh; + state.stance = head.newstate; + } + + // Stop at the first element on a foothold from the one playing + // now, dropping the rest. A flier, or one with none ahead, + // stops where it is (FUN_0068b16f). + void land(bool flying) + { + auto stop = flying ? queue.end() : std::find_if(queue.begin(), queue.end(), grounded); + + if (stop == queue.end()) + { + queue.clear(); + from = state; + elapsed = 0; + queued = 0; + + return; + } + + for (auto element = stop + 1; element != queue.end(); ++element) + queued -= element->duration * TICK; + + queue.erase(stop + 1, queue.end()); + } + + // Where the queued paths end, or the state with none. + State end() const + { + if (queue.empty()) + return state; + + const Movement &tail = queue.back(); + + return { static_cast(tail.xpos), static_cast(tail.ypos), static_cast(tail.vx), + static_cast(tail.vy), tail.fh, tail.newstate }; + } + + const State &get_state() const + { + return state; + } + + bool playing() const + { + return !queue.empty(); + } + + private: + // Times are kept in thirtieths of a millisecond, so the faster + // rate adds up exactly. + static constexpr int32_t TICK = 30; + static constexpr int32_t FAST = 33; + static constexpr int32_t BEHIND = 1100; + + void arrive(const Movement &element) + { + state.x = element.xpos; + state.y = element.ypos; + state.vx = element.vx; + state.vy = element.vy; + state.fh = element.fh; + state.stance = element.newstate; + from = state; + } + + // The cubic Hermite curve from where the last element ended to + // where this one ends, with both speeds (FUN_0068b108). + double curve(double p0, double v0, double p1, double v1, int16_t duration) const + { + double t = static_cast(elapsed) / (duration * TICK); + double seconds = elapsed / (1000.0 * TICK); + double t2 = t * t; + double t3 = t2 * t; + + return (2 * t3 - 3 * t2 + 1) * p0 + (3 * t2 - 2 * t3) * p1 + (t2 - 2 * t + 1) * seconds * v0 + + (t2 - t) * seconds * v1; + } + + State state; + State from; + std::deque queue; + int32_t elapsed = 0; + int32_t queued = 0; + int32_t rate = TICK; + }; + } +} diff --git a/HeavenClient/Gameplay/Movement.h b/HeavenClient/Gameplay/Movement.h index af467b043..e386f02c7 100644 --- a/HeavenClient/Gameplay/Movement.h +++ b/HeavenClient/Gameplay/Movement.h @@ -17,8 +17,11 @@ ////////////////////////////////////////////////////////////////////////////////// #pragma once +#include #include +#include "../Constants.h" + #include "Physics/PhysicsObject.h" namespace ms @@ -34,17 +37,18 @@ namespace ms JUMPDOWN }; - Movement(Type t, uint8_t c, int16_t x, int16_t y, int16_t lx, int16_t ly, uint16_t f, uint8_t s, int16_t d) - : type(t), command(c), xpos(x), ypos(y), lastx(lx), lasty(ly), fh(f), newstate(s), duration(d) + Movement(Type t, uint8_t c, int16_t x, int16_t y, int16_t vx, int16_t vy, int16_t f, uint8_t s, int16_t d) + : type(t), command(c), xpos(x), ypos(y), vx(vx), vy(vy), fh(f), newstate(s), duration(d) {} - Movement(int16_t x, int16_t y, int16_t lx, int16_t ly, uint8_t s, int16_t d) : Movement(Type::ABSOLUTE, 0, x, y, - lx, ly, 0, s, d) + Movement(int16_t x, int16_t y, int16_t vx, int16_t vy, uint8_t s, int16_t d) : Movement(Type::ABSOLUTE, 0, x, y, + vx, vy, 0, s, d) {} - Movement(const PhysicsObject &phobj, uint8_t s) : Movement(Type::ABSOLUTE, 0, phobj.get_x(), phobj.get_y(), - phobj.get_last_x(), phobj.get_last_y(), phobj.fhid, - s, 1) + // Where the object is and how fast it goes, as element `c`. + Movement(const PhysicsObject &phobj, uint8_t s, uint8_t c = 0, int16_t d = 1) + : Movement(Type::ABSOLUTE, c, phobj.get_x(), phobj.get_y(), velocity(phobj.hspeed), + velocity(phobj.vspeed), phobj.fhid, s, d) {} Movement() : Movement(Type::NONE, 0, 0, 0, 0, 0) @@ -53,16 +57,25 @@ namespace ms bool hasmoved(const Movement &newmove) const { return newmove.newstate != newstate || newmove.xpos != xpos || newmove.ypos != ypos || - newmove.lastx != lastx || newmove.lasty != lasty; + newmove.vx != vx || newmove.vy != vy; + } + + // v83 paths carry speeds in pixels per second; HC's are per step. + static int16_t velocity(double speed) + { + return static_cast(std::lround(speed * 1000 / Constants::TIMESTEP)); } Type type; uint8_t command; int16_t xpos; int16_t ypos; - int16_t lastx; - int16_t lasty; - uint16_t fh; + // Velocity, in pixels per second. + int16_t vx; + int16_t vy; + // The foothold, minus the ladder or rope's number on one, or 0 in + // the air. + int16_t fh; uint8_t newstate; int16_t duration; }; diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index 2161aba65..31a2c6bbe 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -399,6 +399,10 @@ namespace ms { pending_teleport = Teleport{ spot->position, spot->foothold }; player.show_effect_id(CharEffect::Id::TELEPORT); + + // Element 4 marks the jump in the path, from where the player + // stands until the move lands on the next update. + player.record_move(4); } } diff --git a/HeavenClient/Graphics/Animation.cpp b/HeavenClient/Graphics/Animation.cpp index 9e1e8938a..aadbf6ffd 100644 --- a/HeavenClient/Graphics/Animation.cpp +++ b/HeavenClient/Graphics/Animation.cpp @@ -303,12 +303,17 @@ namespace ms if (i >= frames.size()) break; - total += frames[frame_id].get_delay(); + total += frames[i].get_delay(); } return total; } + uint16_t Animation::get_duration() const + { + return getdelayuntil(static_cast(frames.size())); + } + Point Animation::get_origin() const { return get_frame().get_origin(); diff --git a/HeavenClient/Graphics/Animation.h b/HeavenClient/Graphics/Animation.h index ac5831890..e35cc4e21 100644 --- a/HeavenClient/Graphics/Animation.h +++ b/HeavenClient/Graphics/Animation.h @@ -91,6 +91,9 @@ namespace ms uint16_t getdelayuntil(int16_t frame) const; + // How long one play through every frame takes. + uint16_t get_duration() const; + Point get_origin() const; Point get_dimensions() const; diff --git a/HeavenClient/Net/Handlers/AttackHandlers.cpp b/HeavenClient/Net/Handlers/AttackHandlers.cpp index 25b47a7fc..e0d379ab2 100644 --- a/HeavenClient/Net/Handlers/AttackHandlers.cpp +++ b/HeavenClient/Net/Handlers/AttackHandlers.cpp @@ -46,8 +46,10 @@ namespace ms attack.skill = (attack.level > 0) ? recv.read_int() : 0; attack.display = recv.read_byte(); - attack.toleft = recv.read_bool(); - attack.stance = recv.read_byte(); + // The action, with the facing in the top bit (0x9803ab). + uint16_t action = static_cast(recv.read_short()); + attack.toleft = (action >> 15) != 0; + attack.stance = static_cast(action & 0x7FFF); attack.speed = recv.read_byte(); recv.skip(1); diff --git a/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp b/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp index 005d70f33..a53b70f87 100644 --- a/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp @@ -19,10 +19,13 @@ namespace ms { - std::vector MovementParser::parse_movements(InPacket& recv) + std::vector MovementParser::parse_movements(InPacket& recv, Point start) { std::vector movements; uint8_t length = recv.read_byte(); + int16_t x = start.x(); + int16_t y = start.y(); + uint8_t stance = 0; for (uint8_t i = 0; i < length; ++i) { @@ -33,15 +36,20 @@ namespace ms { case 0: case 5: + case 15: case 17: - fragment.type = Movement::ABSOLUTE; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - fragment.lastx = recv.read_short(); - fragment.lasty = recv.read_short(); + // A move to a point, at a speed, onto a foothold; a jump down + // also says the foothold it fell from. + fragment.type = fragment.command == 15 ? Movement::JUMPDOWN : Movement::ABSOLUTE; + x = recv.read_short(); + y = recv.read_short(); + fragment.vx = recv.read_short(); + fragment.vy = recv.read_short(); fragment.fh = recv.read_short(); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); + + if (fragment.command == 15) + recv.skip(2); + break; case 1: case 2: @@ -49,48 +57,57 @@ namespace ms case 12: case 13: case 16: + case 18: + case 19: + case 20: + case 22: + // A speed from where the last element left off. fragment.type = Movement::RELATIVE; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); - break; - case 11: - fragment.type = Movement::CHAIR; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - recv.skip(2); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); - break; - case 15: - fragment.type = Movement::JUMPDOWN; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - fragment.lastx = recv.read_short(); - fragment.lasty = recv.read_short(); - recv.skip(2); - fragment.fh = recv.read_short(); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); + fragment.vx = recv.read_short(); + fragment.vy = recv.read_short(); break; case 3: case 4: case 7: case 8: case 9: - case 14: - fragment.type = Movement::NONE; + case 11: + // A point and a foothold with no speed: a teleport (4), a seat + // (11) and the like. + fragment.type = fragment.command == 11 ? Movement::CHAIR : Movement::ABSOLUTE; + x = recv.read_short(); + y = recv.read_short(); + fragment.fh = recv.read_short(); break; case 10: + // An equip or stat change: one byte, with no stance or time. + fragment.type = Movement::NONE; + recv.skip(1); + break; + case 14: + fragment.type = Movement::RELATIVE; + fragment.vx = recv.read_short(); + fragment.vy = recv.read_short(); + recv.skip(2); + break; + default: fragment.type = Movement::NONE; - // Change equip break; } + fragment.xpos = x; + fragment.ypos = y; + + if (fragment.command != 10) + { + stance = recv.read_byte(); + fragment.duration = recv.read_short(); + } + + fragment.newstate = stance; movements.push_back(fragment); } return movements; } -} \ No newline at end of file +} diff --git a/HeavenClient/Net/Handlers/Helpers/MovementParser.h b/HeavenClient/Net/Handlers/Helpers/MovementParser.h index cc3f37a45..f1ba55329 100644 --- a/HeavenClient/Net/Handlers/Helpers/MovementParser.h +++ b/HeavenClient/Net/Handlers/Helpers/MovementParser.h @@ -26,6 +26,9 @@ namespace ms { namespace MovementParser { - std::vector parse_movements(InPacket& recv); + // A path's elements after its start position, as v83 reads them + // (FUN_0068a33c). Elements that carry no position keep the one + // before, starting from `start`. + std::vector parse_movements(InPacket& recv, Point start); } } \ No newline at end of file diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 49cb6d8b3..c9f950bc6 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -155,8 +155,8 @@ namespace ms void CharMovedHandler::handle(InPacket& recv) const { int32_t cid = recv.read_int(); - recv.skip(4); - std::vector movements = MovementParser::parse_movements(recv); + Point start = recv.read_point(); + std::vector movements = MovementParser::parse_movements(recv, start); Stage::get().get_chars().send_movement(cid, movements); } @@ -341,7 +341,7 @@ namespace ms recv.read_byte(); // skill 4 Point position = recv.read_point(); - std::vector movements = MovementParser::parse_movements(recv); + std::vector movements = MovementParser::parse_movements(recv, position); Stage::get().get_mobs().send_movement(oid, position, std::move(movements)); } @@ -393,6 +393,11 @@ namespace ms ); } + void RemoveNpcHandler::handle(InPacket& recv) const + { + Stage::get().get_npcs().remove(recv.read_int()); + } + void SpawnNpcControllerHandler::handle(InPacket& recv) const { int8_t mode = recv.read_byte(); @@ -400,7 +405,7 @@ namespace ms if (mode == 0) { - Stage::get().get_npcs().remove(oid); + Stage::get().get_npcs().control(oid, false); } else { @@ -413,12 +418,24 @@ namespace ms recv.read_short(); // 'rx1' recv.read_bool(); // 'minimap' + // Spawned already, the NPC only changes hands. Stage::get().get_npcs().spawn( { oid, id, position, flip, fh } ); + Stage::get().get_npcs().control(oid, true); } } + void NpcActionHandler::handle(InPacket& recv) const + { + int32_t oid = recv.read_int(); + int8_t action = recv.read_byte(); + int8_t line = recv.read_byte(); + + // A path follows for an NPC that walks; none does in v83. + Stage::get().get_npcs().act(oid, action, line); + } + void DropLootHandler::handle(InPacket& recv) const { int8_t mode = recv.read_byte(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 1a9f0c65d..baae31e06 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -142,13 +142,28 @@ namespace ms void handle(InPacket& recv) const override; }; - // Spawn an NPC on the current stage and take control of it + // Remove an NPC from the stage (FUN_006d9a25) + // Opcode: REMOVE_NPC(258) + class RemoveNpcHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Take control of an NPC, spawning it if need be, or give it up + // (FUN_006d9a83). The controller picks the NPC's idle actions. // Opcode: SPAWN_NPC_C(259) class SpawnNpcControllerHandler : public PacketHandler { void handle(InPacket& recv) const override; }; + // Play the idle action the NPC's controller chose (FUN_006d2e07) + // Opcode: NPC_ACTION(260) + class NpcActionHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Drop an item on the stage // Opcode: DROP_LOOT(268) class DropLootHandler : public PacketHandler diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index d68075f33..9ef70b2b5 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -119,6 +119,7 @@ namespace ms BUDDYLIST_MODIFY = 130, MOVE_MONSTER = 188, MOB_DISTANCE = 189, + NPC_ACTION = 197, PICKUP_ITEM = 202, DAMAGE_REACTOR = 205, PLAYER_MAP_TRANSFER = 207, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index d5f7a43a3..90e40ea80 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -132,7 +132,9 @@ namespace ms SHOW_MOB_HP = 250, SHOW_MAGNET = 251, SPAWN_NPC = 257, + REMOVE_NPC = 258, SPAWN_NPC_C = 259, + NPC_ACTION = 260, DROP_LOOT = 268, REMOVE_LOOT = 269, HIT_REACTOR = 277, @@ -191,7 +193,9 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 1b23e7e57..364112e80 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -53,8 +53,8 @@ namespace ms write_int(attack.charge); write_byte(attack.display); - write_byte(attack.toleft); - write_byte(attack.stance); + // The action, with the facing in the top bit (0x952974). + write_short(static_cast((attack.stance & 0x7FFF) | (attack.toleft ? 0x8000 : 0))); skip(1); diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 80a562686..a958ce4be 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -24,6 +24,8 @@ #include "MovementPacket.h" +#include "../../Gameplay/MovePath.h" + #include "../../Character/Inventory/InventoryType.h" namespace ms @@ -215,12 +217,12 @@ namespace ms class MovePlayerPacket : public MovementPacket { public: - // Updates the player's position with the server - MovePlayerPacket(const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_PLAYER) + // Updates the player's position with the server: the field key and + // crc (left 0), then the path. + MovePlayerPacket(const move_path::Path& path) : MovementPacket(OutPacket::Opcode::MOVE_PLAYER) { - skip(9); - write_byte(1); - writemovement(movement); + skip(5); + writepath(path.start, path.elements); } }; @@ -416,8 +418,8 @@ namespace ms // 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) + // flyer fills in but others leave at 0xFFDDCC, then the path. + MoveMobPacket(int32_t oid, int16_t moveid, int8_t flags, int8_t action, bool left, int32_t skilldata, bool flying, const move_path::Path& path) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) { write_int(oid); write_short(moveid); @@ -431,10 +433,7 @@ namespace ms write_int(unknown); write_int(unknown); - write_point(startpos); - - write_byte(1); - writemovement(movement); + writepath(path.start, path.elements); } }; diff --git a/HeavenClient/Net/Packets/MovementPacket.h b/HeavenClient/Net/Packets/MovementPacket.h index 3a18372a6..17d16c583 100644 --- a/HeavenClient/Net/Packets/MovementPacket.h +++ b/HeavenClient/Net/Packets/MovementPacket.h @@ -21,6 +21,9 @@ #include "../Gameplay/Movement.h" +#include +#include + namespace ms { // Base class for packets which update object movements with the server. @@ -30,14 +33,118 @@ namespace ms MovementPacket(OutPacket::Opcode opc) : OutPacket(opc) {} protected: + // A path as v83 writes it (FUN_0068a563): where it starts, its + // elements, the keys held (none here), and the rectangle around the + // start and every element with a position. + void writepath(Point start, const std::vector& elements) + { + write_point(start); + write_byte(static_cast(elements.size())); + + int16_t left = start.x(); + int16_t top = start.y(); + int16_t right = start.x(); + int16_t bottom = start.y(); + + for (const Movement& element : elements) + { + writemovement(element); + + if (positioned(element.command)) + { + left = std::min(left, element.xpos); + top = std::min(top, element.ypos); + right = std::max(right, element.xpos); + bottom = std::max(bottom, element.ypos); + } + } + + write_byte(0); + write_short(left); + write_short(top); + write_short(right); + write_short(bottom); + } + + static bool positioned(uint8_t command) + { + switch (command) + { + case 0: + case 3: + case 4: + case 5: + case 7: + case 8: + case 9: + case 11: + case 15: + case 17: + return true; + default: + return false; + } + } + + // One path element, laid out by its type as v83 writes it + // (FUN_0068a563). void writemovement(const Movement& movement) { - write_byte(movement.command); - write_short(movement.xpos); - write_short(movement.ypos); - write_short(movement.lastx); - write_short(movement.lasty); - write_short(movement.fh); + uint8_t command = movement.command; + write_byte(command); + + switch (command) + { + case 0: + case 5: + case 15: + case 17: + write_short(movement.xpos); + write_short(movement.ypos); + write_short(movement.vx); + write_short(movement.vy); + write_short(movement.fh); + + // The foothold it fell from, which HC doesn't keep. + if (command == 15) + write_short(0); + + break; + case 1: + case 2: + case 6: + case 12: + case 13: + case 16: + case 18: + case 19: + case 20: + case 22: + write_short(movement.vx); + write_short(movement.vy); + break; + case 3: + case 4: + case 7: + case 8: + case 9: + case 11: + write_short(movement.xpos); + write_short(movement.ypos); + write_short(movement.fh); + break; + case 10: + write_byte(movement.newstate); + return; + case 14: + write_short(movement.vx); + write_short(movement.vy); + write_short(0); + break; + default: + break; + } + write_byte(movement.newstate); write_short(movement.duration); } diff --git a/HeavenClient/Net/Packets/NpcInteractionPackets.h b/HeavenClient/Net/Packets/NpcInteractionPackets.h index 816c78f60..ae849aff8 100644 --- a/HeavenClient/Net/Packets/NpcInteractionPackets.h +++ b/HeavenClient/Net/Packets/NpcInteractionPackets.h @@ -36,6 +36,21 @@ namespace ms } }; + // The idle action the NPCs' controller chose for one (FUN_006d3273): the + // action as an animation index from 2, or -1 for a line alone, then the + // line, -1 for none. The server shows it to everyone. + // Opcode: NPC_ACTION(197) + class NpcActionPacket : public OutPacket + { + public: + NpcActionPacket(int32_t oid, int8_t action, int8_t line) : OutPacket(OutPacket::Opcode::NPC_ACTION) + { + write_int(oid); + write_byte(action); + write_byte(line); + } + }; + // Packet which sends a response to an NPC dialog to the server // Opcode: NPC_TALK_MORE(60) class NpcTalkMorePacket : public OutPacket diff --git a/HeavenClient/tests/movement_path.cpp b/HeavenClient/tests/movement_path.cpp new file mode 100644 index 000000000..d9881b47c --- /dev/null +++ b/HeavenClient/tests/movement_path.cpp @@ -0,0 +1,254 @@ +#include "../Gameplay/MovePath.h" +#include "../Net/Handlers/Helpers/MovementParser.h" +#include +#include +#include +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +ms::Movement at(int16_t x, int16_t y, int16_t vx, int16_t vy, int16_t fh, uint8_t stance = 2) +{ + return ms::Movement(ms::Movement::ABSOLUTE, 0, x, y, vx, vy, fh, stance, 0); +} + +// Steps of 8 ms, as HC records them, moving `vx` px/s from `x` each step. +int16_t walk(ms::move_path::Recorder& path, int16_t x, int16_t vx, int16_t fh, int steps, int16_t vy = 0) +{ + for (int i = 0; i < steps; i++) + { + x += vx / 125; + path.step(at(x, 0, vx, vy, fh), 8); + } + + return x; +} + +// What the user sends after a step. +std::optional send(ms::move_path::Recorder& path) +{ + if (!path.ready()) + return std::nullopt; + + ms::move_path::Path sent = path.flush(); + + if (sent.idle) + return std::nullopt; + + return sent; +} + +void recorder() +{ + using namespace ms::move_path; + + Recorder path; + path.reset(at(0, 0, 0, 0, 7)); + + int16_t x = walk(path, 0, 125, 7, 124); + require(!send(path), "nothing goes out before a second"); + + x = walk(path, x, 125, 7, 1); + auto sent = send(path); + require(sent && sent->start == ms::Point(0, 0), "a second of walking goes out from where it began"); + require(sent->elements.size() == 1 && sent->elements[0].duration == 1000 && sent->elements[0].xpos == x, + "a walk in one direction is one element"); + + walk(path, x, 0, 7, 125); + sent = send(path); + require(sent && sent->start == ms::Point(x, 0) && sent->elements.size() == 1, + "the next path starts where the last one ended"); + walk(path, x, 0, 7, 125); + require(!send(path), "standing still sends nothing"); + + x = walk(path, x, 125, 7, 60); + x = walk(path, x, -125, 7, 10); + x = walk(path, x, 125, 0, 40, -500); + x = walk(path, x, 125, 0, 40, 500); + sent = send(path); + require(sent && sent->elements.size() == 2 && sent->elements[1].vx < 0, + "turning around starts an element, and a jump waits"); + require(!send(path), "the jump stays for the next path"); + + int16_t before = sent->elements[1].xpos; + x = walk(path, x, 125, 7, 45); + sent = send(path); + require(sent && sent->start == ms::Point(before, 0) && sent->elements.size() == 3, + "the jump goes out once the player lands"); + require(sent->elements[0].fh == 0 && sent->elements[0].vy < 0 && sent->elements[1].vy > 0, + "rising and falling are separate elements"); + + path.add(ms::Movement(ms::Movement::ABSOLUTE, 4, x, 0, 0, 0, 7, 2, 5)); + walk(path, 200, 0, 7, 125); + sent = send(path); + require(sent && sent->elements.size() == 2 && sent->elements[0].command == 4 && + sent->elements[0].duration == 0 && sent->elements[0].xpos == x && sent->elements[1].xpos == 200, + "a teleport marks where it left from"); + + walk(path, 200, 0, -3, 200); + require(!send(path), "a climb waits for a foothold"); +} + +void mob() +{ + using namespace ms::move_path; + + Recorder path; + path.reset(at(0, 0, 0, 0, 7)); + + // A walker in the air the whole time sends it all. + walk(path, 0, 125, 0, 125, 500); + require(path.ready(true) && !path.ready(), "a walker waits for a foothold, a flier doesn't"); + Path sent = path.flush(); + require(sent.elements.size() == 1 && !path.ready(true), "with nothing on a foothold everything goes"); + + // A flier sends what is in the air after its last foothold. + int16_t x = walk(path, 0, 125, 7, 60); + walk(path, x, 125, 0, 65); + sent = path.flush(true); + require(sent.elements.size() == 2 && sent.elements[1].fh == 0, "a flier holds nothing back"); + + // Standing still still goes out, marked idle. + walk(path, 0, 0, 7, 125); + path.flush(); + walk(path, 0, 0, 7, 125); + require(path.ready() && path.flush().idle, "a path the same as the last is idle"); + + Player shown({ 0, 0, 0, 0, 7, 2 }); + ms::Movement rise = at(100, -50, 100, -500, 0, 6); + rise.duration = 500; + ms::Movement land = at(150, 0, 100, 0, 7, 2); + land.duration = 500; + ms::Movement on = at(250, 0, 100, 0, 7, 2); + on.duration = 1000; + shown.receive({ rise, land, on }); + shown.step(100); + shown.land(false); + require(shown.end().x == 150 && shown.end().fh == 7, "landing stops at the first foothold ahead"); + shown.step(900); + require(!shown.playing() && shown.get_state().x == 150, "and plays only up to it"); + + shown.receive({ rise }); + shown.step(100); + ms::move_path::Player::State now = shown.get_state(); + shown.land(false); + require(!shown.playing() && shown.end().x == now.x, "with no foothold ahead it stops where it is"); +} + +void player() +{ + using namespace ms::move_path; + + Player path({ 0, 0, 0, 0, 7, 2 }); + ms::Movement first = at(100, 0, 100, 0, 7, 3); + first.duration = 1000; + path.receive({ first }); + path.receive({}); + require(path.playing(), "a path plays at once"); + + path.step(500); + require(std::abs(path.get_state().x - 37.5) < 1e-9 && path.get_state().stance == 3, + "it follows a curve between the two speeds"); + + path.step(500); + require(!path.playing() && path.get_state().x == 100 && path.get_state().vx == 100, + "it ends where the path does"); + + ms::Movement skip = at(0, 0, 0, 0, 0); + skip.command = 10; + ms::Movement walk = at(300, 0, 0, 0, 7, 4); + walk.duration = 1200; + path.receive({ skip, walk }); + path.step(100); + // 110 ms into 1200, leaving 100 at 100 px/s for 300. + require(std::abs(path.get_state().x - 113.8093287) < 1e-6, "a path of 1.1 seconds or more plays a tenth faster"); + + walk.duration = 7000; + walk.xpos = -50; + path.receive({ walk }); + path.step(8); + require(!path.playing() && path.get_state().x == -50, "one too far behind jumps to its end"); +} + +struct Writer +{ + std::vector bytes; + + template + Writer& put(T value) + { + int8_t raw[sizeof(T)]; + std::memcpy(raw, &value, sizeof(T)); + bytes.insert(bytes.end(), raw, raw + sizeof(T)); + return *this; + } + + Writer& end(uint8_t stance, int16_t duration) + { + return put(stance).put(duration); + } +}; + +int main() +{ + try + { + using namespace ms; + + Writer w; + w.put(7); + // Walk: position, velocity, foothold. + w.put(0).put(100).put(-20).put(125).put(0).put(7).end(2, 90); + // Jump: velocity only, at the walk's position. + w.put(1).put(125).put(-555).end(6, 300); + // Type 10: one byte and nothing else. + w.put(10).put(3); + // Down jump: foothold 9, fallen from 8. + w.put(15).put(110).put(40).put(0).put(0).put(9).put(8).end(7, 120); + // Teleport: position and foothold. + w.put(4).put(300).put(40).put(11).end(2, 0); + // Velocity and the foothold fallen from. + w.put(14).put(-60).put(0).put(11).end(3, 40); + // A type it doesn't know: stance and duration only. + w.put(21).end(5, 16); + w.put(42); + + InPacket recv(w.bytes.data(), w.bytes.size()); + auto moves = MovementParser::parse_movements(recv, { -5, -6 }); + + require(moves.size() == 7, "every element is read"); + require(recv.available() && recv.read_byte() == 42 && !recv.available(), "the path ends where it should"); + require(moves[0].type == Movement::ABSOLUTE && moves[0].xpos == 100 && moves[0].ypos == -20 && moves[0].vx == 125 && moves[0].fh == 7, "a walk has a position, velocity and foothold"); + require(moves[0].newstate == 2 && moves[0].duration == 90, "elements end with a stance and duration"); + require(moves[1].type == Movement::RELATIVE && moves[1].xpos == 100 && moves[1].ypos == -20 && moves[1].vy == -555, "a jump keeps the position before it"); + require(moves[2].type == Movement::NONE && moves[2].newstate == 6 && moves[2].xpos == 100, "type 10 keeps the stance before it"); + require(moves[3].type == Movement::JUMPDOWN && moves[3].fh == 9 && moves[3].newstate == 7, "a down jump lands on its first foothold"); + require(moves[4].type == Movement::ABSOLUTE && moves[4].xpos == 300 && moves[4].fh == 11, "a teleport has a position and foothold"); + require(moves[5].type == Movement::RELATIVE && moves[5].xpos == 300 && moves[5].vx == -60, "type 14 is a velocity"); + require(moves[6].type == Movement::NONE && moves[6].newstate == 5 && moves[6].duration == 16, "an unknown type still has a stance"); + + Writer empty; + empty.put(0); + InPacket none(empty.bytes.data(), empty.bytes.size()); + require(MovementParser::parse_movements(none, {}).empty(), "a path may have no elements"); + + require(Movement::velocity(1.0) == 125 && Movement::velocity(-4.44) == -555, "speeds are per second"); + + recorder(); + player(); + mob(); + + std::cout << "Movement path passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index ca8d98ed2..ff89cbd1d 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -52,9 +52,12 @@ fn kind_attack( p.write_byte(0x11).unwrap(); p.write_int(skill_id).unwrap(); p.write_bytes(&[0; 8]).unwrap(); - for b in [0u8, 0x80, 5, 0, 4] { - p.write_byte(b).unwrap(); - } + // Flags, then action 5 facing left (v83 `action | left << 15`), then + // a byte and the attack speed. + p.write_byte(0).unwrap(); + p.write_short(0x8005u16 as i16).unwrap(); + p.write_byte(0).unwrap(); + p.write_byte(4).unwrap(); if ranged { p.write_int(0).unwrap(); p.write_short(ammo_slot).unwrap(); diff --git a/net/src/combat.rs b/net/src/combat.rs index 133af5b9c..4fa07aa96 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -66,8 +66,9 @@ pub struct Attack { pub mob_count: u8, pub hit_count: u8, pub display: u8, - pub direction: u8, - pub stance: u8, + /// The action, with the facing in the top bit (v83 0x952974: `action & + /// 0x7fff | left << 15`). + pub action: u16, pub speed: u8, /// How long a held skill was charged, in ms; 0 for the rest. pub charge: i32, @@ -101,8 +102,7 @@ pub fn parse_attack(packet: &Packet, kind: AttackKind) -> Result Result, }, NpcInteraction(NpcInteraction), @@ -328,6 +330,9 @@ pub enum FieldRequest { }, /// How far the player is from a firstAttack mob. MobDistance { object_id: i32, distance: i32 }, + /// The NPCs' controller chose an idle action for one: what followed the + /// object id (action, line, and a path for an NPC that walks). + NpcAction { object_id: i32, action: Vec }, /// A validated attack: `broadcast` for the others, total damage per mob. Attack { broadcast: Packet, @@ -441,6 +446,7 @@ fn world_routes() -> &'static HashMap { (Op::UseItem, InGame(&world::UseItemHandler)), (Op::MoveLife, InGame(&world::MoveLifeHandler)), (Op::MobDistance, InGame(&world::MobDistanceHandler)), + (Op::NpcAction, InGame(&world::NpcActionHandler)), ( Op::CloseRangeAttack, InGame(&world::AttackHandler(AttackKind::Close)), @@ -495,7 +501,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 46); + assert_eq!(world_routes().len(), 47); } #[test] diff --git a/net/src/packet/build/mod.rs b/net/src/packet/build/mod.rs index 140ddae7b..6de1a7508 100644 --- a/net/src/packet/build/mod.rs +++ b/net/src/packet/build/mod.rs @@ -12,7 +12,7 @@ mod tests { /// uses. Builder tests compare against the enum, so this pins the enum. #[test] fn send_opcodes_match_v83_reference() { - let table: [(SendOpcode, i16); 26] = [ + let table: [(SendOpcode, i16); 28] = [ (SendOpcode::LoginStatus, 0x00), (SendOpcode::GuestIdLogin, 0x01), (SendOpcode::ServerStatus, 0x03), @@ -35,6 +35,8 @@ mod tests { (SendOpcode::ChatText, 0xA2), (SendOpcode::MovePlayer, 0xB9), (SendOpcode::SpawnNpc, 0x101), + (SendOpcode::SpawnNpcRequestController, 0x103), + (SendOpcode::NpcAction, 0x104), (SendOpcode::NpcDialogue, 0x130), (SendOpcode::QueryCashResult, 0x144), (SendOpcode::CashShopOperation, 0x145), diff --git a/net/src/packet/build/world/field.rs b/net/src/packet/build/world/field.rs index d36fb499d..dd60eca87 100644 --- a/net/src/packet/build/world/field.rs +++ b/net/src/packet/build/world/field.rs @@ -94,11 +94,17 @@ pub fn build_player_leave_field(character_id: i32) -> Result Result { +/// MOVE_PLAYER: relay a player's movement path, which starts at `start`. +pub fn build_player_move( + character_id: i32, + start: (i16, i16), + movement_bytes: &[u8], +) -> Result { let mut packet = Packet::new_empty(); packet.write_short(SendOpcode::MovePlayer as i16)?; packet.write_int(character_id)?; - packet.write_int(0)?; + packet.write_short(start.0)?; + packet.write_short(start.1)?; packet.write_bytes(movement_bytes)?; Ok(packet) } @@ -154,8 +160,12 @@ pub fn parse_movement_state( } /// Like `parse_movement_state`, plus the foothold the mover ended up on: -/// that of the last absolute or jump-down fragment (0 means airborne), if -/// any fragment had one. +/// that of the last element with a position (0 means airborne), if any. +/// +/// The elements follow a path's start position (`current`'s x and y), as +/// v83 reads them (FUN_0068a33c): each is a type byte and its fields, then a +/// stance byte and a duration short, except type 10, which has neither. +/// Elements that carry only a velocity keep the position before them. pub fn parse_movement( movement_bytes: &[u8], current: (i16, i16, u8), @@ -172,41 +182,43 @@ pub fn parse_movement( for _ in 0..count { let command = cursor.read_byte().ok()?; match command { - 0 | 5 | 17 => { + // Position, velocity, foothold (and for 15, the foothold it + // fell from). + 0 | 5 | 15 | 17 => { x = cursor.read_short().ok()?; y = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; + cursor.read_short().ok()?; + cursor.read_short().ok()?; foothold = Some(cursor.read_short().ok()?); - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; + if command == 15 { + cursor.read_short().ok()?; + } } - 1 | 2 | 6 | 12 | 13 | 16 => { - x = cursor.read_short().ok()?; - y = cursor.read_short().ok()?; - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; + // Velocity. + 1 | 2 | 6 | 12 | 13 | 16 | 18 | 19 | 20 | 22 => { + cursor.read_short().ok()?; + cursor.read_short().ok()?; } - 11 => { + // Position and foothold. + 3 | 4 | 7 | 8 | 9 | 11 => { x = cursor.read_short().ok()?; y = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; - } - 15 => { - x = cursor.read_short().ok()?; - y = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; foothold = Some(cursor.read_short().ok()?); - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; } - 3 | 4 | 7 | 8 | 9 | 10 | 14 => {} - _ => return None, + // Velocity and the foothold fallen from. + 14 => { + cursor.read_short().ok()?; + cursor.read_short().ok()?; + cursor.read_short().ok()?; + } + 10 => { + cursor.read_byte().ok()?; + continue; + } + _ => {} } + stance = cursor.read_byte().ok()?; + cursor.read_short().ok()?; } Some((x, y, stance, foothold)) @@ -317,15 +329,48 @@ mod tests { #[test] fn player_move_rebroadcasts_the_movement_bytes() { let movement = [1u8, 0, 0x10, 0, 0x20, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0x64, 0]; - let packet = build_player_move(0x0102_0304, &movement).unwrap(); + let packet = build_player_move(0x0102_0304, (-40, 300), &movement).unwrap(); let mut expect = Expect::new(&packet.bytes); expect.short(SendOpcode::MovePlayer as i16); - expect.int(0x0102_0304).int(0); + expect.int(0x0102_0304).short(-40).short(300); expect.bytes(&movement); expect.end(); } + #[test] + fn movement_reads_each_element_type() { + let movement = [ + 6u8, // + // A walk: position (16, 32), velocity, foothold 7. + 0, 0x10, 0, 0x20, 0, 0x05, 0, 0, 0, 7, 0, 4, 0x64, 0, // + // A jump: velocity only, so the position stays. + 1, 0xF0, 0xFF, 0x9C, 0xFF, 6, 0x5A, 0, // + // Type 10: one byte, no stance or duration. + 10, 3, // + // A down jump: foothold 9, fallen from 8. + 15, 0x30, 0, 0x40, 0, 0, 0, 0, 0, 9, 0, 8, 0, 7, 0x10, 0, // + // A teleport: position and foothold 11. + 4, 0x50, 0, 0x60, 0, 11, 0, 2, 0, 0, // + // An unknown type: stance and duration only. + 21, 5, 0x10, 0, + ]; + assert_eq!( + parse_movement(&movement, (1, 2, 3)), + Some((0x50, 0x60, 5, Some(11))) + ); + + // Velocity-only elements keep the start and its stance until theirs. + let jump = [ + 2u8, 1, 0, 0, 0, 0, 6, 0x5A, 0, 14, 0, 0, 0, 0, 3, 0, 7, 0x10, 0, + ]; + assert_eq!( + parse_movement(&jump, (100, -50, 2)), + Some((100, -50, 7, None)) + ); + assert_eq!(parse_movement(&jump[..jump.len() - 1], (100, -50, 2)), None); + } + #[test] fn local_chat_matches_reference() { let packet = build_local_chat(0x0102_0304, "hi all", false, 1).unwrap(); diff --git a/net/src/packet/build/world/npc.rs b/net/src/packet/build/world/npc.rs index f0a0476c2..019126050 100644 --- a/net/src/packet/build/world/npc.rs +++ b/net/src/packet/build/world/npc.rs @@ -48,6 +48,31 @@ pub struct ForeignNpc { pub fn build_spawn_npc(npc: &ForeignNpc) -> Result { let mut packet = Packet::new_empty(); packet.write_short(SendOpcode::SpawnNpc as i16)?; + write_npc(&mut packet, npc)?; + Ok(packet) +} + +/// The client now picks the NPC's idle actions: mode 1, then the NPC as +/// SPAWN_NPC writes it, for a client that hasn't seen it yet. +pub fn build_control_npc(npc: &ForeignNpc) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::SpawnNpcRequestController as i16)?; + packet.write_byte(1)?; + write_npc(&mut packet, npc)?; + Ok(packet) +} + +/// NPC_ACTION: everyone, the controller too, plays the action and line it +/// chose. `action` is what followed the object id in its request. +pub fn build_npc_action(object_id: i32, action: &[u8]) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::NpcAction as i16)?; + packet.write_int(object_id)?; + packet.write_bytes(action)?; + Ok(packet) +} + +fn write_npc(packet: &mut Packet, npc: &ForeignNpc) -> Result<(), NetworkError> { packet.write_int(npc.object_id)?; packet.write_int(npc.npc_id)?; packet.write_short(npc.x)?; @@ -57,7 +82,7 @@ pub fn build_spawn_npc(npc: &ForeignNpc) -> Result { packet.write_short(npc.rx0)?; packet.write_short(npc.rx1)?; packet.write_byte(1)?; // show on the minimap - Ok(packet) + Ok(()) } #[cfg(test)] @@ -107,6 +132,28 @@ mod tests { expect.end(); } + #[test] + fn control_npc_is_mode_one_and_the_spawn() { + let packet = build_control_npc(&foreign_npc(true)).unwrap(); + + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::SpawnNpcRequestController as i16); + expect.byte(1).int(1_000_000_001).int(2100); + expect.short(1200).short(-175).byte(1); + expect.short(32).short(1150).short(1250).byte(1); + expect.end(); + } + + #[test] + fn npc_action_relays_the_choice() { + let packet = build_npc_action(1_000_000_001, &[3, 0xff]).unwrap(); + + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::NpcAction as i16); + expect.int(1_000_000_001).byte(3).byte(0xff); + expect.end(); + } + fn page(has_previous: bool, has_next: bool) -> DialoguePage { DialoguePage { npc_id: 9010000, diff --git a/net/src/packet/handle/world/combat.rs b/net/src/packet/handle/world/combat.rs index 169fba5ab..bf9f8c1f3 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -57,6 +57,29 @@ impl WorldHandler for MobDistanceHandler { } } +/// `NPC_ACTION`: the NPCs' controller chose an idle action. Object id, the +/// action (an animation index from 2, or -1 for a line alone), the line +/// (-1 for none), then a path for an NPC that walks. +pub struct NpcActionHandler; + +impl WorldHandler for NpcActionHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let object_id = r.read_int()?; + let action = packet.bytes[r.position() as usize..].to_vec(); + if action.len() < 2 { + return Ok(HandlerResult::empty()); + } + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::NpcAction { + object_id, + action, + })), + ) + } +} + /// `CLOSE_RANGE_ATTACK`, `RANGED_ATTACK`, and `MAGIC_ATTACK`: a swing, a /// shot, or a spell. pub struct AttackHandler(pub AttackKind); diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 628d61c3a..5bdff5162 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -36,7 +36,9 @@ pub use self::change_channel::ChangeChannelHandler; pub use self::change_map::{warp, ChangeMapHandler}; pub use self::char_info::CharInfoRequestHandler; pub use self::chat::AllChatHandler; -pub use self::combat::{AttackHandler, MobDistanceHandler, MoveLifeHandler, TakeDamageHandler}; +pub use self::combat::{ + AttackHandler, MobDistanceHandler, MoveLifeHandler, NpcActionHandler, TakeDamageHandler, +}; pub use self::emote::FaceExpressionHandler; pub use self::item_move::ItemMoveHandler; pub use self::keybinds::ChangeKeybindsHandler; diff --git a/net/src/packet/handle/world/move_player.rs b/net/src/packet/handle/world/move_player.rs index a9f60a169..051ec1295 100644 --- a/net/src/packet/handle/world/move_player.rs +++ b/net/src/packet/handle/world/move_player.rs @@ -2,6 +2,9 @@ use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, Wor use crate::packet::build::world::field::build_player_move; use packet::Packet; +/// Field key byte and CRC int before the path. +const PATH_OFFSET: usize = 2 + 5; +/// The path's start position before its elements. const MOVEMENT_HEADER_LEN: usize = 9; pub struct PlayerMoveHandler; @@ -17,10 +20,16 @@ impl WorldHandler for PlayerMoveHandler { return Ok(HandlerResult::empty()); } + let path = &packet.bytes[PATH_OFFSET..]; + let start = ( + i16::from_le_bytes([path[0], path[1]]), + i16::from_le_bytes([path[2], path[3]]), + ); let movement_bytes = movement_fragment.to_vec(); - let movement_packet = build_player_move(ctx.client_id, &movement_bytes)?; + let movement_packet = build_player_move(ctx.client_id, start, &movement_bytes)?; Ok(HandlerResult::empty().with(WorldAction::FieldMove { packet: movement_packet, + start, movement_bytes, })) } diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index e9efef25a..53ea184cf 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -68,6 +68,7 @@ pub enum RecvOpcode { ChangeKeybinds = 0x87, MoveLife = 0xBC, MobDistance = 0xBD, + NpcAction = 0xC5, ItemPickup = 0xCA, DamageReactor = 0xCD, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index c8da2f86b..988660874 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -65,6 +65,8 @@ pub enum SendOpcode { ThrowGrenade = 0xCC, CancelChair = 0xCD, SpawnNpc = 0x101, + SpawnNpcRequestController = 0x103, + NpcAction = 0x104, DropItemFromMapObject = 0x10C, RemoveItemFromMap = 0x10D, HitReactor = 0x115, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 76ff97bbd..c7b8c35cc 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -271,18 +271,21 @@ impl ClientActor { } WorldAction::FieldMove { packet, + start, movement_bytes, } => { self.last_moved = Some(tokio::time::Instant::now()); if let Some(game) = self.game.as_mut() { - let (x, y) = game.position; - if let Some((x, y, _)) = parse_movement_state(&movement_bytes, (x, y, 0)) { + if let Some((x, y, _)) = + parse_movement_state(&movement_bytes, (start.0, start.1, 0)) + { game.position = (x, y); } } self.send_to_field(FieldMessage::Move { from: self.client_id, packet, + start, movement_bytes, }) .await? diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index c2af4d38a..ed525b078 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -11,7 +11,9 @@ use net::packet::build::world::mob::{ build_control_mob, build_kill_mob, build_mob_hp, build_move_mob, build_move_mob_response, build_show_magnet, build_spawn_mob, build_stop_controlling_mob, }; -use net::packet::build::world::npc::{build_spawn_npc, ForeignNpc}; +use net::packet::build::world::npc::{ + build_control_npc, build_npc_action, build_spawn_npc, ForeignNpc, +}; use net::packet::build::world::reactor::{ build_hit_reactor, build_remove_reactor, build_spawn_reactor, }; @@ -54,6 +56,8 @@ pub struct FieldActor { rx: mpsc::Receiver, occupants: HashMap, map_npcs: Vec, + /// Who picks the NPCs' idle actions; without one they only stand. + npc_controller: Option, mobs: MobSet, drops: DropSet, reactors: ReactorSet, @@ -77,6 +81,7 @@ impl FieldActor { rx, occupants: HashMap::new(), map_npcs, + npc_controller: None, mobs, drops, reactors, @@ -148,6 +153,31 @@ impl FieldActor { } } + /// Tell `client` it now picks every NPC's idle actions. + fn send_npc_control(&mut self, client: ClientId) { + let Some(occupant) = self.occupants.get(&client) else { + return; + }; + self.npc_controller = Some(client); + for map_npc in &self.map_npcs { + if let Ok(packet) = build_control_npc(&to_foreign_npc(map_npc)) { + occupant.handle.send_packet(packet); + } + } + } + + /// Show everyone the action the NPCs' controller chose. + fn handle_npc_action(&self, from: ClientId, object_id: i32, action: &[u8]) { + if self.npc_controller != Some(from) + || !self.map_npcs.iter().any(|npc| npc.object_id == object_id) + { + return; + } + if let Ok(packet) = build_npc_action(object_id, action) { + self.broadcast(None, packet); + } + } + /// Seat `from` on a portable chair (0 to stand) and show the others. fn handle_chair(&mut self, from: ClientId, item_id: i32) { let Some(occupant) = self.occupants.get_mut(&from) else { @@ -199,12 +229,13 @@ impl FieldActor { FieldMessage::Move { from, packet, + start, movement_bytes, } => { if let Some(occupant) = self.occupants.get_mut(&from) { let avatar = &mut occupant.avatar; if let Some((x, y, stance)) = - parse_movement_state(&movement_bytes, (avatar.x, avatar.y, avatar.stance)) + parse_movement_state(&movement_bytes, (start.0, start.1, avatar.stance)) { avatar.x = x; avatar.y = y; @@ -234,6 +265,9 @@ impl FieldActor { object_id, distance, } => self.handle_mob_distance(from, object_id, distance), + FieldRequest::NpcAction { object_id, action } => { + self.handle_npc_action(from, object_id, &action) + } FieldRequest::Attack { broadcast, hits } => { self.handle_attack(from, broadcast, hits) } @@ -381,7 +415,7 @@ impl FieldActor { start: (i16, i16), movement: &[u8], ) { - if !self.mobs.move_mob(from, object_id, movement) { + if !self.mobs.move_mob(from, object_id, start, movement) { return; } if let Some(occupant) = self.occupants.get(&from) { @@ -607,6 +641,10 @@ impl FieldActor { ); let controlled = self.mobs.assign_uncontrolled(client_id); self.send_control(client_id, &controlled); + // A rejoining controller has forgotten it controls them. + if self.npc_controller.is_none() || self.npc_controller == Some(client_id) { + self.send_npc_control(client_id); + } } fn handle_leave(&mut self, client_id: ClientId) { @@ -618,6 +656,12 @@ impl FieldActor { if let Some(next) = next { self.send_control(next, &handed); } + if self.npc_controller == Some(client_id) { + self.npc_controller = None; + if let Some(next) = next { + self.send_npc_control(next); + } + } match build_player_leave_field(client_id) { Ok(packet) => self.broadcast(None, packet), Err(error) => warn!(client_id, %error, "Failed to build leave packet"), @@ -1046,6 +1090,64 @@ mod tests { assert_eq!(checked(&mut far_rx).await, (9, false)); } + #[tokio::test] + async fn one_player_picks_the_npcs_actions_for_everyone() { + let (tx, rx) = mpsc::channel(16); + let maria = FieldMapEntityNpc { + object_id: 1_000_000_000, + npc_id: 2100, + x: 130, + y: 185, + flip: false, + foothold: 0, + rx0: 80, + rx1: 180, + }; + let actor = FieldActor::new( + FieldKey::new(0, 50000), + rx, + vec![maria], + MobSet::default(), + DropSet::default(), + ReactorSet::default(), + Duration::from_millis(50), + ); + tokio::spawn(actor.run()); + let (first, mut first_rx) = client(1); + let (second, mut second_rx) = client(2); + for (handle, id) in [(first, 1), (second, 2)] { + tx.send(FieldMessage::Join { + handle, + avatar: avatar(id, 100, 185), + party: None, + }) + .await + .unwrap(); + } + let control = SendOpcode::SpawnNpcRequestController as i16; + assert!(opcodes(&mut first_rx).await.contains(&control)); + assert!(!opcodes(&mut second_rx).await.contains(&control)); + + let action = |from| FieldMessage::FromClient { + from, + request: FieldRequest::NpcAction { + object_id: 1_000_000_000, + action: vec![2, 0xff], + }, + }; + tx.send(action(2)).await.unwrap(); + assert!(opcodes(&mut first_rx).await.is_empty()); + tx.send(action(1)).await.unwrap(); + for rx in [&mut first_rx, &mut second_rx] { + let packet = next_packet(rx).await; + assert_eq!(packet.opcode(), SendOpcode::NpcAction as i16); + assert_eq!(&packet.bytes[2..], &[0x00, 0xca, 0x9a, 0x3b, 2, 0xff]); + } + + tx.send(FieldMessage::Leave { client_id: 1 }).await.unwrap(); + assert!(opcodes(&mut second_rx).await.contains(&control)); + } + #[tokio::test] async fn the_target_answers_a_char_info_request() { let field = spawn_field(); diff --git a/runtime/src/actor/field_mobs.rs b/runtime/src/actor/field_mobs.rs index 33c614f9b..2d0ae4af2 100644 --- a/runtime/src/actor/field_mobs.rs +++ b/runtime/src/actor/field_mobs.rs @@ -234,15 +234,23 @@ impl MobSet { Some(previous) } - /// Apply the controller's movement. `false` if `from` doesn't control it. - pub fn move_mob(&mut self, from: ClientId, object_id: i32, movement: &[u8]) -> bool { + /// Apply the controller's movement, a path from `start`. `false` if + /// `from` doesn't control it. + pub fn move_mob( + &mut self, + from: ClientId, + object_id: i32, + start: (i16, i16), + movement: &[u8], + ) -> bool { let Some(mob) = self.live.get_mut(&object_id) else { return false; }; if mob.controller != Some(from) { return false; } - if let Some((x, y, stance, foothold)) = parse_movement(movement, (mob.x, mob.y, mob.stance)) + if let Some((x, y, stance, foothold)) = + parse_movement(movement, (start.0, start.1, mob.stance)) { mob.x = x; mob.y = y; @@ -442,8 +450,8 @@ mod tests { // Only the controller may move it. let movement = [1, 0, 0x96, 0, 0xE7, 0xFF, 0, 0, 0, 0, 0, 0, 2, 0, 0]; - assert!(!mobs.move_mob(2, snail, &movement)); - assert!(mobs.move_mob(1, snail, &movement)); + assert!(!mobs.move_mob(2, snail, (150, -25), &movement)); + assert!(mobs.move_mob(1, snail, (150, -25), &movement)); assert_eq!( (mobs.get(snail).unwrap().x, mobs.get(snail).unwrap().y), (150, -25) @@ -454,12 +462,12 @@ mod tests { 2, 0, 0x96, 0, 0x2D, 0, 0, 0, 0, 0, 52, 0, 2, 100, 0, // 1, 0x0A, 0, 0, 0, 2, 100, 0, ]; - assert!(mobs.move_mob(1, snail, &walk)); + assert!(mobs.move_mob(1, snail, (150, -25), &walk)); let view = mobs.get(snail).unwrap().view(false); assert_eq!((view.foothold, view.home_foothold), (52, 45)); // An absolute fragment without a foothold leaves it airborne. let fall = [1, 0, 0x96, 0, 0x30, 0, 0, 0, 0, 0, 0, 0, 2, 100, 0]; - assert!(mobs.move_mob(1, snail, &fall)); + assert!(mobs.move_mob(1, snail, (150, 45), &fall)); assert_eq!(mobs.get(snail).unwrap().view(false).foothold, 0); assert_eq!(mobs.damage(1, snail, 3, now), Some(Ok(63))); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 21f56a026..c7fba27c4 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -178,6 +178,7 @@ pub enum FieldMessage { Move { from: ClientId, packet: Packet, + start: (i16, i16), movement_bytes: Vec, }, NpcInteraction {