diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 4bbb403ba..c478ef8cb 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -254,6 +254,8 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME parcels COMMAND parcels_tests) add_executable(party_search_tests tests/party_search.cpp Character/PartySearch.cpp) add_test(NAME party_search COMMAND party_search_tests) + add_executable(rps_game_tests tests/rps_game.cpp Character/RpsGame.cpp) + add_test(NAME rps_game COMMAND rps_game_tests) add_executable(diseases_tests tests/diseases.cpp) target_include_directories(diseases_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME diseases COMMAND diseases_tests) @@ -488,6 +490,8 @@ add_executable(HeavenClient Character/Party.h Character/PartySearch.cpp Character/PartySearch.h + Character/RpsGame.cpp + Character/RpsGame.h Character/Pets.h Character/MiniRoomBalloon.cpp Character/MiniRoomBalloon.h @@ -812,6 +816,8 @@ add_executable(HeavenClient IO/UITypes/UIQuestLog.h IO/UITypes/UIQuiz.cpp IO/UITypes/UIQuiz.h + IO/UITypes/UIRpsGame.cpp + IO/UITypes/UIRpsGame.h IO/UITypes/UIFieldClock.cpp IO/UITypes/UIFieldClock.h IO/UITypes/UIAvatarMegaphone.cpp diff --git a/HeavenClient/Character/RpsGame.cpp b/HeavenClient/Character/RpsGame.cpp new file mode 100644 index 000000000..a557dca3b --- /dev/null +++ b/HeavenClient/Character/RpsGame.cpp @@ -0,0 +1,298 @@ +#include "RpsGame.h" + +namespace ms +{ + namespace + { + // The spin's step before the result, its growth after it (0xafde20), and the step it stops from. + constexpr int32_t SPIN = 120; + constexpr double SLOWING = 1.2; + constexpr int32_t SLOWEST = 720; + // The face and sound a second after the result, the next state two after that. + constexpr int32_t FACE_DELAY = 1000; + constexpr int32_t NEXT_DELAY = 3000; + // The Timer ticks in the last ten seconds, each in the first 30 ms of its second. + constexpr int32_t TICKING = 10000; + constexpr int32_t TICK_WINDOW = 30; + constexpr int8_t MOST_WINS = 10; + + constexpr int8_t SMILE = 2; + constexpr int8_t TROUBLED = 3; + constexpr int8_t CRY = 4; + + // StringPool 0xe5c to 0xe62. + constexpr const char* PRESS_START = "Press 'Start' to start the game."; + constexpr const char* SELECT = "Please select Rock, Paper, or Scissors in %d seconds."; + constexpr const char* CHALLENGE = + "To challenge the %d-game Winning Streak Challenge, press 'Continue'. A failed challenge will result in no reward."; + constexpr const char* CONQUERED = "Congratulations on conquering the 10-game Winning Streak Challenge!"; + constexpr const char* TOO_LATE = + "You have lost the game due to being over the time limit. Press 'Restart' to try again, which will cost you the participation fee of 1,000 mesos."; + constexpr const char* CONSOLED = + "Here's 500 mesos as a consolation prize. Press 'Restart' to try again, which will cost you the participation fee of 1,000 mesos."; + constexpr const char* RESTART = + "Press 'Restart' to try again, which will cost you the participation fee of 1,000 mesos."; + + std::string with_number(const char* text, int64_t number) + { + std::string line = text; + size_t at = line.find("%d"); + + return line.replace(at, 2, std::to_string(number)); + } + + // v83 keeps 0 for "unset", so a tick that is 0 becomes 1. + int64_t tick(int64_t at) + { + return at ? at : 1; + } + } + + RpsGame::RpsGame() = default; + + void RpsGame::reset() + { + // 0x74034d: the hands and the choice stay as they were. + interval = 0; + streak = 0; + deadline = 0; + result_tick = 0; + next_tick = 0; + main_button = Main::START; + main_visible = true; + exit_visible = true; + } + + void RpsGame::start_round(int64_t now) + { + // 0x7402e9: the spin goes on from where it stood. + npc_hand = -1; + interval = SPIN; + last_spin = tick(now); + deadline = tick(now + TIME_LIMIT); + last_timer = -1; + hands_visible = true; + } + + void RpsGame::time_over(int64_t now) + { + interval = 0; + npc_hand = -1; + streak = -1; + show_result(now); + } + + void RpsGame::result(int8_t hand, int8_t s) + { + npc_hand = hand; + consolation = s < 0 && streak == 0; + streak = s; + } + + void RpsGame::pressed() + { + main_visible = false; + exit_visible = false; + } + + void RpsGame::chose(int8_t c) + { + deadline = 0; + chosen = c; + hands_visible = false; + } + + RpsGame::Events RpsGame::update(int64_t now) + { + Events events; + + if (interval && now - last_spin > interval) + { + spin_index = (spin_index + 1) % 3; + last_spin = now; + events.reel = true; + + if (npc_hand >= 0) + { + interval = static_cast(interval * SLOWING); + + if (interval >= SLOWEST && spin_index == npc_hand) + { + interval = 0; + show_result(now); + } + } + } + + if (deadline) + { + int64_t left = deadline - now; + + if (left < 0) + { + deadline = 0; + hands_visible = false; + events.time_up = true; + } + else if (left < TICKING && left % 1000 < TICK_WINDOW && left / 1000 != last_timer) + { + last_timer = left / 1000; + events.timer = true; + } + } + + if (result_tick && now - result_tick >= 0) + { + result_tick = 0; + + banner_visible = true; + + if (streak < 0) + { + events.emotion = CRY; + events.jingle = Jingle::LOOSE; + } + else if (draw()) + { + events.emotion = TROUBLED; + events.jingle = Jingle::DRAW; + } + else + { + events.emotion = SMILE; + events.jingle = Jingle::WIN; + } + } + + if (next_tick && now - next_tick >= 0) + { + next_tick = 0; + shown_banner = Banner::NONE; + events.emotion = 0; + + if (draw()) + { + // A draw is played again without a word to the server. + npc_hand = -1; + interval = SPIN; + last_spin = tick(now); + deadline = tick(now + TIME_LIMIT); + last_timer = -1; + hands_visible = true; + } + else + { + main_button = streak >= 1 && streak < MOST_WINS ? Main::CONTINUE : Main::RETRY; + main_visible = true; + exit_visible = true; + } + } + + return events; + } + + RpsGame::Main RpsGame::main() const + { + return main_button; + } + + bool RpsGame::main_shown() const + { + return main_visible; + } + + bool RpsGame::exit_shown() const + { + return exit_visible; + } + + bool RpsGame::hands_shown() const + { + return hands_visible; + } + + RpsGame::Banner RpsGame::banner() const + { + return banner_visible ? shown_banner : Banner::NONE; + } + + int8_t RpsGame::choice() const + { + return chosen; + } + + int8_t RpsGame::spin() const + { + return spin_index; + } + + bool RpsGame::spinning() const + { + return interval != 0; + } + + bool RpsGame::update_status(int64_t now) + { + int32_t key = (main_visible ? 0x100 : 0) | static_cast(main_button); + bool counting = !main_visible && main_button != Main::RETRY; + + if (key == status_key && !counting) + return false; + + status_key = key; + + if (counting) + { + // Without a deadline the line stays as it was, the last second included. + if (deadline) + status_line = with_number(SELECT, (deadline - now + 999) / 1000); + } + else if (main_button == Main::START) + { + status_line = PRESS_START; + } + else if (main_button == Main::CONTINUE) + { + status_line = with_number(CHALLENGE, streak + 1); + } + else if (streak >= MOST_WINS) + { + status_line = CONQUERED; + } + else if (npc_hand < 0) + { + status_line = TOO_LATE; + } + else + { + // Hidden Restart keeps its line through the next game's rounds. + status_line = consolation ? CONSOLED : RESTART; + } + + return true; + } + + const std::string& RpsGame::status() const + { + return status_line; + } + + void RpsGame::show_result(int64_t now) + { + // 0x73eba9. + if (streak < 0) + shown_banner = npc_hand < 0 ? Banner::TIMEOVER : Banner::LOSE; + else + shown_banner = draw() ? Banner::DRAW : Banner::WIN; + + banner_visible = false; + + result_tick = tick(now + FACE_DELAY); + next_tick = tick(now + NEXT_DELAY); + } + + bool RpsGame::draw() const + { + return streak >= 0 && chosen == npc_hand; + } +} diff --git a/HeavenClient/Character/RpsGame.h b/HeavenClient/Character/RpsGame.h new file mode 100644 index 000000000..082bb507f --- /dev/null +++ b/HeavenClient/Character/RpsGame.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include + +namespace ms +{ + // The Rock, Paper, Scissors window's game (v83 0x73d05b, its Update 0x73f140; report b038): what the window + // shows and when, from RPS_GAME and the clock. The server decides every round; the client only spins the + // admin's hand to where it says and reads the streak's sign. Pure state: times are ticks in ms, and each + // update says what to play or send. + class RpsGame + { + public: + // The one button in the middle, swapped in place (0x73fea9). + enum class Main : uint8_t + { + START, + CONTINUE, + RETRY + }; + + // The result shown over the hands (window+0x114..0x120). + enum class Banner : uint8_t + { + NONE, + DRAW, + WIN, + LOSE, + TIMEOVER + }; + + // MiniGame.img's sound a second after the result. + enum class Jingle : uint8_t + { + NONE, + WIN, + DRAW, + LOOSE + }; + + struct Events + { + // The spin moved on: Reel. + bool reel = false; + // A second of the last ten went by: Timer. + bool timer = false; + // The 30 seconds ran out: send RPS_ACTION 2. + bool time_up = false; + Jingle jingle = Jingle::NONE; + // The face to show: 2 smile, 3 troubled, 4 cry, 0 back to normal; -1 to leave it. + int8_t emotion = -1; + }; + + static constexpr int8_t ROCK = 0; + static constexpr int8_t PAPER = 1; + static constexpr int8_t SCISSORS = 2; + static constexpr int32_t TIME_LIMIT = 30000; + + RpsGame(); + + // RPS_GAME 6, 7 and 14: Start again. + void reset(); + // 9 and 12: a round, with the hands to choose from. + void start_round(int64_t now); + // 10: out of time. + void time_over(int64_t now); + // 11: the admin's hand, where the spin is to stop, and the streak, negative on a loss. + void result(int8_t hand, int8_t streak); + + // The middle button or Exit was pressed: both hide until the server answers. + void pressed(); + // A hand was chosen, which is sent. + void chose(int8_t choice); + + Events update(int64_t now); + + Main main() const; + bool main_shown() const; + bool exit_shown() const; + bool hands_shown() const; + Banner banner() const; + // The player's hand drawn (rock before the first choice). + int8_t choice() const; + // The admin's hand drawn: where the spin is. + int8_t spin() const; + // Whether the spin is going. + bool spinning() const; + + // Picks the line under the avatars again if v83 would (0x73f60d): on each change of the middle button or + // of its showing, and every frame while Start or Continue is hidden, which is when it counts down. True if + // it did, which also sends a scrolling line back to its start. + bool update_status(int64_t now); + const std::string& status() const; + + private: + void show_result(int64_t now); + bool draw() const; + + Main main_button = Main::START; + bool main_visible = true; + bool exit_visible = true; + bool hands_visible = false; + Banner shown_banner = Banner::NONE; + // The banner is put up unseen and shows with the face (0x73f09c, 0x73f39d). + bool banner_visible = false; + + int8_t chosen = ROCK; + int8_t npc_hand = -1; + int8_t spin_index = 0; + int64_t last_spin = 0; + int32_t interval = 0; + int64_t deadline = 0; + int64_t last_timer = -1; + int8_t streak = 0; + bool consolation = false; + int64_t result_tick = 0; + int64_t next_tick = 0; + + int32_t status_key = -1; + std::string status_line; + }; +} diff --git a/HeavenClient/IO/UIElement.h b/HeavenClient/IO/UIElement.h index 0c6d09c2c..d5c782466 100644 --- a/HeavenClient/IO/UIElement.h +++ b/HeavenClient/IO/UIElement.h @@ -122,6 +122,7 @@ namespace ms KITEVIEW, PARTYSEARCH, PARTYSEARCHICON, + RPSGAME, NUM_TYPES }; diff --git a/HeavenClient/IO/UITypes/UIRpsGame.cpp b/HeavenClient/IO/UITypes/UIRpsGame.cpp new file mode 100644 index 000000000..a88e1fa50 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIRpsGame.cpp @@ -0,0 +1,280 @@ +#include "UIRpsGame.h" + +#include "UINotice.h" + +#include "../UI.h" + +#include "../Components/MapleButton.h" + +#include "../../Audio/Audio.h" +#include "../../Constants.h" +#include "../../Gameplay/Stage.h" +#include "../../Net/Packets/NpcInteractionPackets.h" +#include "../../Util/Misc.h" +#include "../../Util/NxFiles.h" + +#include + +namespace ms +{ + namespace + { + // Everything is from the window's top left (0x73d05b; every canvas's origin is 0, 0). + const Point MAIN_AT(108, 202); + const Point EXIT_AT(242, 335); + const Point HAND_AT[3] = { { 19, 104 }, { 60, 104 }, { 101, 104 } }; + const Point OWN_HAND_AT(38, 20); + const Point NPC_HAND_AT(190, 20); + const Point BANNER_AT(31, 91); + // Where the avatar's and the admin's feet go; the avatar faces the admin, who is drawn as he is. + const Point AVATAR_AT(52, 261); + const Point NPC_AT(256, 261); + // The status line's 270 px (0x73fb0f): text that fits is centred, wider text starts halfway and scrolls + // left a pixel a frame, coming back in from the right (0x73f630). v83 draws its cap top about 3 below, + // we 9 for 12 px text. + const Point STATUS_AT(20, 294); + constexpr int16_t STATUS_WIDTH = 270; + constexpr int16_t TEXT_RAISE = 6; + // The top 15 px move the window (0x73e3f0). + constexpr int16_t DRAG_HEIGHT = 15; + + // StringPool 0xe64 and 0xe63. + constexpr const char* NO_MESOS = + "You do not have enough mesos to pay for the participation fee of 1,000 mesos."; + constexpr const char* NO_SLOT = + "You may only participate in this game with at least one free slot available in the etc. inventory."; + + Expression::Id expression(int8_t emotion) + { + switch (emotion) + { + case 2: + return Expression::Id::SMILE; + case 3: + return Expression::Id::TROUBLED; + case 4: + return Expression::Id::CRY; + default: + return Expression::Id::DEFAULT; + } + } + } + + UIRpsGame::UIRpsGame(int32_t npc_id) : opened(ContinuousTimer::get().start()), emotion(Expression::Id::DEFAULT), + status_x(0), dragged(false) + { + nl::node src = NxFiles::ui_classic["UIWindow.img"]["RpsGame"]; + Texture backgrnd = src["backgrnd"]; + + sprites.emplace_back(src["backgrnd"]); + dimension = backgrnd.get_dimensions(); + + buttons[Buttons::START] = std::make_unique(src["BtStart"], MAIN_AT); + buttons[Buttons::CONTINUE] = std::make_unique(src["BtContinue"], MAIN_AT); + buttons[Buttons::RETRY] = std::make_unique(src["BtRetry"], MAIN_AT); + buttons[Buttons::EXIT] = std::make_unique(src["BtExit"], EXIT_AT); + buttons[Buttons::ROCK] = std::make_unique(src["BtRock"], HAND_AT[RpsGame::ROCK]); + buttons[Buttons::PAPER] = std::make_unique(src["BtPaper"], HAND_AT[RpsGame::PAPER]); + buttons[Buttons::SCISSORS] = std::make_unique(src["BtScissor"], HAND_AT[RpsGame::SCISSORS]); + + own_hands = { src["Frock"], src["Fpaper"], src["Fscissor"] }; + npc_hands = { src["rock"], src["paper"], src["scissor"] }; + banners = { Texture(), src["draw"], src["win"], src["lose"], src["timeover"] }; + npc = NxHelper::Npc::get_npc_node(npc_id)["stand"]; + + status = Text(Text::Font::A12M, Text::Alignment::LEFT, Color::Name::BLACK); + + // Centred on the screen. + position = Point((Constants::Constants::get().get_viewwidth() - dimension.x()) / 2, + (Constants::Constants::get().get_viewheight() - dimension.y()) / 2); + + show_buttons(); + } + + void UIRpsGame::draw(float alpha) const + { + UIElement::draw_sprites(alpha); + + own_hands[game.choice()].draw(position + OWN_HAND_AT); + npc_hands[game.spin()].draw(position + NPC_HAND_AT); + + Stage::get().get_player().get_look().draw(position + AVATAR_AT, true, Stance::Id::STAND1, emotion); + npc.draw(DrawArgument(position + NPC_AT, false), alpha); + // Over the avatar and the admin (layer z 2 against their 1). + banners[static_cast(game.banner())].draw(position + BANNER_AT); + + Range line(position.x() + STATUS_AT.x(), position.x() + STATUS_AT.x() + STATUS_WIDTH); + status.draw_between(position + STATUS_AT + Point(status_x, -TEXT_RAISE), line); + + UIElement::draw_buttons(alpha); + } + + void UIRpsGame::update() + { + UIElement::update(); + npc.update(); + + play(game.update(now())); + show_buttons(); + + if (game.update_status(now())) + { + status.change_text(game.status()); + int16_t width = status.width(); + status_x = width > STATUS_WIDTH ? STATUS_WIDTH / 2 : (STATUS_WIDTH - width) / 2; + } + else if (status.width() > STATUS_WIDTH && --status_x + status.width() < 0) + { + status_x = STATUS_WIDTH; + } + } + + Cursor::State UIRpsGame::send_cursor(bool clicked, Point cursorpos) + { + if (clicked && dragged) + { + position = cursorpos - drag_from; + + return Cursor::State::CLICKING; + } + + dragged = false; + Cursor::State state = UIElement::send_cursor(clicked, cursorpos); + + if (clicked && !is_holding_button() && is_in_range(cursorpos) && cursorpos.y() - position.y() < DRAG_HEIGHT) + { + drag_from = cursorpos - position; + dragged = true; + } + + return state; + } + + void UIRpsGame::remove_cursor() + { + UIElement::remove_cursor(); + dragged = false; + } + + UIElement::Type UIRpsGame::get_type() const + { + return TYPE; + } + + void UIRpsGame::refused(Refusal refusal) + { + game.reset(); + show_buttons(); + + if (refusal == Refusal::NO_MESOS) + UI::get().emplace(NO_MESOS, [](bool) {}); + else if (refusal == Refusal::NO_SLOT) + UI::get().emplace(NO_SLOT, [](bool) {}); + } + + void UIRpsGame::start_round() + { + game.start_round(now()); + show_buttons(); + } + + void UIRpsGame::time_over() + { + game.time_over(now()); + show_buttons(); + } + + void UIRpsGame::result(int8_t hand, int8_t streak) + { + game.result(hand, streak); + } + + void UIRpsGame::close() + { + deactivate(); + } + + Button::State UIRpsGame::button_pressed(uint16_t buttonid) + { + switch (buttonid) + { + case Buttons::START: + case Buttons::CONTINUE: + case Buttons::RETRY: + case Buttons::EXIT: + { + static constexpr RpsActionPacket::Mode MODES[] = { + RpsActionPacket::Mode::START, RpsActionPacket::Mode::CONTINUE, RpsActionPacket::Mode::RETRY, + RpsActionPacket::Mode::EXIT + }; + + game.pressed(); + RpsActionPacket(MODES[buttonid]).dispatch(); + break; + } + default: + { + int8_t hand = static_cast(buttonid - Buttons::ROCK); + game.chose(hand); + RpsActionPacket(hand).dispatch(); + break; + } + } + + show_buttons(); + + return Button::State::NORMAL; + } + + // Ticks in ms since the window opened, never 0. + int64_t UIRpsGame::now() const + { + return ContinuousTimer::get().stop(opened) / 1000 + 1; + } + + void UIRpsGame::show_buttons() + { + RpsGame::Main main = game.main(); + bool main_shown = game.main_shown(); + + buttons[Buttons::START]->set_active(main_shown && main == RpsGame::Main::START); + buttons[Buttons::CONTINUE]->set_active(main_shown && main == RpsGame::Main::CONTINUE); + buttons[Buttons::RETRY]->set_active(main_shown && main == RpsGame::Main::RETRY); + buttons[Buttons::EXIT]->set_active(game.exit_shown()); + + for (uint16_t hand = Buttons::ROCK; hand <= Buttons::SCISSORS; hand++) + buttons[hand]->set_active(game.hands_shown()); + } + + void UIRpsGame::play(RpsGame::Events events) + { + nl::node minigame = nl::nx::sound["MiniGame.img"]; + + if (events.reel) + Sound(minigame["Reel"]).play(); + + if (events.timer) + Sound(minigame["Timer"]).play(); + + switch (events.jingle) + { + case RpsGame::Jingle::WIN: + Sound(minigame["Win"]).play(); + break; + case RpsGame::Jingle::DRAW: + Sound(minigame["Draw"]).play(); + break; + case RpsGame::Jingle::LOOSE: + Sound(minigame["Loose"]).play(); + break; + case RpsGame::Jingle::NONE: + break; + } + + if (events.emotion >= 0) + emotion = expression(events.emotion); + + if (events.time_up) + RpsActionPacket(RpsActionPacket::Mode::TIME_UP).dispatch(); + } +} diff --git a/HeavenClient/IO/UITypes/UIRpsGame.h b/HeavenClient/IO/UITypes/UIRpsGame.h new file mode 100644 index 000000000..7834b6080 --- /dev/null +++ b/HeavenClient/IO/UITypes/UIRpsGame.h @@ -0,0 +1,92 @@ +#pragma once + +#include "../UIElement.h" + +#include "../../Character/RpsGame.h" +#include "../../Character/Look/Face.h" +#include "../../Graphics/Animation.h" +#include "../../Graphics/Text.h" +#include "../../Timer.h" + +#include + +namespace ms +{ + // The Rock, Paper, Scissors window (v83 0x73d05b on UIWindow.img/RpsGame, report b038): the player's hand and + // the admin's spinning one, Start, Continue or Retry in the middle, the three hands while choosing, and Exit. + // The server plays every round. Exit is the only way out: Esc does nothing (report b041). + class UIRpsGame : public UIElement + { + public: + static constexpr Type TYPE = UIElement::Type::RPSGAME; + static constexpr bool FOCUSED = false; + static constexpr bool TOGGLED = false; + + // StringPool 0xe5b, shown before the window. + static constexpr const char* NOTICE = + "#e#bRock, Paper, Scissors Notice#n#k\r\nThe participation fee for this Rock, Paper, Scissors game is #r1,000 mesos#k.\r\nWinning games will allow you to obtain various Winning Streak Certificates awarded for each consecutive win, but failure to complete the challenge will result in not receiving the Cerficate.\r\nThe received Certificate can be traded with the NPC's Paul, Jean, Martin, and Tony."; + + enum class Refusal : uint8_t + { + NO_MESOS, + NO_SLOT, + SILENT + }; + + UIRpsGame(int32_t npc_id); + + void draw(float alpha) const override; + void update() override; + + Cursor::State send_cursor(bool clicked, Point cursorpos) override; + void remove_cursor() override; + + UIElement::Type get_type() const override; + + // RPS_GAME 6, 7 and 14. + void refused(Refusal refusal); + // 9 and 12. + void start_round(); + // 10. + void time_over(); + // 11. + void result(int8_t hand, int8_t streak); + // 13. + void close(); + + protected: + Button::State button_pressed(uint16_t buttonid) override; + + private: + int64_t now() const; + void show_buttons(); + void play(RpsGame::Events events); + + enum Buttons : uint16_t + { + START, + CONTINUE, + RETRY, + EXIT, + ROCK, + PAPER, + SCISSORS + }; + + RpsGame game; + ContinuousTimer::point opened; + + std::array own_hands; + std::array npc_hands; + // By RpsGame::Banner, NONE first. + std::array banners; + Animation npc; + Expression::Id emotion; + Text status; + // Where the line starts in its 270 px. + int16_t status_x; + + bool dragged; + Point drag_from; + }; +} diff --git a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp index ce636dd10..c695a79a4 100644 --- a/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp +++ b/HeavenClient/Net/Handlers/NpcInteractionHandlers.cpp @@ -30,6 +30,7 @@ #include "../IO/UITypes/UINpcTalk.h" #include "../IO/UITypes/UIPetChooser.h" #include "../IO/UITypes/UIQuiz.h" +#include "../IO/UITypes/UIRpsGame.h" #include "../IO/UITypes/UIShop.h" #include "../IO/UITypes/UISlideMenu.h" #include "../IO/UITypes/UIStorage.h" @@ -440,6 +441,83 @@ namespace ms UI::get().emplace(message, [](bool) {}); } + void RpsGameHandler::handle(InPacket& recv) const + { + enum Mode : uint8_t + { + NO_MESOS = 6, + NO_SLOT = 7, + OPEN = 8, + START = 9, + TIME_OVER = 10, + RESULT = 11, + NEXT_ROUND = 12, + CLOSE = 13, + RESET = 14 + }; + + auto mode = static_cast(recv.read_byte()); + auto window = UI::get().get_element(); + bool open = window && window->is_active(); + + // 0x73fff1: everything but the opening needs the window. + if (mode == Mode::OPEN) + { + if (open) + return; + + int32_t npc_id = recv.read_int(); + + // The notice comes first, and the window once it is answered either way (StringPool 0xe5b). + UI::get().emplace(); + UI::get().enable(); + + if (auto talk = UI::get().get_element()) + { + talk->change_text(npc_id, UINpcTalk::TalkType::SENDOK, 0, 0, UIRpsGame::NOTICE); + talk->set_local_responder([npc_id](int8_t, int32_t) + { + UI::get().emplace(npc_id); + }); + } + + return; + } + + if (!open) + return; + + switch (mode) + { + case Mode::NO_MESOS: + case Mode::NO_SLOT: + case Mode::RESET: + window->refused(mode == Mode::NO_MESOS ? UIRpsGame::Refusal::NO_MESOS + : mode == Mode::NO_SLOT ? UIRpsGame::Refusal::NO_SLOT + : UIRpsGame::Refusal::SILENT); + break; + case Mode::START: + case Mode::NEXT_ROUND: + window->start_round(); + break; + case Mode::TIME_OVER: + window->time_over(); + break; + case Mode::RESULT: + { + int8_t hand = recv.read_byte(); + int8_t streak = recv.read_byte(); + window->result(hand, streak); + break; + } + case Mode::CLOSE: + window->close(); + break; + default: + break; + } + } + void ParcelHandler::handle(InPacket& recv) const { enum Mode : uint8_t diff --git a/HeavenClient/Net/Handlers/NpcInteractionHandlers.h b/HeavenClient/Net/Handlers/NpcInteractionHandlers.h index aabc71150..6f8a22f1d 100644 --- a/HeavenClient/Net/Handlers/NpcInteractionHandlers.h +++ b/HeavenClient/Net/Handlers/NpcInteractionHandlers.h @@ -57,6 +57,13 @@ namespace ms void handle(InPacket& recv) const override; }; + // Opens the Rock, Paper, Scissors window, or plays it (report b038) + // Opcode: RPS_GAME(312) + class RpsGameHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Opens Duey's window, or answers a request to it (report b028) // Opcode: PARCEL(322) class ParcelHandler : public PacketHandler diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 2f9b5634a..2e93656fa 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -93,6 +93,7 @@ namespace ms NPC_SHOP_ACTION = 61, TRUNK_REQUEST = 62, DUEY_ACTION = 65, + RPS_ACTION = 136, /// Player Interaction CHAR_INFO_REQUEST = 97, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index 187839943..af16cf144 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -209,6 +209,7 @@ namespace ms OPEN_NPC_SHOP = 305, CONFIRM_SHOP_TRANSACTION = 306, TRUNK_RESULT = 309, + RPS_GAME = 312, PARCEL = 322, KEYMAP = 335, AUTO_HP_POT = 336, @@ -396,6 +397,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); // Player Interaction diff --git a/HeavenClient/Net/Packets/NpcInteractionPackets.h b/HeavenClient/Net/Packets/NpcInteractionPackets.h index 74be56d5e..61b60c37e 100644 --- a/HeavenClient/Net/Packets/NpcInteractionPackets.h +++ b/HeavenClient/Net/Packets/NpcInteractionPackets.h @@ -186,6 +186,34 @@ namespace ms } }; + // A press in the Rock, Paper, Scissors window (v83 0x7403d0 to 0x7405a0) + // Opcode: RPS_ACTION(136) + class RpsActionPacket : public OutPacket + { + public: + enum Mode : int8_t + { + START = 0, + CHOOSE = 1, + TIME_UP = 2, + CONTINUE = 3, + EXIT = 4, + RETRY = 5 + }; + + RpsActionPacket(Mode mode) : OutPacket(OutPacket::Opcode::RPS_ACTION) + { + write_byte(mode); + } + + // A hand: 0 rock, 1 paper, 2 scissors + RpsActionPacket(int8_t hand) : OutPacket(OutPacket::Opcode::RPS_ACTION) + { + write_byte(Mode::CHOOSE); + write_byte(hand); + } + }; + // Opcode: DUEY_ACTION(65) class DueyActionPacket : public OutPacket { diff --git a/HeavenClient/tests/rps_game.cpp b/HeavenClient/tests/rps_game.cpp new file mode 100644 index 000000000..a3e3f9385 --- /dev/null +++ b/HeavenClient/tests/rps_game.cpp @@ -0,0 +1,248 @@ +#include "../Character/RpsGame.h" + +#include +#include + +using namespace ms; + +namespace +{ + void require(bool condition, const char* message) + { + if (!condition) throw std::runtime_error(message); + } + + using Banner = RpsGame::Banner; + using Jingle = RpsGame::Jingle; + using Main = RpsGame::Main; + + // Runs the game frame by frame from `now` to `until`, gathering what happened. + struct Run + { + int reels = 0; + int timers = 0; + bool time_up = false; + Jingle jingle = Jingle::NONE; + int8_t emotion = -1; + }; + + Run run(RpsGame& game, int64_t& now, int64_t until, int64_t frame = 8) + { + Run seen; + + for (; now < until; now += frame) + { + RpsGame::Events events = game.update(now); + game.update_status(now); + seen.reels += events.reel; + seen.timers += events.timer; + seen.time_up |= events.time_up; + + if (events.jingle != Jingle::NONE) + seen.jingle = events.jingle; + + if (events.emotion >= 0) + seen.emotion = events.emotion; + } + + return seen; + } + + // The line under the avatars at `now`. + std::string line(RpsGame& game, int64_t now) + { + game.update_status(now); + + return game.status(); + } + + // Started and chosen: the server's answer is next. + RpsGame chosen(int64_t& now, int8_t choice) + { + RpsGame game; + game.pressed(); + game.start_round(now); + game.chose(choice); + + return game; + } + + void opens_with_start() + { + RpsGame game; + require(game.main() == Main::START && game.main_shown() && game.exit_shown(), "start and exit"); + require(!game.hands_shown() && game.choice() == RpsGame::ROCK, "rock drawn, no hands"); + require(line(game, 0) == "Press 'Start' to start the game.", "start text"); + } + + void a_round_counts_down_until_a_hand_is_chosen() + { + int64_t now = 1000; + RpsGame game; + game.pressed(); + game.start_round(now); + require(game.hands_shown() && !game.main_shown() && !game.exit_shown(), "hands only"); + require(line(game, now) == "Please select Rock, Paper, or Scissors in 30 seconds.", "30 seconds"); + require(line(game, now + 1) == "Please select Rock, Paper, or Scissors in 30 seconds.", "rounded up"); + require(line(game, now + 1000) == "Please select Rock, Paper, or Scissors in 29 seconds.", "29 seconds"); + require(game.update_status(now + 1000), "picked every frame"); + + // The spin never slows before the result. + Run seen = run(game, now, now + 2000); + require(seen.reels >= 15 && seen.reels <= 17, "every 120 ms"); + require(game.banner() == Banner::NONE, "no result yet"); + } + + void the_last_ten_seconds_tick_and_then_time_runs_out() + { + int64_t now = 1000; + RpsGame game; + game.pressed(); + game.start_round(now); + Run seen = run(game, now, now + 31000); + require(seen.timers == 10, "ten ticks"); + require(seen.time_up && !game.hands_shown(), "time up hides the hands"); + + game.time_over(now); + require(game.banner() == Banner::NONE, "the banner waits"); + seen = run(game, now, now + 1001); + require(game.banner() == Banner::TIMEOVER && seen.jingle == Jingle::LOOSE && seen.emotion == 4, + "time over banner, loose and cry"); + seen = run(game, now, now + 2100); + require(seen.emotion == 0 && game.banner() == Banner::NONE, "then back"); + require(game.main() == Main::RETRY && game.main_shown(), "retry"); + require(line(game, now).rfind("You have lost the game due to being over the time limit.", 0) == 0, + "time over text"); + } + + void a_win_slows_the_spin_to_the_admins_hand() + { + int64_t now = 1000; + RpsGame game = chosen(now, RpsGame::PAPER); + run(game, now, now + 500); + game.result(RpsGame::ROCK, 1); + + int64_t from = now; + int64_t stopped = 0; + + for (; now < from + 8000 && !stopped; now += 8) + { + game.update(now); + + if (!game.spinning()) + stopped = now; + } + + require(stopped, "the spin stops"); + require(game.spin() == RpsGame::ROCK && game.banner() == Banner::NONE, "on rock, the banner unseen"); + require(stopped - from > 3000 && stopped - from < 5000, "after three to five seconds"); + + Run seen = run(game, now, stopped + 1001); + require(game.banner() == Banner::WIN, "won"); + require(seen.jingle == Jingle::WIN && seen.emotion == 2, "win and smile a second later"); + require(!game.main_shown(), "nothing to press yet"); + seen = run(game, now, stopped + 3001); + require(seen.emotion == 0 && game.banner() == Banner::NONE, "banner gone"); + require(game.main() == Main::CONTINUE && game.main_shown() && game.exit_shown(), "continue or exit"); + require(line(game, now) == + "To challenge the 2-game Winning Streak Challenge, press 'Continue'. A failed challenge will result in no reward.", + "next challenge"); + } + + void a_draw_plays_again_by_itself() + { + int64_t now = 1000; + RpsGame game = chosen(now, RpsGame::SCISSORS); + game.result(RpsGame::SCISSORS, 0); + Run seen = run(game, now, now + 9000); + require(seen.jingle == Jingle::DRAW && seen.emotion == 0, "draw, troubled, then back"); + require(game.hands_shown() && !game.main_shown() && !game.exit_shown(), "choose again"); + require(line(game, now).rfind("Please select", 0) == 0, "counting down again"); + } + + void a_draw_after_a_tick_ticks_all_ten_again() + { + int64_t now = 1000; + RpsGame game; + game.pressed(); + game.start_round(now); + Run seen = run(game, now, now + 21500); + require(seen.timers == 1, "the nine"); + game.chose(RpsGame::ROCK); + game.result(RpsGame::ROCK, 0); + seen = run(game, now, now + 40000); + require(seen.timers == 10 && seen.time_up, "ten in the replay"); + } + + void only_a_loss_from_no_streak_is_consoled() + { + int64_t now = 1000; + RpsGame game = chosen(now, RpsGame::ROCK); + game.result(RpsGame::PAPER, -1); + Run seen = run(game, now, now + 9000); + require(game.banner() == Banner::NONE && seen.jingle == Jingle::LOOSE && seen.emotion == 0, "lost"); + require(game.main() == Main::RETRY, "retry"); + require(line(game, now).rfind("Here's 500 mesos as a consolation prize.", 0) == 0, "consoled"); + + // A new game keeps the lost streak, so its loss isn't; Restart's line stays through its rounds. + game.pressed(); + require(line(game, now).rfind("Here's 500", 0) == 0, "no countdown after Restart"); + game.start_round(now); + require(line(game, now).rfind("Here's 500", 0) == 0, "nor in its rounds"); + game.chose(RpsGame::ROCK); + game.result(RpsGame::PAPER, -1); + run(game, now, now + 9000); + require(line(game, now) == + "Press 'Restart' to try again, which will cost you the participation fee of 1,000 mesos.", + "not consoled"); + + // A refusal starts over. + game.reset(); + require(game.main() == Main::START && game.main_shown() && game.exit_shown(), "start again"); + game.pressed(); + game.start_round(now); + game.chose(RpsGame::ROCK); + game.result(RpsGame::PAPER, -1); + run(game, now, now + 9000); + require(line(game, now).rfind("Here's 500", 0) == 0, "consoled again"); + } + + void a_chosen_hand_leaves_the_last_second() + { + int64_t now = 1000; + RpsGame game; + game.pressed(); + game.start_round(now); + run(game, now, now + 4500); + game.chose(RpsGame::PAPER); + require(line(game, now + 2000) == "Please select Rock, Paper, or Scissors in 26 seconds.", "left as it was"); + } + + void ten_wins_only_offer_retry() + { + int64_t now = 1000; + RpsGame game = chosen(now, RpsGame::PAPER); + game.result(RpsGame::ROCK, 10); + run(game, now, now + 9000); + require(game.main() == Main::RETRY, "retry"); + require(line(game, now) == "Congratulations on conquering the 10-game Winning Streak Challenge!", + "conquered"); + } +} + +int main() +{ + opens_with_start(); + a_round_counts_down_until_a_hand_is_chosen(); + the_last_ten_seconds_tick_and_then_time_runs_out(); + a_win_slows_the_spin_to_the_admins_hand(); + a_draw_plays_again_by_itself(); + a_chosen_hand_leaves_the_last_second(); + a_draw_after_a_tick_ticks_all_ten_again(); + only_a_loss_from_no_streak_is_consoled(); + ten_wins_only_offer_retry(); + + std::cout << "rps_game tests passed\n"; + + return 0; +} diff --git a/agent_docs/client.md b/agent_docs/client.md index 361d62124..040aab541 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -596,6 +596,7 @@ side yet. | Cash mists (528xxxx) | Implemented: the use gate (`MapMists::item_busy`), the cloud (`MapMists`, type 3), the puff on the user (`Char::show_mist_puff`, user effect mode 20) and the face (ITEM_EMOTION, `Stage::use_item_emotion`); see `inventory.md` | | Kites (508xxxx) | Implemented: the use gates, the three-line entry window, the kite over the field with its bob, fade, hover line and double-click viewer (`MapKites`, `UIKiteWrite`, `UIKiteView`); see "Kites" in `fields.md` | | Storage (`UIStorage`) | Implemented; see `inventory.md` | +| Rock, Paper, Scissors (`UIRpsGame`, `Character/RpsGame`) | Implemented: the admin's notice, the window, rounds, results and certificates; see `inventory.md` | | Duey (`UIDuey`) | The tab strip, receiving, normal and quick sends, the arrival notice, and the Quick Delivery Coupon's window; see `inventory.md` | | Teleport rocks (`UITeleportRock`) | Implemented: the map list, keeping and dropping maps, and moving to a map or a character; see `inventory.md` | | Item Maker (`UIMaker`) | Recipes with their stimulator and gems, monster crystals, disassembly and the random make: the drop lists, ingredient counts, fee, gauge, result dialog and chat lines; see `inventory.md` | diff --git a/agent_docs/inventory.md b/agent_docs/inventory.md index c0c474dc7..736a7c6c9 100644 --- a/agent_docs/inventory.md +++ b/agent_docs/inventory.md @@ -1050,8 +1050,32 @@ Continue, 4 Exit, 5 Retry. Requests that don't fit the game are ignored. the streak, 4031332 for one win up to 4031341 for ten. The tenth win ends the game and gives its certificate at once. -The client's X closes the window without a packet, so the server's game -stays until the next talk, which starts a new one. +The server's game stays until the next talk, which starts a new one, in +case a client closes the window without a packet. + +The client's window is `UIRpsGame` (reports b038 and b041), with the game +itself in `Character/RpsGame`, which `rps_game_tests` covers: + +- Mode 8 shows the admin's notice as a local NPC dialog first, then opens + the window centred on the screen. It isn't modal, its top 15 pixels drag + it, and Exit is the only way out: there is no X, and Esc does nothing. +- The player's hand and the admin's are drawn from the start, rock until + chosen. Start, Continue and Retry swap in the middle; the three hands show + while a round waits for a choice, and the player's 30 seconds run out + into RPS_ACTION 2, with MiniGame.img's Timer in the last ten. +- The admin's hand spins with Reel every 120 ms. After the result it slows + by a fifth a step until it reaches his hand at 720 ms or more. The banner + is put up then but shows a second later with the jingle and the avatar's + face (smile, troubled or cry), and two seconds after that the next state + comes: a draw plays the round again without asking the server. +- The line under the avatars is centred if it fits in 270 pixels. A wider + one starts halfway and scrolls left a pixel a frame. It is picked again + when the middle button changes or shows, and every frame while Start or + Continue is hidden, which is when it counts down. So the countdown, wider + than 270 in Arial 12, shows from halfway and never scrolls, and after + Retry the round keeps Restart's line, as v83 does. +- Refusals 6 and 7 show v83's notices over the window, which shows Start + again. ## Trading