From 42d0ca9224d24b802b77b169f5d6c10935f1c19d Mon Sep 17 00:00:00 2001 From: carrdylan52 <17089587+carrdylan52@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:55:06 -0400 Subject: [PATCH 1/5] Port: give the Make and Monster Transform menus mouse input Hover selects cards, items, badges and class rows; a click confirms and a right click backs out; the wheel scrolls lists and eases the item grid like the D-pad scroll. Pages without targets ignore stray left clicks, and a click wins over a wheel notch in the same frame. --- docs/PC.md | 29 +- port/include/stubs/inventmn.hpp | 2 + port/include/stubs/menuchr.hpp | 1 + port/src/menu_mouse.cpp | 31 +- port/src/menu_mouse.hpp | 5 + port/src/menu_mouse_item.cpp | 63 ++-- port/src/menu_mouse_item.hpp | 9 + port/src/menu_mouse_make.cpp | 225 +++++++++++ port/src/menu_mouse_make.hpp | 59 +++ port/src/menu_mouse_menus.cpp | 6 + port/src/menu_mouse_monster.cpp | 101 +++++ port/src/menu_mouse_monster.hpp | 14 + port/src/menu_mouse_shop.cpp | 27 +- port/src/tests/menu_mouse_item_test.cpp | 72 +++- port/src/tests/menu_mouse_make_test.cpp | 432 ++++++++++++++++++++++ port/src/tests/menu_mouse_test.cpp | 7 + tools/input/make-menu-mouse.input | 53 +++ tools/input/monster-transform-mouse.input | 36 ++ 18 files changed, 1120 insertions(+), 52 deletions(-) create mode 100644 port/src/menu_mouse_make.cpp create mode 100644 port/src/menu_mouse_make.hpp create mode 100644 port/src/menu_mouse_monster.cpp create mode 100644 port/src/menu_mouse_monster.hpp create mode 100644 port/src/tests/menu_mouse_make_test.cpp create mode 100644 tools/input/make-menu-mouse.input create mode 100644 tools/input/monster-transform-mouse.input diff --git a/docs/PC.md b/docs/PC.md index 77531dfce..c784a01b4 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -749,7 +749,8 @@ the mouse. A short click opens commands on release. Swapped items return to the original slot through the retail return animation. Dropping outside a target or pressing Mouse2 returns the held item; it does not discard it. Equipment, use, stack limits and refusal checks remain the game's own. Wheel notches over the -grid scroll rows, stopping at the bag's ends. +grid scroll one row each, stopping at the bag's ends, with the board's retail +eased scroll; the cell that settles under the pointer is selected. The pointer starts at the retail selection when the mouse takes over. Controller or keyboard button input returns control to the retail cursor. Mouse counts are @@ -769,6 +770,32 @@ outside the menu (1550). `item-menu-mouse-dungeon.input` reaches the dungeon's Item page and opens Bread's commands by frame 2850. These two scripts also use 1280x720; scale `mouse:dx,dy` counts with the window dimensions for other sizes. +### Make and Monster Transform mouse + +On the Make menu, hovering an invention card or a cell of the item board moves +the retail cursor there; hovering the other pane brings it forward as Left and +Right do. Where the panes overlap, the pane in front keeps the pointer. Mouse1 +on a card or cell confirms it; a click that brings the other pane forward only +selects. Mouse2 backs out like Circle, and clicks on the background confirm +nothing. The wheel moves the card cursor one row per notch, or scrolls the item +board one row with its retail eased scroll. On the quantity board, hovering Yes +or No selects it, the arrows and the wheel change the count, and Mouse1 on Yes, +No or the count confirms. Result and error messages accept a click. The idea +board, notebook and album pages have no pointer targets: Mouse1 there does +nothing and Mouse2 backs out. + +On the Monster Transform screen, hovering a badge selects it and updates the +preview, Mouse1 opens its commands, and the wheel moves one row of badges. +Command, form and Yes/No lists use the shared message choices; the class-change +list accepts hover and click. On a badge's status page the wheel steps through +its forms like L1 and R1, and Mouse1 does nothing. Mouse2 backs out of each +step and closes the box. + +The adapters are `port/src/menu_mouse_make.cpp` and `menu_mouse_monster.cpp`; +`MenuMouseMake.*` and `MakeMouse.*` test them. `tools/input/make-menu-mouse.input` +and `monster-transform-mouse.input` replay both screens at 1280x720 with +`--jump town:10 --fast-load`; their comments give capture frames. + ### Fixed camera mouse look `input.mouse_fixed_look` defaults to `true` and appears as **Fixed camera mouse look** diff --git a/port/include/stubs/inventmn.hpp b/port/include/stubs/inventmn.hpp index 473e382ba..52755424c 100644 --- a/port/include/stubs/inventmn.hpp +++ b/port/include/stubs/inventmn.hpp @@ -3,9 +3,11 @@ class mgCMemory; class CInventDataManage; class CInventUserData; +class CMenuInvent; struct SPI_TAG_PARAM; extern mgCMemory InventTeigiStack asm("Inventmn_RecipeStack"); extern CInventDataManage *InventManagePt asm("Inventmn_RecipeManager"); extern SPI_TAG_PARAM invent_teigi_func[3] asm("Inventmn_RecipeTags"); extern CInventUserData *InventUserDataPtr asm("Inventmn_PhotoData"); +extern CMenuInvent *CMenuInventPt asm("MenuMouse_Invent"); diff --git a/port/include/stubs/menuchr.hpp b/port/include/stubs/menuchr.hpp index cd68e29d4..d21e0b77c 100644 --- a/port/include/stubs/menuchr.hpp +++ b/port/include/stubs/menuchr.hpp @@ -4,3 +4,4 @@ void PortRetailInitMainCharaBG(int, mgCMemory *, int) asm("MenuChr_InitMainCharaBG"); int PortRetailKeyMainCharaBG() asm("MenuChr_KeyMainCharaBG"); +extern CMenuMosSelect *MenuMosSelectPtr asm("MenuMouse_MosSelect"); diff --git a/port/src/menu_mouse.cpp b/port/src/menu_mouse.cpp index c37acb8cd..107eb22dd 100644 --- a/port/src/menu_mouse.cpp +++ b/port/src/menu_mouse.cpp @@ -9,6 +9,7 @@ #include "gamepad.hpp" #include "mainloop.hpp" #include "menu_mouse_item.hpp" +#include "menudraw.hpp" #include "mglib.hpp" #include "mglib_port.hpp" #include "platform/input.hpp" @@ -72,13 +73,33 @@ int MenuMouseChoose(const MenuMouseSample &mouse, std::span return selected; } const int hit = MenuMouseHit(rows, mouse.x, mouse.y); - if (hit >= 0 && (mouse.moved || (mouse.clicked & 1))) { + if (hit >= 0 && (mouse.clicked & 1)) { + return hit; // A click confirms the row it was pressed on; a wheel notch in the same sample is dropped. + } + if (hit >= 0 && mouse.moved) { selected = hit; } - if (mouse.wheel != 0 && std::isfinite(mouse.wheel)) { - selected = std::clamp(selected + (-static_cast(std::clamp(mouse.wheel, -32.0f, 32.0f)) * wheel_step), 0, static_cast(rows.size()) - 1); + return MenuMouseWheelStep(selected, mouse.wheel, static_cast(rows.size()), wheel_step); +} + +int MenuMouseWheelStep(int selected, float wheel, int count, int wheel_step) { + if (wheel == 0 || !std::isfinite(wheel) || count <= 0) { + return selected; + } + return std::clamp(selected + (-static_cast(std::clamp(wheel, -32.0f, 32.0f)) * wheel_step), 0, count - 1); +} + +MenuMouseRect MenuMousePart(CMenuPosDataForm *form, const char *name) { + if (!form || !form->draw_flag) { + return {}; + } + auto *part = form->GetPartInfo(const_cast(name)); + if (!part || !part->draw_flag) { + return {}; } - return selected; + float x, y; + form->GetPutPosXY(part->name, x, y); + return {x, y, float(part->w), float(part->h)}; } namespace { @@ -369,7 +390,7 @@ void MenuMouseDraw() { // The pointing fingertip is at the far edge, halfway down the hand's atlas cell. const float tip_x = sign * hand.uv.w * hand.scale; const float tip_y = hand.uv.h * hand.scale * 0.5f; - const float texture_scale = mgPortTextureScale(hand.texture.texture); + const float texture_scale = mgPortTextureScale(hand.texture.texture); gfx::Vertex2D vertices[4]{}; for (int i = 0; i < 4; ++i) { const float x = (i & 1 ? tip_x : 0) - tip_x; diff --git a/port/src/menu_mouse.hpp b/port/src/menu_mouse.hpp index 29c1ecd88..87c59c2e5 100644 --- a/port/src/menu_mouse.hpp +++ b/port/src/menu_mouse.hpp @@ -21,6 +21,11 @@ struct MenuMouseSample { float MenuMouseUnits(float scale, float pixels, float window); int MenuMouseHit(std::span rows, float x, float y); int MenuMouseChoose(const MenuMouseSample &mouse, std::span rows, int selected, int wheel_step = 1); +// Whole wheel notches move selected by wheel_step per notch, clamped to [0, count). +int MenuMouseWheelStep(int selected, float wheel, int count, int wheel_step = 1); +class CMenuPosDataForm; +// A drawn form part's rectangle; empty when the form or the part is hidden or missing. +MenuMouseRect MenuMousePart(CMenuPosDataForm *form, const char *name); class ClsMes; std::vector MenuMouseMessageRows(ClsMes &message, int count); diff --git a/port/src/menu_mouse_item.cpp b/port/src/menu_mouse_item.cpp index 66ae40eda..5174113bd 100644 --- a/port/src/menu_mouse_item.cpp +++ b/port/src/menu_mouse_item.cpp @@ -45,6 +45,32 @@ int ScrollTop(int top, int count, int visible_rows, float wheel) { return std::clamp(top + (wheel > 0 ? -step : step), 0, std::max(0, (count + 5) / 6 - visible_rows)); } +bool VisibleRow(int cursor, int top, int visible_rows) { + return cursor >= 0 && cursor / 6 >= top && cursor / 6 < top + visible_rows; +} + +Point FormPoint(CMenuPosDataForm *form, const char *part) { + Point p; + form->GetPutPosXY(const_cast(part), p.x, p.y); + return p; +} + +Rect ItemBoardClip(CMenuPosDataForm *board) { + const Point p = FormPoint(board); + return {p.x + 16, p.y + 20, 240, 250}; +} + +Point ItemBoardRest(CMenuPosDataForm *board) { return {MenuItemBrdUnderBrdPosXY[0], FormPoint(board).y + 26}; } + +Target WheelTarget(Point p, Point rest, Rect clip, int top, int count, int cursor) { + // The aperture reaches above the first resting row; only a cell of the new visible rows may be chosen. + const Target target = GridHit(p, {rest.x, rest.y - top * 50.0f}, clip, count); + if (target && VisibleRow(target.cursor, top, 5)) { + return target; + } + return {ITEM_MENU_PANEL_BAG, std::clamp(cursor, top * 6, std::min(count - 1, (top + 5) * 6 - 1))}; +} + void Gesture::Reset() { *this = {}; } Action Gesture::Update(const Take &take, Point p, Target hit, bool can_pick_up, bool holding) { @@ -99,8 +125,6 @@ using namespace menumouse; Pointer pointer; Gesture gesture; bool menu_open = false, finish_drop = false; -bool wheel_jump = false; -int saved_scroll_manner = MENU_ITEM_BRD_CALC_EASE; int synthetic = 0, bag_top = 0; CMenuKeyFunc *owner = nullptr; int view = -1; @@ -122,16 +146,7 @@ bool Unfocused() { return WindowCurrentMode(mode) && !(SDL_GetWindowFlags(WindowHandle()) & SDL_WINDOW_INPUT_FOCUS); } -Point FormPoint(CMenuPosDataForm *form, const char *part = nullptr) { - Point p; - form->GetPutPosXY(const_cast(part), p.x, p.y); - return p; -} - -Rect BoardClip(CMenuItemInfo &menu) { - Point p = FormPoint(menu.item_board_form); - return {p.x + 16, p.y + 20, 240, 250}; -} +Rect BoardClip(CMenuItemInfo &menu) { return ItemBoardClip(menu.item_board_form); } Target Hit(CMenuItemInfo &menu, Point p, int count) { Target target = GridHit(p, {MenuItemBrdUnderBrdPosXY[0], MenuItemBrdUnderBrdPosXY[1]}, BoardClip(menu), count); @@ -303,7 +318,7 @@ void Question(CMenuItemInfo &menu, CMenuKeyFunc &keys, const Take &take) { // Use those parts rather than assuming which side has room. Point up = FormPoint(quantity, "up"), down = FormPoint(quantity, "down"); Rect area{std::min(up.x, down.x) - 12, std::min(up.y, down.y) - 12, - std::abs(up.x - down.x) + 48, std::abs(up.y - down.y) + 48}; + std::abs(up.x - down.x) + 48, std::abs(up.y - down.y) + 48}; if (area.Contains(pointer.pos)) { if (take.wheel != 0) { synthetic = take.wheel > 0 ? PAD_UP : PAD_DOWN; @@ -380,10 +395,6 @@ void MenuMouseClose() { void MenuMousePadTick() { synthetic = 0; - if (wheel_jump) { - MenuItemBrdCalcManner = saved_scroll_manner; - wheel_jump = false; - } if (menu_open && !ItemPage()) { MenuMouseClose(); } @@ -478,23 +489,23 @@ void MenuMouseUpdate(CMenuKeyFunc &keys) { if (menu.key_arg_no == ITEM_MENU_PANEL_BAG) { bag_top = keys.top_line; } + bool scrolled = false; if (take.wheel && BoardClip(menu).Contains(pointer.pos)) { int top = ScrollTop(bag_top, count, layouts[ITEM_MENU_PANEL_BAG].disp_lines, take.wheel); if (top != bag_top) { bag_top = top; - Select(menu, keys, {ITEM_MENU_PANEL_BAG, std::clamp(keys.cursor, top * 6, std::min(count - 1, (top + 5) * 6 - 1))}, layouts); + // The retail board eases to the new top line, as it does for a D-pad scroll. + Select(menu, keys, WheelTarget(pointer.pos, ItemBoardRest(menu.item_board_form), BoardClip(menu), top, count, keys.cursor), layouts); keys.top_line = top; keys.key_input = true; - // Wheel steps are discrete; match hit-testing and drawing this tick. - saved_scroll_manner = MenuItemBrdCalcManner; - wheel_jump = true; - MenuItemBrdCalcManner = MENU_ITEM_BRD_CALC_JUMP; - Point p = FormPoint(menu.item_board_form); - MenuItemBrdUnderBrdPosXY[1] = p.y + 26 - top * 50; + scrolled = true; } } Target hit = Hit(menu, pointer.pos, count); - if (hit && (take.moved || gesture.dragging)) { + if (hit.panel == ITEM_MENU_PANEL_BAG && !VisibleRow(hit.cursor, bag_top, layouts[ITEM_MENU_PANEL_BAG].disp_lines)) { + hit = {}; // A row still sliding out of the eased board is no longer selectable. + } + if (hit && !scrolled && (take.moved || gesture.dragging)) { Select(menu, keys, hit, layouts); } bool can_pick_up = false; @@ -514,6 +525,8 @@ void MenuMouseUpdate(CMenuKeyFunc &keys) { finish_drop = true; break; case Action::Click: + // Confirm the clicked cell even if a wheel scroll moved the cursor meanwhile. + Select(menu, keys, hit, layouts); synthetic = PAD_CROSS; break; case Action::Cancel: diff --git a/port/src/menu_mouse_item.hpp b/port/src/menu_mouse_item.hpp index fdbd1e0b5..8763950fe 100644 --- a/port/src/menu_mouse_item.hpp +++ b/port/src/menu_mouse_item.hpp @@ -30,6 +30,15 @@ struct Target { // clip is its visible aperture, so rows sliding out cannot be selected. Target GridHit(Point p, Point origin, Rect clip, int count); int ScrollTop(int top, int count, int visible_rows, float wheel); +// True when a bag cell's row is inside the target visible rows, not just the eased aperture. +bool VisibleRow(int cursor, int top, int visible_rows); +Point FormPoint(CMenuPosDataForm *form, const char *part = nullptr); +// The item board's visible aperture for its form. +Rect ItemBoardClip(CMenuPosDataForm *board); +// Func_MenuItemBrdPosStep's resting origin of the bag's first cell at top line 0. +Point ItemBoardRest(CMenuPosDataForm *board); +// After a wheel scroll to top, the visible cell that settles under p, else the cursor kept within the visible rows. +Target WheelTarget(Point p, Point rest, Rect clip, int top, int count, int cursor); struct Take { bool pointing = false, moved = false; diff --git a/port/src/menu_mouse_make.cpp b/port/src/menu_mouse_make.cpp new file mode 100644 index 000000000..85e3d48db --- /dev/null +++ b/port/src/menu_mouse_make.cpp @@ -0,0 +1,225 @@ +#include "menu_mouse_make.hpp" + +#include +#include + +#include "gamepad.hpp" +#include "inventmn.hpp" +#include "mainloop.hpp" +#include "menu_mouse.hpp" +#include "menucommon.hpp" +#include "menudraw.hpp" +#include "menumain.hpp" +#include "menusys.hpp" +#include "mglib_port.hpp" +#include "stubs/inventmn.hpp" + +namespace menumouse { + +int CardHit(Point p, Point list, float clip_top, int visible_rows, int count) { + if (p.x < list.x || p.x >= list.x + kCardWidth || p.y < clip_top || p.y >= clip_top + visible_rows * kCardPitch || + p.y < list.y) { + return -1; + } + const int card = static_cast((p.y - list.y) / kCardPitch); + return card < count ? card : -1; +} + +MakeListsResult MakeListsUpdate(const MakeLists &lists, Point p, bool moved, std::uint32_t clicked, float wheel) { + MakeListsResult result{lists}; + auto &next = result.state; + // The pane drawn in front owns the pointer where the card list and the item board overlap, + // even over a row it does not accept, such as one still sliding out of the eased board. + const Rect card_rows{lists.card_list.x, lists.card_clip, kCardWidth, kCardRows * kCardPitch}; + bool on_cards = lists.cards && card_rows.Contains(p); + bool on_board = lists.board && lists.board_clip.Contains(p); + if (on_cards && on_board && lists.items_front) { + on_cards = false; + } else if (on_cards && on_board) { + on_board = false; + } + int card = -1; + if (on_cards) { + card = CardHit(p, lists.card_list, lists.card_clip, kCardRows, lists.card_count); + if (card >= 0 && (card < lists.card_top || card >= lists.card_top + kCardRows)) { + card = -1; + } + } + Target cell; + if (on_board) { + cell = GridHit(p, lists.board_origin, lists.board_clip, lists.item_count); + if (cell && !VisibleRow(cell.cursor, lists.item_top, kCardRows)) { + cell = {}; + } + } + result.buttons = clicked & 2 ? PAD_CIRCLE : 0; + // Mouse1 in the same sample as a wheel notch confirms what it was pressed on; the notch is dropped. + if (std::isfinite(wheel) && wheel != 0 && !(clicked & 1)) { + if (on_cards) { + next.items_front = false; + next.card_cursor = MenuMouseWheelStep(lists.card_cursor, wheel, lists.card_count); + // MenuCheckLine keeps the cursor within the visible rows. + next.card_top = std::clamp(lists.card_top, next.card_cursor - kCardRows + 1, next.card_cursor); + result.sound = next.card_cursor != lists.card_cursor; + } else if (on_board) { + next.item_top = ScrollTop(lists.item_top, lists.item_count, kCardRows, wheel); + if (next.item_top != lists.item_top) { + // The board eases to the new row; pick the cell that settles under the pointer. + next.items_front = true; + next.item_cursor = WheelTarget(p, lists.board_rest, lists.board_clip, next.item_top, lists.item_count, + lists.item_cursor) + .cursor; + result.sound = true; + } + } + } else if (moved || (clicked & 1)) { + if (card >= 0) { + next.items_front = false; + result.sound = card != lists.card_cursor; + next.card_cursor = card; + } else if (cell) { + next.items_front = true; + result.sound = cell.cursor != lists.item_cursor; + next.item_cursor = cell.cursor; + } + } + const bool switched = next.items_front != lists.items_front; + if (switched) { + result.sound = false; + } + // A click that brings the other pane forward only selects; the next click confirms. + if ((clicked & 1) && (card >= 0 || cell) && !switched) { + result.buttons |= PAD_CROSS; + } + return result; +} + +MakeBoard MakeBoardRects(Point pos, const float *info) { + // These follow CommonBoardDraw's own placement of the buttons and the count. + const float width = info[4]; + const float arrow_x = static_cast(10 + pos.x + width / 2 + width / 10 + 2); + const float arrow_y = static_cast(250 + pos.y); + return { + {info[0], info[1], 60, 26}, + {info[2], info[3], 60, 26}, + {arrow_x, arrow_y, 16, 22}, + {arrow_x + 16, arrow_y, 32, 22}, + {arrow_x + 16 + 34, arrow_y, 16, 22}, + }; +} + +MakeInput MakeBoardInput(const MakeBoard &board, Point p, bool moved, std::uint32_t clicked, float wheel) { + const bool on_number = board.fewer.Contains(p) || board.count.Contains(p) || board.more.Contains(p); + MakeInput input; + if (moved || (clicked & 1)) { + input.row = board.make.Contains(p) || on_number ? 0 : board.quit.Contains(p) ? 1 + : -1; + } + input.buttons = clicked & 2 ? PAD_CIRCLE : 0; + if (std::isfinite(wheel) && wheel != 0) { + // The wheel changes the count, as Left and Right do on the Make row. + input.row = 0; + input.buttons |= wheel > 0 ? PAD_RIGHT : PAD_LEFT; + } else if (clicked & 1) { + if (board.fewer.Contains(p)) { + input.buttons |= PAD_LEFT; + } else if (board.more.Contains(p)) { + input.buttons |= PAD_RIGHT; + } else if (input.row >= 0) { + input.buttons |= PAD_CROSS; + } + } + return input; +} + +} // namespace menumouse + +namespace { +using namespace menumouse; +int tick_frame = -1; + +void MakeBoardMouse(CMenuInvent &menu, const MenuMouseSample &mouse) { + if (!menu.makebrd_form || !menu.makebrd_form->draw_flag) { + return; + } + const MakeBoard board = MakeBoardRects(FormPoint(menu.makebrd_form), MakeBoardDrawInfo); + const MakeInput input = MakeBoardInput(board, {mouse.x, mouse.y}, mouse.moved, mouse.clicked, mouse.wheel); + if (input.row >= 0 && input.row != menu.make_cursor) { + menu.make_cursor = input.row; + MenuSePlay(SYSTEM_SE_CURSOR); + } + MenuMouseSetButtons(input.buttons); +} + +void ListsMouse(CMenuInvent &menu, CMenuKeyFunc &keys, const MenuMouseSample &mouse) { + MakeLists lists; + lists.items_front = menu.key_arg_no == INVENT_MODE_ITEM_LIST; + lists.card_cursor = menu.card_cursor; + lists.card_top = menu.card_top; + lists.item_cursor = menu.item_cursor; + lists.item_top = menu.item_top; + auto *title = menu.card_list_title_form; + auto *list = menu.card_list_form; + // A held item keeps the cursor on the item board, as retail redirects Left there. + lists.cards = title && title->draw_flag && list && keys.have_item.item_no <= 0; + if (lists.cards) { + lists.card_count = menu.EnableSelectMaxCardList(); + lists.card_list = {list->x, list->y}; + lists.card_clip = FormPoint(title, "basecard").y; + } + auto *board = menu.itembrd_form; + lists.board = board && board->draw_flag; + if (lists.board) { + lists.item_count = GetNowBagMax(ITEM_BOARD_MAIN); + lists.board_origin = {MenuItemBrdUnderBrdPosXY[0], MenuItemBrdUnderBrdPosXY[1]}; + lists.board_clip = ItemBoardClip(board); + lists.board_rest = ItemBoardRest(board); + } + const auto result = MakeListsUpdate(lists, {mouse.x, mouse.y}, mouse.moved, mouse.clicked, mouse.wheel); + const auto &next = result.state; + const int pane = next.items_front ? INVENT_MODE_ITEM_LIST : INVENT_MODE_CARD_LIST; + if (pane != menu.key_arg_no) { + // The retail pane change slides the boards and plays the cursor sound. + menu.NextDifferentMode(pane, 0); + } + if (next.card_top != menu.card_top) { + menu.card_scroll_dir = next.card_top > menu.card_top; + } + menu.card_cursor = next.card_cursor; + menu.card_top = next.card_top; + menu.item_cursor = next.item_cursor; + menu.item_top = next.item_top; + if (result.sound) { + MenuSePlay(SYSTEM_SE_CURSOR); + } + MenuMouseSetButtons(result.buttons); +} +} // namespace + +void MenuMouseMakeTick(CMenuKeyFunc &keys) { + auto *menu = CMenuInventPt; + if (!menu || &keys != MenuCommonInfo || !keys.key_enable || tick_frame == mgPortFrameCount()) { + return; + } + tick_frame = mgPortFrameCount(); + const auto &mouse = MenuMouseTake(); + if (!mouse.pointing || GamePad__2.key_lock || menu_debug_flag) { + return; + } + if (menu->mode == MENU_ASK_MODE_MAKE) { + if (menu->step == INVENT_MAKE_STEP_SELECT) { + MakeBoardMouse(*menu, mouse); + } + return; + } + if (menu->mode != MENU_ASK_MODE_NONE || (MenuMoveItemPtr && MenuMoveItemPtr->move_on)) { + return; + } + if (menu->key_arg_no == INVENT_MODE_CARD_LIST || menu->key_arg_no == INVENT_MODE_ITEM_LIST) { + ListsMouse(*menu, keys, mouse); + } else { + // The idea board, notebook and album pages have no pointer targets: a stray left click + // must not confirm their keyboard selection. Mouse2 still backs out. + MenuMouseSetButtons(mouse.clicked & 2 ? PAD_CIRCLE : 0); + } +} diff --git a/port/src/menu_mouse_make.hpp b/port/src/menu_mouse_make.hpp new file mode 100644 index 000000000..eed7beb4f --- /dev/null +++ b/port/src/menu_mouse_make.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include "menu_mouse_item.hpp" + +class CMenuKeyFunc; + +namespace menumouse { + +// MenuInventCreateCardDraw's cards and the list's five visible rows. +inline constexpr float kCardWidth = 231, kCardPitch = 46; +inline constexpr int kCardRows = 5; + +// Cards are kCardPitch apart from the eased list origin. clip_top is the first visible row; +// rows outside the visible window cannot be hit. +int CardHit(Point p, Point list, float clip_top, int visible_rows, int count); + +// The Make menu's card list and item board, as drawn this tick. +struct MakeLists { + bool items_front = false; // The item board is the active pane. + bool cards = false, board = false; // Each pane is drawn and selectable. + int card_cursor = 0, card_top = 0, card_count = 0; + int item_cursor = 0, item_top = 0, item_count = 0; + Point card_list; // Eased origin of the first card. + float card_clip = 0; // Top of the first visible card row. + Point board_origin; // Eased origin of the first bag cell. + Rect board_clip; // Visible aperture of the item board. + Point board_rest; // Resting origin of the first bag cell at top line 0. +}; + +struct MakeListsResult { + MakeLists state; + int buttons = 0; + bool sound = false; // A cursor move within the pane in front; a pane change sounds itself. +}; + +// Hover, click and wheel on the Make menu's two panes. +MakeListsResult MakeListsUpdate(const MakeLists &lists, Point p, bool moved, std::uint32_t clicked, float wheel); + +// The quantity board's Make and Quit buttons, its arrows and the number between them. +struct MakeBoard { + Rect make, quit, fewer, count, more; +}; + +// pos is the board form's position; info is MakeBoardDrawInfo as the board last drew it. +MakeBoard MakeBoardRects(Point pos, const float *info); + +// The make row to select (0 Make, 1 Quit, -1 none) and the retail buttons for this tick. +struct MakeInput { + int row = -1, buttons = 0; +}; + +MakeInput MakeBoardInput(const MakeBoard &board, Point p, bool moved, std::uint32_t clicked, float wheel); + +} // namespace menumouse + +// Called before the invention menu reads its retail navigation and action buttons. +void MenuMouseMakeTick(CMenuKeyFunc &keys); diff --git a/port/src/menu_mouse_menus.cpp b/port/src/menu_mouse_menus.cpp index 5cccb3cf4..7467842dd 100644 --- a/port/src/menu_mouse_menus.cpp +++ b/port/src/menu_mouse_menus.cpp @@ -6,6 +6,8 @@ #include "gamepad.hpp" #include "mainloop.hpp" #include "menu_mouse.hpp" +#include "menu_mouse_make.hpp" +#include "menu_mouse_monster.hpp" #include "menu_mouse_shop.hpp" #include "menucls1.hpp" #include "menucommon.hpp" @@ -100,6 +102,10 @@ PORT_OVERRIDE int CMenuKeyFunc::CheckSelectKey() { if (now_mode == MENU_MODE_ITEM_SELECT) { ItemPopupMouse(); } + if (now_mode == MENU_MODE_INVENT || now_mode == MENU_MODE_INVENT_DIRECT) { + MenuMouseMakeTick(*this); + } + MenuMouseMonsterTick(*this); } if (GamePad__2.Down(PAD_UP)) { select_key |= MENU_SELECT_KEY_UP; diff --git a/port/src/menu_mouse_monster.cpp b/port/src/menu_mouse_monster.cpp new file mode 100644 index 000000000..65bcf3b95 --- /dev/null +++ b/port/src/menu_mouse_monster.cpp @@ -0,0 +1,101 @@ +#include "menu_mouse_monster.hpp" + +#include +#include +#include + +#include "gamepad.hpp" +#include "mainloop.hpp" +#include "menuchr.hpp" +#include "menucommon.hpp" +#include "menumain.hpp" +#include "menusys.hpp" +#include "mglib_port.hpp" +#include "stubs/menuchr.hpp" + +namespace { +CMenuMosSelect *box_keys = nullptr; +int tick_frame = -1; + +void BadgeMouse(CMenuMosSelect &box) { + MenuMouseRect cells[MOS_SELECT_BADGE_NUM]{}; + for (int i = 0; i < MOS_SELECT_BADGE_NUM; ++i) { + char name[8]; + std::snprintf(name, sizeof(name), "B%d", i); + // AttachForm hides the parts of badges Monica does not have. + cells[i] = MenuMouseBadgeCell(MenuMousePart(box.badge_form, name)); + } + // Badges are three to a row, as KeyStep's own select / 3 assumes; a wheel notch moves one row. + const int selected = MenuMouseRows(cells, box.select, 3); + if (selected == box.select) { + return; + } + // KeyNormalMode's own side effects of a cursor move. + box.select = selected; + MenuLoadInfo.update_scene = false; + box.view_monster = -1; + if (selected < MOS_SELECT_BADGE_KIND_NUM && box.badge[selected].enable) { + box.view_monster = box.badge[selected].monster_id; + } + MenuSePlay(SYSTEM_SE_CURSOR); +} + +void ClassMouse() { + const int count = std::clamp(int(BuildUpWeaponInfo.select_num), 0, 3); + MenuMouseRect rows[3]{}; + for (int i = 0; i < count; ++i) { + rows[i] = {float(BuildUpNameXY[i][0]), float(BuildUpNameXY[i][1]), 200, 24}; + } + const int selected = MenuMouseRows({rows, static_cast(count)}, BuildUpWeaponInfo.select_no); + if (selected != BuildUpWeaponInfo.select_no) { + BuildUpWeaponInfo.select_no = selected; + MenuSePlay(SYSTEM_SE_CURSOR); + } +} +} // namespace + +MenuMouseRect MenuMouseBadgeCell(const MenuMouseRect &badge) { + if (badge.w <= 0 || badge.h <= 0) { + return {}; + } + return {badge.x - 1, badge.y - 1, badge.w + 2, badge.h + 2}; +} + +void MenuMouseMonsterTick(CMenuKeyFunc &keys) { + if (box_keys) { + MenuMouseMonster(*box_keys, keys); + } +} + +void MenuMouseMonster(CMenuMosSelect &box, CMenuKeyFunc &keys) { + if (&keys != MenuCommonInfo || !keys.key_enable || tick_frame == mgPortFrameCount()) { + return; + } + tick_frame = mgPortFrameCount(); + const auto &mouse = MenuMouseTake(); + if (!mouse.pointing || GamePad__2.key_lock || menu_debug_flag || box.mode != MENU_ASK_MODE_NONE) { + return; + } + if (box.key_arg_no == MOS_SELECT_PAGE_BADGE) { + BadgeMouse(box); + } else if (box.key_arg_no == MOS_SELECT_PAGE_COMMAND && box.step == MOS_SELECT_STEP_CLASS_SELECT && + BuildUpWeaponInfo.mode == 1) { + ClassMouse(); + } else if (box.key_arg_no == MOS_SELECT_PAGE_STATUS) { + // The status page has no pointer targets: a left click must not send its rejected Cross. + // The wheel steps through the badge's forms, as L1 and R1 do. + int buttons = mouse.clicked & 2 ? PAD_CIRCLE : 0; + if (std::isfinite(mouse.wheel) && mouse.wheel != 0) { + buttons |= mouse.wheel > 0 ? PAD_L1 : PAD_R1; + } + MenuMouseSetButtons(buttons); + } +} + +PORT_OVERRIDE int MenuMonsterBoxKey() { + // The box's keys are read only inside its own key step. + box_keys = MenuMosSelectPtr; + const int result = MenuMosSelectPtr->KeyStep(); + box_keys = nullptr; + return result; +} diff --git a/port/src/menu_mouse_monster.hpp b/port/src/menu_mouse_monster.hpp new file mode 100644 index 000000000..c7be6df83 --- /dev/null +++ b/port/src/menu_mouse_monster.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "menu_mouse.hpp" + +class CMenuKeyFunc; +class CMenuMosSelect; + +// A drawn badge's hit cell. Badges are 64 units square, 66 apart; the cell covers the gap between them. +MenuMouseRect MenuMouseBadgeCell(const MenuMouseRect &badge); + +// Called from CheckSelectKey; acts only while MenuMonsterBoxKey runs the box's key step. +void MenuMouseMonsterTick(CMenuKeyFunc &keys); +// The box's pointer input for one key step. +void MenuMouseMonster(CMenuMosSelect &box, CMenuKeyFunc &keys); diff --git a/port/src/menu_mouse_shop.cpp b/port/src/menu_mouse_shop.cpp index afa041e88..0f9bac333 100644 --- a/port/src/menu_mouse_shop.cpp +++ b/port/src/menu_mouse_shop.cpp @@ -14,19 +14,6 @@ CShopMenu *shop_menu = nullptr; CShop *shop_stock = nullptr; CMenuPosDataManage *shop_forms = nullptr; -MenuMouseRect Part(CMenuPosDataForm *form, const char *name) { - if (!form || !form->draw_flag) { - return {}; - } - auto *part = form->GetPartInfo(const_cast(name)); - if (!part || !part->draw_flag) { - return {}; - } - float x, y; - form->GetPutPosXY(part->name, x, y); - return {x, y, float(part->w), float(part->h)}; -} - int BagHit(const MenuMouseSample &mouse, CMenuPosDataForm *board, int count) { if (!board || !board->draw_flag) { return -1; @@ -97,18 +84,18 @@ void MenuMouseShopUpdate(CShopMenu &menu, CMenuKeyFunc &keys, int goods) { buttons |= PAD_CROSS; } } else if (menu.key_arg_no == SHOP_MENU_MODE_BUY_NUM || menu.key_arg_no == SHOP_MENU_MODE_SELL_NUM) { - const auto number = Part(menu.trade_brd, "\x90\x94"); - const auto up = Part(menu.trade_brd, "\x96\xEE\x88\xF3\x8F\xE3"); - const auto down = Part(menu.trade_brd, "\x96\xEE\x88\xF3\x89\xBA"); + const auto number = MenuMousePart(menu.trade_brd, "\x90\x94"); + const auto up = MenuMousePart(menu.trade_brd, "\x96\xEE\x88\xF3\x8F\xE3"); + const auto down = MenuMousePart(menu.trade_brd, "\x96\xEE\x88\xF3\x89\xBA"); if (menu.trade_brd && menu.trade_brd->draw_flag) { // The number itself is a zero-sized digit anchor. Hit its three // drawn frame pieces and the localized Quit button instead. const bool on_number = number.Contains(mouse.x, mouse.y) || up.Contains(mouse.x, mouse.y) || down.Contains(mouse.x, mouse.y) || - Part(menu.trade_brd, "\x90\x94\x94\xC2\x31").Contains(mouse.x, mouse.y) || - Part(menu.trade_brd, "\x90\x94\x94\xC2\x32").Contains(mouse.x, mouse.y) || - Part(menu.trade_brd, "\x90\x94\x94\xC2\x33").Contains(mouse.x, mouse.y); - const bool on_quit = Part(menu.trade_brd, "NO").Contains(mouse.x, mouse.y); + MenuMousePart(menu.trade_brd, "\x90\x94\x94\xC2\x31").Contains(mouse.x, mouse.y) || + MenuMousePart(menu.trade_brd, "\x90\x94\x94\xC2\x32").Contains(mouse.x, mouse.y) || + MenuMousePart(menu.trade_brd, "\x90\x94\x94\xC2\x33").Contains(mouse.x, mouse.y); + const bool on_quit = MenuMousePart(menu.trade_brd, "NO").Contains(mouse.x, mouse.y); if (choose && on_number) { menu.num_cursor = 0; } else if (choose && on_quit) { diff --git a/port/src/tests/menu_mouse_item_test.cpp b/port/src/tests/menu_mouse_item_test.cpp index 1fc4d0905..2a9777c8b 100644 --- a/port/src/tests/menu_mouse_item_test.cpp +++ b/port/src/tests/menu_mouse_item_test.cpp @@ -1,5 +1,7 @@ #include +#include + #include "../menu_mouse_item.hpp" #include "gamepad.hpp" #include "mainloop.hpp" @@ -12,6 +14,9 @@ #include "mglib.hpp" #include "platform/clock.hpp" #include "platform/input.hpp" +#include "savedata.hpp" + +extern CSaveData *PortActiveSaveData asm("MainLoop_ActiveSaveData"); using namespace menumouse; @@ -262,12 +267,77 @@ TEST_F(ItemMouse, WheelChangesVisibleRowAndCursorTogether) { EXPECT_EQ(keys.cursor, 30); EXPECT_EQ(keys.top_line, 5); EXPECT_TRUE(keys.CheckKeyInput()); - EXPECT_FLOAT_EQ(MenuItemBrdUnderBrdPosXY[1], -200); Tick(0, 0, 0, 100); EXPECT_EQ(keys.cursor, 0); EXPECT_EQ(keys.top_line, 0); } +TEST_F(ItemMouse, WheelScrollEasesLikeTheRetailDpadScroll) { + const auto saved_manner = MenuItemBrdCalcManner; + MenuItemBrdCalcManner = MENU_ITEM_BRD_CALC_EASE; + MenuItemBrdUnderBrdPosXY[1] = 50; // The retail board origin for top line 0. + Tick(1, 0, 0, -1); + EXPECT_EQ(keys.top_line, 1); + EXPECT_EQ(keys.cursor, 6); // The cell that settles under the pointer. + EXPECT_EQ(MenuItemBrdCalcManner, MENU_ITEM_BRD_CALC_EASE); + EXPECT_FLOAT_EQ(MenuItemBrdUnderBrdPosXY[1], 50); // Not snapped by the mouse. + Func_MenuItemBrdPosStep(keys.top_line); + EXPECT_LT(MenuItemBrdUnderBrdPosXY[1], 50); + EXPECT_GT(MenuItemBrdUnderBrdPosXY[1], 0); // Part way to the next row. + // Row 0 is sliding out: hovering it no longer moves the cursor back. + Tick(0, 1); // Still over the drawn first row. + EXPECT_EQ(keys.cursor, 6); + EXPECT_EQ(keys.top_line, 1); + for (int i = 0; i < 60; ++i) { + Func_MenuItemBrdPosStep(keys.top_line); + } + EXPECT_NEAR(MenuItemBrdUnderBrdPosXY[1], 0, 1); + MenuItemBrdCalcManner = saved_manner; +} + +TEST_F(ItemMouse, ClickDuringAnEasedWheelScrollConfirmsTheClickedCell) { + const auto saved_manner = MenuItemBrdCalcManner; + MenuItemBrdCalcManner = MENU_ITEM_BRD_CALC_EASE; + MenuItemBrdUnderBrdPosXY[1] = 50; + items[6].item_no = 1; + items[12].item_no = 1; + // Mouse1 and a wheel notch arrive together over cell 6 at (263, 130). + Tick(1, 55, 1, -1); + EXPECT_EQ(keys.top_line, 1); + EXPECT_EQ(keys.cursor, 12); // The cell that will settle under the pointer. + EXPECT_EQ(GamePad__2.GetPadDown(), 0); + MenuItemBrdUnderBrdPosXY[1] = 37.5f; // Part way through the retail ease; cell 6 is still drawn there. + Tick(); + EXPECT_EQ(keys.cursor, 6); + EXPECT_EQ(keys.top_line, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); + MenuItemBrdCalcManner = saved_manner; +} + +TEST_F(ItemMouse, WheelOverTheBoardTopStripKeepsTheCursorInTheNewRows) { + MenuItemBrdUnderBrdPosXY[1] = 50; + // (263, 45) is inside the aperture but above the first resting row. + Tick(1, -30, 0, -1); + EXPECT_EQ(keys.top_line, 1); + EXPECT_EQ(keys.cursor, 6); + // The retail key pass that follows keeps the new top line. + auto save = std::make_unique(); + auto *saved = PortActiveSaveData; + PortActiveSaveData = save.get(); + MenuItemBrdKey(0, &keys.cursor, &keys.top_line, ITEM_BOARD_MAIN); + PortActiveSaveData = saved; + EXPECT_EQ(keys.top_line, 1); + EXPECT_EQ(keys.cursor, 6); +} + +TEST(MenuMouse, VisibleRowUsesTheTargetTopLine) { + EXPECT_TRUE(VisibleRow(6, 1, 5)); + EXPECT_FALSE(VisibleRow(5, 1, 5)); + EXPECT_TRUE(VisibleRow(35, 1, 5)); + EXPECT_FALSE(VisibleRow(36, 1, 5)); + EXPECT_FALSE(VisibleRow(-1, 0, 5)); +} + TEST_F(ItemMouse, DragEmitsRetailPickupThenCrossAtDestination) { items[0].item_no = 1; Tick(0, 0, 1); diff --git a/port/src/tests/menu_mouse_make_test.cpp b/port/src/tests/menu_mouse_make_test.cpp new file mode 100644 index 000000000..dc0b2ff1a --- /dev/null +++ b/port/src/tests/menu_mouse_make_test.cpp @@ -0,0 +1,432 @@ +#include + +#include +#include + +#include "../menu_mouse_make.hpp" +#include "../menu_mouse_monster.hpp" +#include "gamepad.hpp" +#include "inventmn.hpp" +#include "mainloop.hpp" +#include "menuchr.hpp" +#include "menudraw.hpp" +#include "menumain.hpp" +#include "menusys.hpp" +#include "mglib.hpp" +#include "platform/clock.hpp" +#include "platform/input.hpp" + +extern CMenuInvent *TestInventPt asm("MenuMouse_Invent"); + +using namespace menumouse; + +TEST(MenuMouseMake, CardRowsAreHalfOpenAndFollowTheEasedList) { + const Point list{30, 72}; + EXPECT_EQ(CardHit({30, 72}, list, 72, 5, 20), 0); + EXPECT_EQ(CardHit({260.99f, 117.99f}, list, 72, 5, 20), 0); + EXPECT_EQ(CardHit({30, 118}, list, 72, 5, 20), 1); + EXPECT_EQ(CardHit({100, 72 + 4 * 46 + 45}, list, 72, 5, 20), 4); + EXPECT_EQ(CardHit({261, 80}, list, 72, 5, 20), -1); + EXPECT_EQ(CardHit({29, 80}, list, 72, 5, 20), -1); + // Rows above or below the five-row window are clipped. + EXPECT_EQ(CardHit({100, 71}, {30, 26}, 72, 5, 20), -1); + EXPECT_EQ(CardHit({100, 72 + 5 * 46}, list, 72, 5, 20), -1); + // A list scrolled two rows puts card 2 in the first visible row; mid-ease it follows the drawn origin. + EXPECT_EQ(CardHit({100, 80}, {30, 72 - 92}, 72, 5, 20), 2); + EXPECT_EQ(CardHit({100, 80}, {30, 72 - 60}, 72, 5, 20), 1); + EXPECT_EQ(CardHit({100, 200}, list, 72, 5, 2), -1); +} + +TEST(MenuMouseMake, WheelStepIsSharedWithListChoice) { + EXPECT_EQ(MenuMouseWheelStep(3, -1, 10), 4); + EXPECT_EQ(MenuMouseWheelStep(3, 2, 10), 1); + EXPECT_EQ(MenuMouseWheelStep(0, 5, 10), 0); + EXPECT_EQ(MenuMouseWheelStep(8, -100, 10), 9); + EXPECT_EQ(MenuMouseWheelStep(4, 0, 10), 4); + EXPECT_EQ(MenuMouseWheelStep(3, -1, 12, 3), 6); +} + +namespace { +// The Make menu as town:10 draws it: cards at x 30-261 from y 72, the item board's +// first column (x 236-276) under the card list's right edge. +MakeLists Lists() { + MakeLists lists; + lists.cards = lists.board = true; + lists.card_count = 20; + lists.item_count = 60; + lists.card_list = {30, 72}; + lists.card_clip = 72; + lists.board_origin = {236, 50}; + lists.board_clip = {236, 44, 240, 250}; + lists.board_rest = {236, 50}; + return lists; +} +} // namespace + +TEST(MenuMouseMake, HoverMovesTheCursorOfThePaneInFront) { + auto result = MakeListsUpdate(Lists(), {100, 170}, true, 0, 0); + EXPECT_FALSE(result.state.items_front); + EXPECT_EQ(result.state.card_cursor, 2); + EXPECT_TRUE(result.sound); + EXPECT_EQ(result.buttons, 0); + result = MakeListsUpdate(Lists(), {100, 170}, false, 0, 0); + EXPECT_EQ(result.state.card_cursor, 0); // A resting pointer leaves the keyboard's choice alone. +} + +TEST(MenuMouseMake, OverlapKeepsThePaneInFront) { + // (250, 100) is both card 0 and bag cell 6. + auto result = MakeListsUpdate(Lists(), {250, 100}, true, 0, 0); + EXPECT_FALSE(result.state.items_front); + EXPECT_EQ(result.state.card_cursor, 0); + auto items = Lists(); + items.items_front = true; + result = MakeListsUpdate(items, {250, 100}, true, 0, 0); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.item_cursor, 6); +} + +TEST(MenuMouseMake, HoveringTheOtherPaneSwitchesWithoutConfirmingOrDoubleSound) { + auto result = MakeListsUpdate(Lists(), {300, 100}, false, 1, 0); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.item_cursor, 7); + EXPECT_FALSE(result.sound); // The retail pane change plays its own sound. + EXPECT_EQ(result.buttons, 0); + auto items = result.state; + result = MakeListsUpdate(items, {300, 100}, false, 1, 0); + EXPECT_EQ(result.buttons, PAD_CROSS); + EXPECT_EQ(MakeListsUpdate(items, {300, 100}, false, 2, 0).buttons, PAD_CIRCLE); + EXPECT_EQ(MakeListsUpdate(items, {490, 400}, false, 1, 0).buttons, 0); // Background. +} + +TEST(MenuMouseMake, HeldItemKeepsTheItemBoard) { + auto lists = Lists(); + lists.items_front = true; + lists.cards = false; // An item on the hand: retail redirects the card list to the board. + auto result = MakeListsUpdate(lists, {100, 170}, true, 1, -1); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.card_cursor, 0); + EXPECT_EQ(result.buttons, 0); +} + +TEST(MenuMouseMake, WheelScrollsThePaneUnderThePointer) { + auto result = MakeListsUpdate(Lists(), {100, 300}, false, 0, -6); + EXPECT_EQ(result.state.card_cursor, 6); + EXPECT_EQ(result.state.card_top, 2); + EXPECT_TRUE(result.sound); + // The wheel over the board brings it forward and selects the cell that settles under the pointer. + result = MakeListsUpdate(Lists(), {300, 100}, false, 0, -1); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.item_top, 1); + EXPECT_EQ(result.state.item_cursor, 13); + EXPECT_FALSE(result.sound); + EXPECT_EQ(result.buttons, 0); +} + +TEST(MenuMouseMake, ClickWithAWheelNotchConfirmsTheDrawnTarget) { + auto result = MakeListsUpdate(Lists(), {100, 100}, false, 1, -1); + EXPECT_EQ(result.state.card_cursor, 0); + EXPECT_EQ(result.state.card_top, 0); + EXPECT_EQ(result.buttons, PAD_CROSS); + auto items = Lists(); + items.items_front = true; + result = MakeListsUpdate(items, {300, 100}, false, 1, -1); + EXPECT_EQ(result.state.item_top, 0); + EXPECT_EQ(result.state.item_cursor, 7); + EXPECT_EQ(result.buttons, PAD_CROSS); +} + +TEST(MenuMouseMake, WheelOverTheBoardTopStripKeepsTheCursorInTheNewRows) { + auto items = Lists(); + items.items_front = true; + // (300, 45) is inside the aperture but above the first resting row. + auto result = MakeListsUpdate(items, {300, 45}, false, 0, -1); + EXPECT_EQ(result.state.item_top, 1); + EXPECT_EQ(result.state.item_cursor, 6); + // The retail scroll check that MenuItemBrdKey runs next keeps the new top line. + int top = result.state.item_top; + MenuCheckLine(&top, result.state.item_cursor / 6, 5); + EXPECT_EQ(top, 1); +} + +TEST(MenuMouseMake, AnEasingBoardRowDoesNotLetTheCardsBehindTakeInput) { + auto items = Lists(); + items.items_front = true; + // (250, 80) is over card 0 and, in front of it, bag cell 6. + auto result = MakeListsUpdate(items, {250, 80}, false, 0, -1); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.item_top, 1); + EXPECT_EQ(result.state.item_cursor, 6); + auto easing = result.state; + easing.board_origin.y = 37.5f; // Row 0 is still drawn under the pointer. + result = MakeListsUpdate(easing, {250, 80}, false, 0, -1); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.item_top, 2); + EXPECT_EQ(result.state.card_cursor, 0); + result = MakeListsUpdate(easing, {250, 80}, true, 1, 0); + EXPECT_TRUE(result.state.items_front); + EXPECT_EQ(result.state.card_cursor, 0); + EXPECT_EQ(result.state.item_cursor, 6); + EXPECT_EQ(result.buttons, 0); +} + +TEST(MenuMouseMake, RowsSlidingOutOfTheBoardAreNotHit) { + auto lists = Lists(); + lists.items_front = true; + lists.item_top = 1; + lists.board_origin = {236, 20}; // Easing from top 0 towards top 1: row 0 is still drawn. + auto result = MakeListsUpdate(lists, {300, 50}, true, 1, 0); + EXPECT_EQ(result.state.item_cursor, 0); + EXPECT_EQ(result.buttons, 0); + result = MakeListsUpdate(lists, {300, 80}, true, 0, 0); + EXPECT_EQ(result.state.item_cursor, 7); +} + +namespace { +// CommonBoardDraw for a board at (152, 30) with the English minimum width. +const float kInfo[5] = {172, 276, 172, 304, 248}; +const MakeBoard kBoard = MakeBoardRects({152, 30}, kInfo); +} // namespace + +TEST(MenuMouseMake, BoardRectsFollowTheRetailDrawPlacement) { + EXPECT_FLOAT_EQ(kBoard.make.x, 172); + EXPECT_FLOAT_EQ(kBoard.make.y, 276); + EXPECT_FLOAT_EQ(kBoard.quit.y, 304); + // arrow_x = 10 + 152 + 124 + 24.8 + 2, truncated as the draw does. + EXPECT_FLOAT_EQ(kBoard.fewer.x, 312); + EXPECT_FLOAT_EQ(kBoard.fewer.y, 280); + EXPECT_FLOAT_EQ(kBoard.count.x, 328); + EXPECT_FLOAT_EQ(kBoard.more.x, 362); + EXPECT_FALSE(kBoard.make.Contains({172, 302})); + EXPECT_TRUE(kBoard.quit.Contains({172, 304})); +} + +TEST(MenuMouseMake, BoardClicksAndWheelBecomeRetailKeys) { + EXPECT_EQ(MakeBoardInput(kBoard, {180, 280}, false, 1, 0).buttons, PAD_CROSS); + EXPECT_EQ(MakeBoardInput(kBoard, {315, 290}, false, 1, 0).buttons, PAD_LEFT); + EXPECT_EQ(MakeBoardInput(kBoard, {365, 290}, false, 1, 0).buttons, PAD_RIGHT); + EXPECT_EQ(MakeBoardInput(kBoard, {340, 290}, false, 1, 0).buttons, PAD_CROSS); + EXPECT_EQ(MakeBoardInput(kBoard, {20, 20}, false, 1, 0).buttons, 0); + EXPECT_EQ(MakeBoardInput(kBoard, {340, 290}, true, 0, 0).row, 0); // The count belongs to the Make row. + auto wheel = MakeBoardInput(kBoard, {180, 310}, false, 0, 1); + EXPECT_EQ(wheel.row, 0); + EXPECT_EQ(wheel.buttons, PAD_RIGHT); +} + +namespace { +// Drives the adapters through the retail CheckSelectKey, as MenuInventKey and KeyStep do. +// The retail menus' constructors are unit-local, so the tests use zeroed storage and set +// only the members the adapters read; no virtual member is called. +class MakeMouse : public testing::Test { +protected: + CMenuKeyFunc keys; + alignas(CMenuInvent) unsigned char invent_storage[sizeof(CMenuInvent)] {}; + alignas(CMenuMosSelect) unsigned char box_storage[sizeof(CMenuMosSelect)] {}; + CMenuInvent &invent = *reinterpret_cast(invent_storage); + CMenuMosSelect &box = *reinterpret_cast(box_storage); + CMenuPosDataForm makebrd{}, badges{}; + MENUFORMPARTS_TYPE badge_parts[MOS_SELECT_BADGE_NUM]{}; + char badge_names[MOS_SELECT_BADGE_NUM][4]{}; + MOS_CHANGE_PARAM badge[MOS_SELECT_BADGE_NUM]{}; + CMenuKeyFunc *saved_keys = MenuCommonInfo; + CMenuInvent *saved_invent = TestInventPt; + CGamePad saved_pad = GamePad__2; + BUILDUP_WEAPON_INFO saved_buildup = BuildUpWeaponInfo; + MENU_LOAD_INFO saved_load = MenuLoadInfo; + float saved_info[5]{}; + s16 saved_names[3][2]{}; + int saved_width = mgScreenWidth, saved_height = mgScreenHeight; + + void SetUp() override { + std::memcpy(saved_info, MakeBoardDrawInfo, sizeof(saved_info)); + std::memcpy(saved_names, BuildUpNameXY, sizeof(saved_names)); + MenuMouseEnable(false); + InputSetScriptedDevices({}); + mgEndFrame(nullptr); + MenuMouseTake(); + InputResetBindings(); + mgScreenWidth = 512; + mgScreenHeight = 480; + MenuCommonInfo = &keys; + GamePad__2.key_lock = 0; + GamePad__2.pad[0].button = GamePad__2.previous_pad[0].button = 0; + keys.key_enable = true; + keys.now_mode = MENU_MODE_INVENT; + TestInventPt = &invent; + std::memcpy(MakeBoardDrawInfo, kInfo, sizeof(kInfo)); + makebrd.x = 152; + makebrd.y = 30; + makebrd.draw_flag = true; + invent.makebrd_form = &makebrd; + badges.draw_flag = true; + badges.parts = badge_parts; + badges.parts_num = MOS_SELECT_BADGE_NUM; + for (int i = 0; i < MOS_SELECT_BADGE_NUM; ++i) { + std::snprintf(badge_names[i], sizeof(badge_names[i]), "B%d", i); + auto &part = badge_parts[i]; + part.name = badge_names[i]; + part.dtype = MENUFORMPARTS_DTYPE_FUNCINFO; + part.x = 40.0f + 66 * (i % 3); + part.y = 82.0f + 66 * (i / 3); + part.w = part.h = 64; + part.draw_flag = i == 0 || i == 3; // AttachForm shows only the badges Monica has. + badge[i].enable = part.draw_flag; + badge[i].monster_id = static_cast(100 + i); + } + box.badge_form = &badges; + box.badge = badge; + ClockSetUnbounded(true); + MenuMouseEnable(true); + } + + void TearDown() override { + MenuMouseEnable(false); + InputSetScriptedDevices({}); + mgEndFrame(nullptr); + MenuMouseTake(); + InputResetBindings(); + MenuCommonInfo = saved_keys; + TestInventPt = saved_invent; + GamePad__2 = saved_pad; + BuildUpWeaponInfo = saved_buildup; + MenuLoadInfo = saved_load; + std::memcpy(MakeBoardDrawInfo, saved_info, sizeof(saved_info)); + std::memcpy(BuildUpNameXY, saved_names, sizeof(saved_names)); + mgScreenWidth = saved_width; + mgScreenHeight = saved_height; + } + + // The pointer starts at the screen centre (256, 240). + void Tick(float dx = 0, float dy = 0, unsigned buttons = 0, float wheel = 0) { + InputSetScriptedDevices({.mouse_buttons = buttons, .mouse_dx = dx, .mouse_dy = dy, .mouse_wheel = wheel}); + mgEndFrame(nullptr); + keys.CheckSelectKey(); + } + + // One key step of the badge box, as MenuMonsterBoxKey runs it. + void BoxTick(float dx = 0, float dy = 0, unsigned buttons = 0, float wheel = 0) { + InputSetScriptedDevices({.mouse_buttons = buttons, .mouse_dx = dx, .mouse_dy = dy, .mouse_wheel = wheel}); + mgEndFrame(nullptr); + MenuMouseMonster(box, keys); + } +}; +} // namespace + +TEST_F(MakeMouse, QuantityBoardHoverAndClicksDriveTheRetailRow) { + invent.mode = MENU_ASK_MODE_MAKE; + invent.step = INVENT_MAKE_STEP_SELECT; + invent.make_cursor = 0; + Tick(-76, 70); // Quit at (180, 310). + EXPECT_EQ(invent.make_cursor, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), 0); + Tick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); + Tick(135, -20); // The fewer arrow at (315, 290). + EXPECT_EQ(invent.make_cursor, 0); + Tick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_LEFT); + Tick(-200, -200, 0); // Background. + Tick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), 0); +} + +TEST_F(MakeMouse, PagesWithoutTargetsIgnoreLeftClicksAndBackOutOnRightClicks) { + invent.mode = MENU_ASK_MODE_NONE; + invent.key_arg_no = INVENT_MODE_THINK; + Tick(5, 0); + Tick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), 0); + Tick(0, 0, 0); + Tick(0, 0, 2); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CIRCLE); +} + +TEST_F(MakeMouse, BadgeInputActsOnlyInsideTheBoxKeyStep) { + box.key_arg_no = MOS_SELECT_PAGE_BADGE; + keys.now_mode = MENU_MODE_CHARA_CHANGE; + Tick(-184, -60); // Over badge 3, outside MenuMonsterBoxKey. + EXPECT_EQ(box.select, 0); + BoxTick(1, 0); + EXPECT_EQ(box.select, 3); + EXPECT_EQ(box.view_monster, 103); + EXPECT_FALSE(MenuLoadInfo.update_scene); + BoxTick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); +} + +TEST_F(MakeMouse, HiddenBadgesAndBackgroundTakeNoClicksButTheWheelMovesRows) { + box.key_arg_no = MOS_SELECT_PAGE_BADGE; + keys.now_mode = MENU_MODE_CHARA_CHANGE; + BoxTick(-118, -60); // Badge 4 has no part drawn. + EXPECT_EQ(box.select, 0); + BoxTick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), 0); + BoxTick(0, 0, 0, -1); + EXPECT_EQ(box.select, 3); + EXPECT_EQ(box.view_monster, 103); +} + +TEST_F(MakeMouse, BadgeClickWithAWheelNotchOpensTheClickedBadge) { + box.key_arg_no = MOS_SELECT_PAGE_BADGE; + keys.now_mode = MENU_MODE_CHARA_CHANGE; + BoxTick(-184, -140, 1, -1); // (72, 100): badge 0. + EXPECT_EQ(box.select, 0); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); +} + +TEST_F(MakeMouse, ClassRowClickWithAWheelNotchConfirmsTheClickedRow) { + keys.now_mode = MENU_MODE_CHARA_CHANGE; + box.key_arg_no = MOS_SELECT_PAGE_COMMAND; + box.step = MOS_SELECT_STEP_CLASS_SELECT; + BuildUpWeaponInfo.mode = 1; + BuildUpWeaponInfo.select_num = 3; + BuildUpWeaponInfo.select_no = 0; + for (int i = 0; i < 3; ++i) { + BuildUpNameXY[i][0] = 200; + BuildUpNameXY[i][1] = static_cast(200 + 30 * i); + } + BoxTick(-46, -35, 1, -1); // (210, 205): the first row. + EXPECT_EQ(BuildUpWeaponInfo.select_no, 0); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); +} + +TEST_F(MakeMouse, ClassChangeRowsHoverThenConfirm) { + keys.now_mode = MENU_MODE_CHARA_CHANGE; + box.key_arg_no = MOS_SELECT_PAGE_COMMAND; + box.step = MOS_SELECT_STEP_CLASS_SELECT; + BuildUpWeaponInfo.mode = 1; + BuildUpWeaponInfo.select_num = 3; + BuildUpWeaponInfo.select_no = 0; + for (int i = 0; i < 3; ++i) { + BuildUpNameXY[i][0] = 200; + BuildUpNameXY[i][1] = static_cast(200 + 30 * i); + } + BoxTick(10, 25); // (266, 265): the third row. + EXPECT_EQ(BuildUpWeaponInfo.select_no, 2); + BoxTick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); +} + +TEST_F(MakeMouse, StatusPageWheelStepsFormsAndLeftClickDoesNothing) { + keys.now_mode = MENU_MODE_CHARA_CHANGE; + box.key_arg_no = MOS_SELECT_PAGE_STATUS; + BoxTick(5, 0); + BoxTick(0, 0, 1); + EXPECT_EQ(GamePad__2.GetPadDown(), 0); + BoxTick(0, 0, 0, -1); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_R1); + BoxTick(0, 0, 2); + EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CIRCLE); +} + +TEST(MenuMouseMake, BadgeCellsTileTheBoxWithoutOverlap) { + MenuMouseRect cells[12]; + for (int i = 0; i < 12; ++i) { + cells[i] = MenuMouseBadgeCell({40.0f + 66 * (i % 3), 82.0f + 66 * (i / 3), 64, 64}); + } + EXPECT_EQ(MenuMouseHit(cells, 40, 82), 0); + EXPECT_EQ(MenuMouseHit(cells, 104.5f, 100), 0); // The gap belongs to a neighbour. + EXPECT_EQ(MenuMouseHit(cells, 105, 100), 1); + EXPECT_EQ(MenuMouseHit(cells, 236, 344), 11); + EXPECT_EQ(MenuMouseHit(cells, 237, 344), -1); + EXPECT_EQ(MenuMouseBadgeCell({}).w, 0); // A hidden badge stays empty. +} diff --git a/port/src/tests/menu_mouse_test.cpp b/port/src/tests/menu_mouse_test.cpp index e3710bc8c..415228d64 100644 --- a/port/src/tests/menu_mouse_test.cpp +++ b/port/src/tests/menu_mouse_test.cpp @@ -61,6 +61,13 @@ TEST(MenuMouse, ClickSelectsTheHoveredRowAndWheelClamps) { EXPECT_EQ(MenuMouseChoose(mouse, rows, 1), 1); } +TEST(MenuMouse, ClickWinsOverAWheelNotchInTheSameSample) { + MenuMouseSample mouse{.x = 40, .y = 90, .wheel = -1, .pointing = true, .clicked = 1}; + EXPECT_EQ(MenuMouseChoose(mouse, rows, 0), 1); + mouse.x = -100; // A click on the background still lets the notch move the selection. + EXPECT_EQ(MenuMouseChoose(mouse, rows, 0), 1); +} + TEST(MenuMouse, WindowPixelsFollowUiScalingAndHighDpiMapping) { EXPECT_FLOAT_EQ(MenuMouseUnits(3, 1536, 1536), 1.0f / 3); EXPECT_FLOAT_EQ(MenuMouseUnits(6, 3072, 1536), 1.0f / 3); diff --git a/tools/input/make-menu-mouse.input b/tools/input/make-menu-mouse.input new file mode 100644 index 000000000..e8a512a86 --- /dev/null +++ b/tools/input/make-menu-mouse.input @@ -0,0 +1,53 @@ +# --jump town:10 --fast-load, 1280x720: the Make menu with the mouse. +# Frames 720-990 use the retail debug keys (R3, Circle) to fill the invention cards. +# Capture 1240: hovering card 3. 1320: hovering the item board brings it forward. +# 1345/1400: a wheel notch eases the board one row. 1480: back on the cards. +# 1580: wheel notches move the card cursor and scroll the list. 1700: a click opens the quantity board. +# 1740: hovering Yes. 1820: hovering No. 1920: Yes reports missing materials. 2010: a click dismisses it. +# 2150: Mouse2 leaves the Make menu. +300 triangle +301 +400 down +401 +420 down +421 +500 cross +501 +720 cross +721 +900 r3 +901 +930 circle +931 +960 r3 +961 +990 circle +991 +1200 mouse:-210,-80 +1201 +1260 mouse:390,-100 +1261 +1340 wheel:-1 +1341 +1420 mouse:-600,0 +1421 +1500 wheel:-3 +1501 +1530 wheel:-2 +1531 +1600 mouse:0,1 +1601 +1620 mouse1 +1621 +1720 mouse:320,268 +1721 +1800 mouse:0,42 +1801 +1840 mouse:0,-42 +1841 +1860 mouse1 +1861 +1950 mouse1 +1951 +2030 mouse2 +2031 diff --git a/tools/input/monster-transform-mouse.input b/tools/input/monster-transform-mouse.input new file mode 100644 index 000000000..b8d7008ed --- /dev/null +++ b/tools/input/monster-transform-mouse.input @@ -0,0 +1,36 @@ +# --jump town:10 --fast-load, 1280x720: Character > Monster Transform with the mouse. +# Capture 940: hovering the plant badge selects it. 1040: its commands after a click. +# 1140: View Status clicked. 1380: a click beside the commands confirms nothing. +# 1500: Mouse2 back on the badges. 1560: a wheel notch moves down a row. 1750: Mouse2 closed the box. +300 triangle +301 +400 down +401 +500 cross +501 +650 left +651 +700 left +701 +760 cross +761 +900 mouse:-345,-90 +901 +960 mouse1 +961 +1010 mouse:45,100 +1011 +1060 mouse1 +1061 +1250 mouse2 +1251 +1300 mouse:0,-100 +1301 +1320 mouse1 +1321 +1400 mouse2 +1401 +1520 wheel:-1 +1521 +1600 mouse2 +1601 From 586389804fdaacb2cbac4421d4d517b12541e006 Mon Sep 17 00:00:00 2001 From: carrdylan52 <17089587+carrdylan52@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:02:00 -0400 Subject: [PATCH 2/5] Port: add mouse to the Character, Options and Help menus Character page portraits take hover and click; only a portrait click confirms, and left clicks do nothing while a character change loads. Cedric's repair board takes hover, wheel and item clicks, and its input no longer reaches his command list after it closes. The menu pointer keeps one facing: retail mirrors the hand at Max, which made the pointer's body jump across its hotspot. Retail Options rows and buttons follow hover and the wheel scrolls the list; Help entries follow hover and scroll, and the wheel turns its picture pages. Rows are hit where they are drawn and only inside the list's clip, so a row sliding under the footer cannot be clicked. --- docs/PC.md | 42 ++ port/include/stubs/menuchr.hpp | 2 + port/include/stubs/menuop.hpp | 3 + port/src/menu_mouse.cpp | 69 +++- port/src/menu_mouse.hpp | 39 +- port/src/menu_mouse_menus.cpp | 278 ++++++++++++- port/src/menu_mouse_message.cpp | 2 +- port/src/menudraw.cpp | 2 +- port/src/menudraw_primquad.cpp | 2 +- port/src/tests/menu_mouse_test.cpp | 504 +++++++++++++++++++++++ port/src/tests/mglib_present_test.cpp | 33 +- tools/input/character-mouse-cedric.input | 38 ++ tools/input/character-mouse.input | 20 + tools/input/help-mouse.input | 53 +++ tools/input/options-mouse.input | 29 ++ 15 files changed, 1082 insertions(+), 34 deletions(-) create mode 100644 tools/input/character-mouse-cedric.input create mode 100644 tools/input/character-mouse.input create mode 100644 tools/input/help-mouse.input create mode 100644 tools/input/options-mouse.input diff --git a/docs/PC.md b/docs/PC.md index 77531dfce..8fe8f1643 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -667,6 +667,48 @@ pointer immediately. Ordinary dialogue choices share the message adapter; the rendered confirmation replays and drawn-message unit tests cover that input path, but campaign dialogue choices still need a dedicated replay. +### Character, Options and Help mouse + +On the Character page, hovering a shown portrait (Max, Monica, the Ridepod, +Monster Transform or the travelling townsperson) moves the retail cursor there, +and clicking it confirms; a click beside the portraits does nothing. While a +character change loads, left clicks do nothing. The change question, the +townsperson's View Status/Send back question, their commands and their +questions take clicks on their drawn choices; when a question opens over a +list that still draws its cursor, the newest window takes the mouse. On +Cedric's repair board, hover and the wheel select an item and only a click on +an item confirms, so a stray click cannot spend his points. Waits that take +any button (the opening, the townsperson's speech) still take any click. + +Retail mirrors the hand to point left at Max. The mouse pointer is never +mirrored: the shared pointer already kept the hotspot on the fingertip in either +facing, but a hand whose body jumps across the hotspot between neighbouring +portraits reads as pointing somewhere else, so it keeps its usual facing on +every target. + +On the retail Options page, hovering a row's label moves the row cursor and keeps +its choice; hovering a choice button also moves the choice. Clicking a button +selects that setting. The PC SETTINGS label opens that page on click. + +In Help, hovering an entry moves the cursor and a click opens it. On an entry's +picture pages the wheel turns pages (down for the next) through the retail +Left/Right keys; a click turns to the next page and right click returns, as Cross +and Circle do. Movies keep their retail controls. Keyboard and controller input +is unchanged on all three pages. + +The wheel follows one rule on these pages: in a list longer than its window, +each notch scrolls the list one row and the cursor takes the row that comes under +the pointer, or stays in view (`MenuMouseScroll`; the ITEM page's bag scrolls the +same way). Elsewhere it moves the cursor, as in shops and the root menu; the +Character page's portrait map is not a list, so the wheel does nothing there. Options and +Help rows are hit where they are drawn while the list eases to a new scroll, and +only inside the list's scissor (`clip0`): a row sliding under the footer cannot +be clicked, and a visible row above the new scroll still can. + +`tools/input/character-mouse.input`, `character-mouse-cedric.input`, `options-mouse.input` (`--jump town:10`) +and `help-mouse.input` (`--jump dungeon:0:1`) replay these at `--window 1920x1080` +with `--fast-load`; their comments give capture frames. + ### Mouse follow camera Mouse motion directly turns the active walking camera around the character. It diff --git a/port/include/stubs/menuchr.hpp b/port/include/stubs/menuchr.hpp index cd68e29d4..74541fdaa 100644 --- a/port/include/stubs/menuchr.hpp +++ b/port/include/stubs/menuchr.hpp @@ -2,5 +2,7 @@ #include #include "texture_color.hpp" +extern CMenuChrCngMenu *ChrChangMenuPt asm("MenuMouse_ChrChange"); + void PortRetailInitMainCharaBG(int, mgCMemory *, int) asm("MenuChr_InitMainCharaBG"); int PortRetailKeyMainCharaBG() asm("MenuChr_KeyMainCharaBG"); diff --git a/port/include/stubs/menuop.hpp b/port/include/stubs/menuop.hpp index e22e71571..f0b9d6587 100644 --- a/port/include/stubs/menuop.hpp +++ b/port/include/stubs/menuop.hpp @@ -3,3 +3,6 @@ extern CSaveMenuClass *SaveMenuPtr asm("MenuMouse_Save"); // Let the PC page wrap the original Options step without replacing its state machine. extern CMenuOption *CMenuOptionPtr asm("MenuOption_Current"); +// The menu pointer reads the option count and the Help list it scrolls. +extern float config_option_num_i asm("MenuOption_Count"); +extern CManualMenu *CManualPtr asm("MenuMouse_Manual"); diff --git a/port/src/menu_mouse.cpp b/port/src/menu_mouse.cpp index c37acb8cd..000ab3412 100644 --- a/port/src/menu_mouse.cpp +++ b/port/src/menu_mouse.cpp @@ -30,7 +30,7 @@ struct Hand { gfx::TextureBinding texture; gfx::LogicalRect uv{}; float rotation = 0, scale = 1; - int reverse = 0, alpha = 128, seen = -100; + int alpha = 128, seen = -100; } hand; struct Choices { @@ -40,6 +40,16 @@ struct Choices { std::vector rows; } choices; +// Choice windows drawn in the last two ticks: the tick each first appeared, the order it was first +// drawn in, and the tick it was last drawn. +struct ShownChoices { + const void *owner; + int message, first, order, last; +}; + +std::vector shown_choices; +int shown_order = 0; + struct ListClick { const void *owner = nullptr; int seen = -100, ready = 0, target = -1; @@ -67,6 +77,12 @@ int MenuMouseHit(std::span rows, float x, float y) { return -1; } +MenuMouseRect MenuMouseClip(MenuMouseRect rect, MenuMouseRect clip) { + const float left = std::max(rect.x, clip.x), top = std::max(rect.y, clip.y); + const float right = std::min(rect.x + rect.w, clip.x + clip.w), bottom = std::min(rect.y + rect.h, clip.y + clip.h); + return right > left && bottom > top ? MenuMouseRect{left, top, right - left, bottom - top} : MenuMouseRect{}; +} + int MenuMouseChoose(const MenuMouseSample &mouse, std::span rows, int selected, int wheel_step) { if (!mouse.pointing || rows.empty()) { return selected; @@ -81,6 +97,20 @@ int MenuMouseChoose(const MenuMouseSample &mouse, std::span return selected; } +MenuMouseView MenuMouseScroll(float wheel, MenuMouseView view, int count, int visible, int hovered) { + if (wheel == 0 || !std::isfinite(wheel) || count <= 0 || visible <= 0) { + return view; + } + const int notches = static_cast(std::clamp(wheel, -32.0f, 32.0f)); + view.top = std::clamp(view.top - notches, 0, std::max(0, count - visible)); + if (hovered >= 0 && view.top + hovered < count) { + view.selected = view.top + hovered; + } else { + view.selected = std::clamp(view.selected, view.top, std::min(count, view.top + visible) - 1); + } + return view; +} + namespace { bool PadOrFocusOwnsPointer() { const auto &pad = InputGetPad(0); @@ -146,11 +176,32 @@ void SetCapture(bool on) { void MenuMouseEnable(bool on) { navigation = on; choices = {}; + shown_choices.clear(); list_click = {}; SetCapture(on); } void MenuMouseNoteChoices(const void *owner, int message, std::span rows, int selected, bool horizontal) { + // A question can open over a list that still draws its cursor (Cedric's repair question over his + // commands). The newest window takes the mouse; alternating between them would drop every click. + const int now = mgPortFrameCount(); + std::erase_if(shown_choices, [&](const ShownChoices &window) { return window.last < now - 1; }); + auto window = std::ranges::find_if(shown_choices, [&](const ShownChoices &shown) { + return shown.owner == owner && shown.message == message; + }); + if (window == shown_choices.end()) { + shown_choices.push_back({owner, message, now, ++shown_order, now}); + window = shown_choices.end() - 1; + } + window->last = now; + // Windows that appear in one tick rank by first draw: the later one is on top. The ranking holds + // until a window closes, whatever order later redraws take. + const ShownChoices mine = *window; + if (std::ranges::any_of(shown_choices, [&](const ShownChoices &shown) { + return shown.first > mine.first || (shown.first == mine.first && shown.order > mine.order); + })) { + return; + } if (choices.owner != owner || choices.message != message || choices.drawn < mgPortFrameCount() - 1) { choices.target = selected; choices.confirm = false; @@ -336,14 +387,14 @@ gfx::InterpKey MenuMouseNextHandKey() { gfx::InterpKey MenuMouseReplacedHandKey() { return MenuMousePointerVisible() ? hand.key : 0; } gfx::InterpKey MenuMouseNoteHand(const gfx::TextureBinding &texture, gfx::LogicalRect uv, - float rotation, int reverse, int alpha, float scale) { + float rotation, int alpha, float scale) { if (texture.texture == gfx::kNullTexture) { return 0; } const auto key = MenuMouseNextHandKey(); ++hand_index; if (enabled) { - hand = {key, texture, uv, rotation, scale, reverse, alpha, mgPortFrameCount()}; + hand = {key, texture, uv, rotation, scale, alpha, mgPortFrameCount()}; } return key; } @@ -365,9 +416,9 @@ void MenuMouseDraw() { state.alpha_test = true; state.alpha_func = gfx::AlphaFunc::Greater; const float c = std::cos(hand.rotation), s = std::sin(hand.rotation); - const float sign = hand.reverse ? -1 : 1; - // The pointing fingertip is at the far edge, halfway down the hand's atlas cell. - const float tip_x = sign * hand.uv.w * hand.scale; + // The pointing fingertip is at the far edge, halfway down the hand's atlas cell. The hand is + // never mirrored, so its body stays on the same side of the hotspot on every target. + const float tip_x = hand.uv.w * hand.scale; const float tip_y = hand.uv.h * hand.scale * 0.5f; const float texture_scale = mgPortTextureScale(hand.texture.texture); gfx::Vertex2D vertices[4]{}; @@ -387,4 +438,10 @@ void MenuMouseDraw() { void MenuMouseSetButtons(int mask) { buttons = mask; } +void MenuMouseClaimButtons(int mask) { + buttons = mask; + choices.target = choices.selected; + choices.confirm = false; +} + bool MenuMouseActive() { return enabled; } diff --git a/port/src/menu_mouse.hpp b/port/src/menu_mouse.hpp index 29c1ecd88..1931af755 100644 --- a/port/src/menu_mouse.hpp +++ b/port/src/menu_mouse.hpp @@ -17,6 +17,17 @@ struct MenuMouseSample { std::uint32_t clicked = 0, released = 0, held = 0; }; +// The part of a hit rectangle inside a list's scissor; empty when none of it is. +MenuMouseRect MenuMouseClip(MenuMouseRect rect, MenuMouseRect clip); + +struct MenuMouseView { + int top = 0, selected = 0; +}; + +// Wheel notches scroll a list by whole rows. The cursor takes the row that comes under the pointer +// (hovered, counted from the first shown row, or -1), or else stays in view. +MenuMouseView MenuMouseScroll(float wheel, MenuMouseView view, int count, int visible, int hovered); + // Relative window pixels map through the same logical extent as the menu art. float MenuMouseUnits(float scale, float pixels, float window); int MenuMouseHit(std::span rows, float x, float y); @@ -40,11 +51,15 @@ void MenuMouseDraw(); inline constexpr gfx::InterpKey kMenuMouseHandKey = 0x4d454e5548414e44; bool MenuMousePointerVisible(); MenuMouseSample MenuMouseDisplaySample(); -gfx::InterpKey MenuMouseNoteHand(const gfx::TextureBinding &texture, gfx::LogicalRect uv, - float rotation = 0, int reverse = 0, int alpha = 128, float scale = 1); +// The pointer keeps one facing, so a mirrored retail hand (Max on the Character page) passes no facing. +gfx::InterpKey MenuMouseNoteHand(const gfx::TextureBinding &texture, gfx::LogicalRect uv, + float rotation = 0, int alpha = 128, float scale = 1); // Exclusive adapters translate this sampled frame into retail buttons. void MenuMouseSetButtons(int buttons); +// Like MenuMouseSetButtons, for a screen drawn over choice windows: it also drops the target and +// confirmation this tick's sample gave them, so nothing acts on them once the screen closes. +void MenuMouseClaimButtons(int buttons); bool MenuMouseActive(); class CSaveMenuClass; @@ -54,4 +69,24 @@ gfx::InterpKey MenuMouseNextHandKey(); gfx::InterpKey MenuMouseReplacedHandKey(); class CMenuOption; class CMenuPosDataForm; +// Retail Options targets are drawn rows and buttons inside the list's clip form (clip0). bool MenuMouseOption(CMenuOption &menu, CMenuPosDataForm *form); +bool MenuMouseOption(CMenuOption &menu, CMenuPosDataForm *form, const CMenuPosDataForm *clip); +// Hover moves the retail Options cursor to a row or button; the wheel scrolls its rows. +void MenuMouseOptionHover(CMenuOption &menu, CMenuPosDataForm *form, const CMenuPosDataForm *clip); +// Character page portraits: hover selects, a click on a portrait confirms. +int MenuMouseCharaSelect(CMenuPosDataForm &form, unsigned party, int npc_no, int select); +// What the mouse drives on the Character page in a retail state. +enum class MenuMouseCharaInput { + Retail, // Any-button waits and drawn message choices keep the shared mouse. + Portraits, // Hover selects a portrait; only a portrait click confirms. + Locked, // A character change is loading; left clicks do nothing. + RepairBoard, // Cedric's item board: hover and wheel select; only an item click confirms. +}; +MenuMouseCharaInput MenuMouseCharaInputFor(int mode, int sub_menu, int page, int step, int change_phase, int open_wait); +void MenuMouseRepairBoard(CMenuPosDataForm *board, int &select, int &top, int count); +class CMenuChrCngMenu; +void MenuMouseChara(CMenuChrCngMenu &menu); +// Help: entries follow hover and scroll with the wheel; the wheel turns picture pages. +class CManualMenu; +void MenuMouseManual(CManualMenu &menu, CMenuPosDataForm *background, const CMenuPosDataForm *clip); diff --git a/port/src/menu_mouse_menus.cpp b/port/src/menu_mouse_menus.cpp index 5cccb3cf4..b59486591 100644 --- a/port/src/menu_mouse_menus.cpp +++ b/port/src/menu_mouse_menus.cpp @@ -1,12 +1,15 @@ #include "common.h" #include +#include #include #include "gamepad.hpp" #include "mainloop.hpp" #include "menu_mouse.hpp" #include "menu_mouse_shop.hpp" +#include "menuaqua.hpp" +#include "menuchr.hpp" #include "menucls1.hpp" #include "menucommon.hpp" #include "menudraw.hpp" @@ -17,12 +20,16 @@ #include "save_slots.hpp" #include "title.hpp" -extern CItemSelect *MouseItemSelect asm("MenuMouse_ItemSelect"); -extern CMenuInter *MouseInter asm("MenuMouse_Inter"); -extern CSaveMenuClass *MouseSave asm("MenuMouse_Save"); -extern TITLE_INFO *MouseTitle asm("MenuMouse_Title"); -extern short MouseTitlePhase asm("MenuMouse_TitlePhase"); -extern unsigned char MouseTitleHDD asm("MenuMouse_TitleHDD"); +extern CItemSelect *MouseItemSelect asm("MenuMouse_ItemSelect"); +extern CMenuInter *MouseInter asm("MenuMouse_Inter"); +extern CSaveMenuClass *MouseSave asm("MenuMouse_Save"); +extern TITLE_INFO *MouseTitle asm("MenuMouse_Title"); +extern short MouseTitlePhase asm("MenuMouse_TitlePhase"); +extern unsigned char MouseTitleHDD asm("MenuMouse_TitleHDD"); +extern CMenuChrCngMenu *MouseChrChange asm("MenuMouse_ChrChange"); +extern CManualMenu *MouseManual asm("MenuMouse_Manual"); +extern CMenuOption *MouseOption asm("MenuOption_Current"); +extern float MouseOptionCount asm("MenuOption_Count"); bool MenuMouseFloor(); @@ -91,6 +98,17 @@ PORT_OVERRIDE int CMenuKeyFunc::CheckSelectKey() { MenuMouseShopTick(*this); } if (key_enable) { + if (now_mode == MENU_MODE_CHARA_CHANGE && MouseChrChange) { + MenuMouseChara(*MouseChrChange); + } + if (now_mode == MENU_MODE_OPTION && MouseOption && MenuPosData) { + MenuMouseOptionHover(*MouseOption, MenuPosData->GetFormInfo(const_cast("Op_Switch")), + MenuPosData->GetFormInfo(const_cast("clip0"))); + } + if (now_mode == MENU_MODE_MANUAL && MouseManual && MenuPosData) { + MenuMouseManual(*MouseManual, MenuPosData->GetFormInfo(const_cast("op_bg")), + MenuPosData->GetFormInfo(const_cast("clip0"))); + } if (now_mode == MENU_MODE_MAIN_TOWN || now_mode == MENU_MODE_MAIN_DUNGEON) { RootMouse(); } @@ -160,27 +178,243 @@ void MenuMouseSave(CSaveMenuClass &save) { } } -bool MenuMouseOption(CMenuOption &menu, CMenuPosDataForm *form) { - const auto &mouse = MenuMouseTake(); - if (!mouse.pointing || !form || !form->draw_flag) { - return false; - } - for (int row = std::max(0, menu.top); row < std::min(OPTION_ITEM_MAX, menu.top + 9); ++row) { - for (int choice = 0; choice < std::clamp(menu.choice_num[row], 0, OPTION_BUTTON_NUM); ++choice) { +namespace { +struct OptionHit { + int row = -1, choice = -1; +}; + +// Retail stores the option count as a float; the list never has more rows than buttons. +int OptionCount() { return std::clamp(static_cast(MouseOptionCount), 0, OPTION_ITEM_MAX); } + +MenuMouseRect FormClip(const CMenuPosDataForm *clip) { + return clip ? MenuMouseRect{clip->x, clip->y, float(clip->clip_w), float(clip->clip_h)} + : MenuMouseRect{-1e9f, -1e9f, 2e9f, 2e9f}; +} + +// Hits follow the drawn rows, which ease towards a new scroll, and stop at the list's scissor. +// A row spans its label and its buttons, one 24-unit line centred on the buttons. +OptionHit OptionAt(const CMenuOption &menu, const CMenuPosDataForm &form, MenuMouseRect clip, float x, float y) { + for (int row = 0; row < OptionCount(); ++row) { + float top = 0, right = 0; + bool shown = false; + const auto choices = std::clamp(menu.choice_num[row], 0, OPTION_BUTTON_NUM); + for (int choice = 0; choice < choices; ++choice) { const auto *part = menu.button[row][choice]; if (!part || !part->draw_flag) { continue; } - const MenuMouseRect rect{form->x + part->x, form->y + part->y, part->w, part->h}; - if (rect.Contains(mouse.x, mouse.y)) { - if (mouse.moved || (mouse.clicked & 1)) { - menu.select = row; - menu.choice = choice; - menu.cursor_jump = true; - } - return true; + const MenuMouseRect rect{form.x + part->x, form.y + part->y, part->w, part->h}; + if (MenuMouseClip(rect, clip).Contains(x, y)) { + return {row, choice}; } + top = shown ? top : rect.y + rect.h * 0.5f - 12; + right = std::max(right, rect.x + rect.w); + shown = true; + } + if (shown && MenuMouseClip({form.x - 8, top, right - form.x + 8, 24}, clip).Contains(x, y)) { + return {row, -1}; + } + } + return {}; +} + +// The button of a row under x, or -1. Buttons keep their columns as the list scrolls. +int OptionChoiceAtX(const CMenuOption &menu, const CMenuPosDataForm &form, int row, float x) { + for (int choice = 0; choice < std::clamp(menu.choice_num[row], 0, OPTION_BUTTON_NUM); ++choice) { + const auto *part = menu.button[row][choice]; + if (part && x >= form.x + part->x && x < form.x + part->x + part->w) { + return choice; } } - return false; + return -1; +} + +// The shown row under the pointer, counted from the first shown row, for the wheel; -1 off the list. +int ShownRow(int hit, int top, int visible) { return hit >= top && hit < top + visible ? hit - top : -1; } + +// The inventory board slot under the pointer as drawn (40 by 50 cells easing with the board's scroll), +// inside the board's window, or -1. The shop hits its bag the same way. +int BoardHit(const MenuMouseSample &mouse, CMenuPosDataForm *board, int count) { + if (!board || !board->draw_flag) { + return -1; + } + float x, y; + board->GetPutPosXY(nullptr, x, y); + const float left = MenuItemBrdUnderBrdPosXY[0], top = MenuItemBrdUnderBrdPosXY[1]; + if (!MenuMouseRect{x + 16, y + 20, 240, 250}.Contains(mouse.x, mouse.y) || mouse.x < left || + mouse.x >= left + 240 || mouse.y < top) { + return -1; + } + const int slot = int((mouse.y - top) / 50) * MENU_ITEM_BOARD_COLUMNS + int((mouse.x - left) / 40); + return slot < count ? slot : -1; +} +} // namespace + +bool MenuMouseOption(CMenuOption &menu, CMenuPosDataForm *form) { + return MenuMouseOption(menu, form, MenuPosData ? MenuPosData->GetFormInfo(const_cast("clip0")) : nullptr); +} + +bool MenuMouseOption(CMenuOption &menu, CMenuPosDataForm *form, const CMenuPosDataForm *clip) { + const auto &mouse = MenuMouseTake(); + if (!mouse.pointing || !form || !form->draw_flag) { + return false; + } + const auto hit = OptionAt(menu, *form, FormClip(clip), mouse.x, mouse.y); + if (hit.choice < 0) { + return false; + } + if (mouse.moved || (mouse.clicked & 1)) { + menu.select = hit.row; + menu.choice = hit.choice; + menu.cursor_jump = true; + } + return true; +} + +void MenuMouseOptionHover(CMenuOption &menu, CMenuPosDataForm *form, const CMenuPosDataForm *clip) { + constexpr int kVisible = 9; + const auto &mouse = MenuMouseTake(); + if (!mouse.pointing || !form || !form->draw_flag || menu.mode != MENU_ASK_MODE_NONE) { + return; + } + const auto hit = OptionAt(menu, *form, FormClip(clip), mouse.x, mouse.y); + MenuMouseView view{menu.top, menu.select}; + if (hit.row >= 0 && mouse.moved) { + view.selected = hit.row; + } + view = MenuMouseScroll(mouse.wheel, view, OptionCount(), kVisible, ShownRow(hit.row, menu.top, kVisible)); + const bool same_row = view.selected == menu.select; + menu.top = view.top; + if (!same_row) { + MenuMouseMove(&menu.select, view.selected); + } + const int choice = mouse.wheel != 0 && hit.row >= 0 ? OptionChoiceAtX(menu, *form, view.selected, mouse.x) : hit.choice; + // KeyStep compares the choice after this hook, so a move along one row sounds here. + if (choice >= 0 && choice != menu.choice && (mouse.moved || mouse.wheel != 0)) { + if (same_row) { + MenuSePlay(SYSTEM_SE_CURSOR); + } + menu.choice = choice; + } +} + +int MenuMouseCharaSelect(CMenuPosDataForm &form, unsigned party, int npc_no, int select) { + // Each portrait's ring is a target while its character, or the townsperson, is shown. + MenuMouseRect rows[CHR_CNG_SELECT_NPC + 1]{}; + for (int i = 0; i <= CHR_CNG_SELECT_NPC; ++i) { + char name[8]; + std::snprintf(name, sizeof name, "fc%d", i); + const auto *ring = form.GetPartInfo(name); + if (ring && ((party & (1u << i)) || (i == CHR_CNG_SELECT_NPC && npc_no > 0))) { + rows[i] = {form.x + ring->x, form.y + ring->y, ring->w, ring->h}; + } + } + return MenuMouseRows(rows, select, 0); +} + +MenuMouseCharaInput MenuMouseCharaInputFor(int mode, int sub_menu, int page, int step, int change_phase, int open_wait) { + if (mode != MENU_ASK_MODE_NONE || sub_menu != CHR_CNG_SUB_MENU_NONE) { + return MenuMouseCharaInput::Retail; + } + if (page == CHR_CNG_PAGE_SELECT && step == CHR_CNG_STEP_SELECT && open_wait <= 0) { + return change_phase ? MenuMouseCharaInput::Locked : MenuMouseCharaInput::Portraits; + } + if (page == CHR_CNG_PAGE_NPC && step == CHR_CNG_NPC_STEP_REPAIR_SELECT) { + return MenuMouseCharaInput::RepairBoard; + } + // Opening waits and townsperson speech take any button; their questions use drawn message choices. + return MenuMouseCharaInput::Retail; +} + +void MenuMouseRepairBoard(CMenuPosDataForm *board, int &select, int &top, int count) { + constexpr int kColumns = MENU_ITEM_BOARD_COLUMNS, kRows = MENU_ITEM_BOARD_ROWS; + const auto &mouse = MenuMouseTake(); + if (!mouse.pointing) { + return; + } + const int hit = BoardHit(mouse, board, count); + int target = select; + if (hit >= 0 && (mouse.moved || (mouse.clicked & 1))) { + target = hit; + } + const int column = (hit >= 0 ? hit : target) % kColumns; + const auto view = MenuMouseScroll(mouse.wheel, {top, target / kColumns}, (count + kColumns - 1) / kColumns, kRows, + hit >= 0 ? ShownRow(hit / kColumns, top, kRows) : -1); + top = view.top; + target = std::clamp(view.selected * kColumns + column, 0, std::max(0, count - 1)); + if (target != select) { + select = target; + MenuSePlay(SYSTEM_SE_CURSOR); + } + // The board owns this tick's buttons, as the shop's does: Cedric's command list stays drawn behind + // it, and its choices must neither move nor swallow a click. Repairs spend his points, so only a + // click on an item confirms; right click backs out. + MenuMouseClaimButtons(((mouse.clicked & 1) && hit >= 0 ? PAD_CROSS : 0) | (mouse.clicked & 2 ? PAD_CIRCLE : 0)); +} + +void MenuMouseChara(CMenuChrCngMenu &menu) { + if (menu_debug_flag || !menu.form) { + return; + } + switch (MenuMouseCharaInputFor(menu.mode, menu.sub_menu, menu.key_arg_no, menu.step, menu.change_phase, menu.open_wait)) { + case MenuMouseCharaInput::Retail: + return; + case MenuMouseCharaInput::Locked: + // A character change is loading; retail ignores the cursor, and a click must not ask again. + if (MenuMouseTake().clicked & 1) { + MenuMouseSetButtons(MenuMouseButtons() & ~PAD_CROSS); + } + return; + case MenuMouseCharaInput::RepairBoard: + MenuMouseRepairBoard(MenuPosData ? MenuPosData->GetFormInfo(const_cast("itembrd")) : nullptr, + menu.item_brd_select, menu.item_brd_pos, GetNowBagMax(ITEM_BOARD_MAIN)); + return; + case MenuMouseCharaInput::Portraits: + break; + } + const int selected = MenuMouseCharaSelect(*menu.form, menu.party_member, menu.npc_no, menu.select); + if (selected == menu.select) { + return; + } + // KeyChangeMain reads its cursor change after this hook; repeat that change's effects here. + const int last = menu.select; + menu.select = selected; + MenuSePlay(SYSTEM_SE_CURSOR); + if (selected == CHR_CNG_SELECT_NPC) { + MenuDCMsg[0]->SetBuff(menu.mes_data); + } + if (last == CHR_CNG_SELECT_NPC) { + MenuDCMsg[0]->SetBuff(menu.sys_mes); + } +} + +void MenuMouseManual(CManualMenu &menu, CMenuPosDataForm *background, const CMenuPosDataForm *clip) { + constexpr int kEntries = 46, kVisible = 10; + const auto &mouse = MenuMouseTake(); + if (menu.mode != MENU_ASK_MODE_NONE || !mouse.pointing) { + return; + } + if (menu.key_arg_no == MANUAL_STEP_PLAY && menu.pict_mode && mouse.wheel != 0) { + // Picture pages turn with the wheel through the retail Left/Right page keys. + MenuMousePress(mouse.wheel < 0 ? PAD_RIGHT : PAD_LEFT); + return; + } + if (menu.key_arg_no != MANUAL_STEP_SELECT || !background) { + return; + } + int left = 0, top = 0, right = 0, bar = 0; + background->GetPutPosXY(const_cast("base_msg"), left, top); + background->GetPutPosXY(const_cast("scrlbase"), right, bar); + // Entries are hit where they are drawn as the list eases to a new scroll, inside its scissor. + std::vector rows(kEntries); + for (int i = 0; i < kEntries; ++i) { + rows[i] = MenuMouseClip({left - 8.0f, menu.list_y + 24 * i - 4, right - left + 4.0f, 24}, FormClip(clip)); + } + const int hovered = MenuMouseRows(rows, menu.select, 0); + const int hit = MenuMouseHit(rows, mouse.x, mouse.y); + MenuMouseView view = MenuMouseScroll(mouse.wheel, {menu.top, hovered}, kEntries, kVisible, ShownRow(hit, menu.top, kVisible)); + menu.top = view.top; + if (view.selected != menu.select) { + MenuMouseMove(&menu.select, view.selected); + } } diff --git a/port/src/menu_mouse_message.cpp b/port/src/menu_mouse_message.cpp index c609b615f..629768706 100644 --- a/port/src/menu_mouse_message.cpp +++ b/port/src/menu_mouse_message.cpp @@ -73,7 +73,7 @@ PORT_OVERRIDE void _set2DSprite(char *name, mgCDrawPrim *primitive, mgRect if (cursor && (color->r || color->g || color->b)) { MenuMouseNoteHand(mgPortBinding(primitive->texture.tex0_bits, primitive->texture.tex1_bits), {float(uv.left), float(uv.top), float(uv.right), float(uv.bottom)}, - 0, 0, color->a); + 0, color->a); } set2DSpriteEasy(primitive, destination, uv, color); primitive->End(); diff --git a/port/src/menudraw.cpp b/port/src/menudraw.cpp index 92edb7255..08c0957f3 100644 --- a/port/src/menudraw.cpp +++ b/port/src/menudraw.cpp @@ -338,7 +338,7 @@ PORT_OVERRIDE void MenuCursorDraw(mgCTexture *texture, float *position, float ro const auto hand_key = MenuMouseNoteHand(mgPortBinding(texture), {float(menu_long_hand.left), float(menu_long_hand.top), float(menu_long_hand.right), float(menu_long_hand.bottom)}, - rotation, reverse, alpha, scale); + rotation, alpha, scale); // The canonical image retains the selection hand. A displayed mouse pointer // replaces this tagged group without changing screenshots or grab textures. gfx::Interp2DScope tag({hand_key, static_cast(mgPortFrameCount())}); diff --git a/port/src/menudraw_primquad.cpp b/port/src/menudraw_primquad.cpp index 8ae07513c..ad5315ef0 100644 --- a/port/src/menudraw_primquad.cpp +++ b/port/src/menudraw_primquad.cpp @@ -102,7 +102,7 @@ PORT_OVERRIDE void PrimQuad(mgCTexture *texture, float x, float y, mgRect u if (cursor) { MenuMouseNoteHand(mgPortBinding(texture), {float(uv.left), float(uv.top), float(uv.right), float(uv.bottom)}, - 0, 0, alpha); + 0, alpha); } mgCDrawPrim prim; SetSpriteEnv(&prim, MENU_SPRITE_ENV_TEXTURE); diff --git a/port/src/tests/menu_mouse_test.cpp b/port/src/tests/menu_mouse_test.cpp index e3710bc8c..82d4d7781 100644 --- a/port/src/tests/menu_mouse_test.cpp +++ b/port/src/tests/menu_mouse_test.cpp @@ -6,6 +6,7 @@ #include "gamepad.hpp" #include "mainloop.hpp" #include "menu_mouse.hpp" +#include "menuchr.hpp" #include "menudraw.hpp" #include "menumain.hpp" #include "menuop.hpp" @@ -580,3 +581,506 @@ TEST_F(MenuMouseInput, MultipleHandsKeepDistinctReplacementKeys) { EXPECT_NE(first, second); EXPECT_EQ(MenuMouseReplacedHandKey(), second); } + +TEST(MenuMouse, WheelScrollsWholeRowsAndKeepsTheCursorUnderThePointerOrInView) { + // Sixteen rows, nine shown: down three notches from the top. + EXPECT_EQ(MenuMouseScroll(-3, {0, 2}, 16, 9, 4).top, 3); + EXPECT_EQ(MenuMouseScroll(-3, {0, 2}, 16, 9, 4).selected, 7); + EXPECT_EQ(MenuMouseScroll(-3, {0, 2}, 16, 9, -1).selected, 3); + // The list stops at its last page and at its first row. + EXPECT_EQ(MenuMouseScroll(-20, {5, 9}, 16, 9, -1).top, 7); + EXPECT_EQ(MenuMouseScroll(-20, {5, 9}, 16, 9, -1).selected, 9); + EXPECT_EQ(MenuMouseScroll(2, {1, 9}, 16, 9, -1).top, 0); + EXPECT_EQ(MenuMouseScroll(2, {1, 9}, 16, 9, -1).selected, 8); + // A list that fits, no wheel, or a bad wheel value does not move. + EXPECT_EQ(MenuMouseScroll(-1, {0, 1}, 4, 9, -1).top, 0); + EXPECT_EQ(MenuMouseScroll(0, {3, 5}, 16, 9, 0).selected, 5); + EXPECT_EQ(MenuMouseScroll(std::numeric_limits::quiet_NaN(), {3, 5}, 16, 9, 0).top, 3); +} + +namespace { +void Place(MENUFORMPARTS_TYPE &part, const char *name, float x, float y, float w, float h) { + part.name = const_cast(name); + part.x = x; + part.y = y; + part.w = w; + part.h = h; + part.draw_flag = true; +} +} // namespace + +TEST_F(MenuMouseInput, RetailOptionsHoverMovesRowAndButtonAndTheWheelScrolls) { + MenuMouseEnable(true); + CMenuOption menu; + CMenuPosDataForm form{}; + MENUFORMPARTS_TYPE buttons[OPTION_ITEM_MAX][2]{}; + form.draw_flag = true; + form.x = 40; + form.y = 158; // Row 3's buttons are centred on the initial pointer, 256,240. + for (int row = 0; row < OPTION_ITEM_MAX; ++row) { + menu.choice_num[row] = 2; + for (int choice = 0; choice < 2; ++choice) { + Place(buttons[row][choice], "button", 200.0f + 80 * choice, 24.0f * row, 60, 20); + menu.button[row][choice] = &buttons[row][choice]; + } + } + menu.mode = MENU_ASK_MODE_NONE; + menu.top = menu.select = menu.choice = 0; + // A stationary pointer leaves the keyboard's row alone. + InputSetScriptedDevices({.mouse_buttons = 2}); + MenuMouseOptionHover(menu, &form, nullptr); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 16, 9, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_UNCHANGED); + InputSetScriptedDevices({}); + Tick(); + InputSetScriptedDevices({.mouse_dx = 1}); + MenuMouseOptionHover(menu, &form, nullptr); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 16, 9, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_MOVED); + EXPECT_EQ(menu.select, 3); + EXPECT_EQ(menu.choice, 0); + Tick(); + // Along one row the adapter changes the choice itself (and sounds it); no row move is queued. + InputSetScriptedDevices({.mouse_dx = 80}); + MenuMouseOptionHover(menu, &form, nullptr); + EXPECT_EQ(menu.choice, 1); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 16, 9, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_UNCHANGED); + Tick(); + // The row's label keeps the row and its choice. + InputSetScriptedDevices({.mouse_dx = -280}); + MenuMouseOptionHover(menu, &form, nullptr); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 16, 9, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_UNCHANGED); + EXPECT_EQ(menu.choice, 1); + Tick(); + // Two notches down: the list moves under the pointer, which stays on its fourth shown row. + InputSetScriptedDevices({.mouse_wheel = -2}); + MenuMouseOptionHover(menu, &form, nullptr); + EXPECT_EQ(menu.top, 2); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 16, 9, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_MOVED); + EXPECT_EQ(menu.select, 5); + EXPECT_EQ(menu.top, 2); + Tick(); + // Sixteen retail options stop at the last page. + InputSetScriptedDevices({.mouse_wheel = -20}); + MenuMouseOptionHover(menu, &form, nullptr); + EXPECT_EQ(menu.top, 7); +} + +TEST_F(MenuMouseInput, RetailOptionsHitOnlyDrawnRowsInsideTheListWhileItEases) { + MenuMouseEnable(true); + CMenuOption menu; + CMenuPosDataForm form{}, clip{}; + MENUFORMPARTS_TYPE buttons[OPTION_ITEM_MAX][2]{}; + form.draw_flag = true; + form.x = 40; + for (int row = 0; row < OPTION_ITEM_MAX; ++row) { + menu.choice_num[row] = 2; + for (int choice = 0; choice < 2; ++choice) { + Place(buttons[row][choice], "button", 200.0f + 80 * choice, 24.0f * row, 60, 20); + menu.button[row][choice] = &buttons[row][choice]; + } + } + // Nine rows show between y 100 and 316. The wheel has just moved top to 7, but the drawn list + // still starts near row 0: row 6's drawn buttons cover the initial pointer (256,240) though it + // is above the new top, and row 10 has slid below the scissor, to y 330-350. + clip.x = 30; + clip.y = 100; + clip.clip_w = 400; + clip.clip_h = 216; + form.y = 90; + menu.mode = MENU_ASK_MODE_NONE; + menu.top = 7; + menu.select = 7; + menu.choice = 0; + // A visible outgoing row is still the row under the pointer, and clicking it confirms that row. + InputSetScriptedDevices({.mouse_buttons = 1}); + EXPECT_TRUE(MenuMouseOption(menu, &form, &clip)); + EXPECT_EQ(menu.select, 6); + InputSetScriptedDevices({}); + Tick(); + // The footer below the scissor is not row 10. + InputSetScriptedDevices({.mouse_buttons = 1, .mouse_dy = 90}); + EXPECT_FALSE(MenuMouseOption(menu, &form, &clip)); + EXPECT_EQ(menu.select, 6); + InputSetScriptedDevices({}); + Tick(); + InputSetScriptedDevices({.mouse_dx = 1}); + MenuMouseOptionHover(menu, &form, &clip); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 16, 9, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_UNCHANGED); + // Without the scissor the same point would be row 10's button. + EXPECT_TRUE(MenuMouseOption(menu, &form, nullptr)); + EXPECT_EQ(menu.select, 10); +} + +TEST_F(MenuMouseInput, CharacterPortraitsSelectOnHoverAndOnlyAPortraitClickConfirms) { + MenuMouseEnable(true); + CMenuPosDataForm form{}; + MENUFORMPARTS_TYPE rings[5]{}; + const char *names[] = {"fc0", "fc1", "fc2", "fc3", "fc4"}; + for (int i = 0; i < 5; ++i) { + Place(rings[i], names[i], 40.0f + 90 * i, 200, 80, 80); + } + form.parts = rings; + form.parts_num = 5; + // The pointer starts at 256,240, over fc2; the ridepod is not in this party. + InputSetScriptedDevices({.mouse_buttons = 1, .mouse_dx = 1}); + EXPECT_EQ(MenuMouseCharaSelect(form, 0b0011, 0, 0), 0); + EXPECT_FALSE(GamePad__2.Down(PAD_CROSS)); + InputSetScriptedDevices({}); + Tick(); + InputSetScriptedDevices({.mouse_dx = -90}); + EXPECT_EQ(MenuMouseCharaSelect(form, 0b0011, 0, 0), 1); + Tick(); + InputSetScriptedDevices({.mouse_buttons = 1}); + EXPECT_EQ(MenuMouseCharaSelect(form, 0b0011, 0, 1), 1); + EXPECT_TRUE(GamePad__2.Down(PAD_CROSS)); + InputSetScriptedDevices({}); + Tick(); + // The townsperson's portrait counts once one travels with the party. + InputSetScriptedDevices({.mouse_dx = 270}); + EXPECT_EQ(MenuMouseCharaSelect(form, 0b0011, 0, 1), 1); + Tick(); + InputSetScriptedDevices({.mouse_dx = 1}); + EXPECT_EQ(MenuMouseCharaSelect(form, 0b0011, 3, 1), 4); + Tick(); + // The wheel does not walk the portrait map. + InputSetScriptedDevices({.mouse_wheel = 1}); + EXPECT_EQ(MenuMouseCharaSelect(form, 0b0011, 3, 4), 4); +} + +TEST(MenuMouse, CharacterPageStatesChooseWhatTheMouseDrives) { + using enum MenuMouseCharaInput; + constexpr int none = MENU_ASK_MODE_NONE, box = CHR_CNG_SUB_MENU_MONSTER_BOX, own = CHR_CNG_SUB_MENU_NONE; + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_SELECT, CHR_CNG_STEP_SELECT, 0, 0), Portraits); + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_SELECT, CHR_CNG_STEP_SELECT, CHR_CNG_PHASE_LOAD, 0), Locked); + // The opening wait and the townsperson's questions keep the retail any-button and message choices. + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_SELECT, CHR_CNG_STEP_SELECT, 0, 20), Retail); + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_SELECT, CHR_CNG_STEP_NPC_MENU, 0, 0), Retail); + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_CONFIRM, 0, 0, 0), Retail); + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_NPC, CHR_CNG_NPC_STEP_COMMAND, 0, 0), Retail); + EXPECT_EQ(MenuMouseCharaInputFor(none, own, CHR_CNG_PAGE_NPC, CHR_CNG_NPC_STEP_REPAIR_SELECT, 0, 0), RepairBoard); + // Opening, closing and the monster box are not this page's. + EXPECT_EQ(MenuMouseCharaInputFor(MENU_ASK_MODE_OPEN, own, CHR_CNG_PAGE_SELECT, CHR_CNG_STEP_SELECT, 0, 0), Retail); + EXPECT_EQ(MenuMouseCharaInputFor(none, box, CHR_CNG_PAGE_SELECT, CHR_CNG_STEP_SELECT, 0, 0), Retail); +} + +TEST_F(MenuMouseInput, RepairBoardSelectsItemsScrollsAndRejectsBackgroundClicks) { + MenuMouseEnable(true); + CMenuPosDataForm board{}; + board.draw_flag = true; + board.x = 200; + board.y = 30; + const float old_x = MenuItemBrdUnderBrdPosXY[0], old_y = MenuItemBrdUnderBrdPosXY[1]; + // Slots are 40 by 50 from 216,56, six to a row, so the initial pointer (256,240) is slot 19. + MenuItemBrdUnderBrdPosXY[0] = 216; + MenuItemBrdUnderBrdPosXY[1] = 56; + int select = 0, top = 0; + InputSetScriptedDevices({.mouse_dx = 1}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(select, 19); + Tick(); + InputSetScriptedDevices({.mouse_buttons = 1}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_TRUE(GamePad__2.Down(PAD_CROSS)); + InputSetScriptedDevices({}); + Tick(); + // Two rows down keeps the pointer's place on the board. + InputSetScriptedDevices({.mouse_wheel = -2}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(top, 2); + EXPECT_EQ(select, 31); + Tick(); + // Beside the board a click does not repair the selected item. + InputSetScriptedDevices({.mouse_dx = 250}); + MenuMouseRepairBoard(&board, select, top, 60); + Tick(); + InputSetScriptedDevices({.mouse_buttons = 1}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_FALSE(GamePad__2.Down(PAD_CROSS)); + EXPECT_EQ(select, 31); + MenuItemBrdUnderBrdPosXY[0] = old_x; + MenuItemBrdUnderBrdPosXY[1] = old_y; +} + +TEST_F(MenuMouseInput, HelpEntriesFollowHoverScrollAndTurnPicturePages) { + MenuMouseEnable(true); + CManualMenu menu; + CMenuPosDataForm background{}, clip{}; + MENUFORMPARTS_TYPE places[2]{}; + Place(places[0], "base_msg", 60, 100, 0, 0); + Place(places[1], "scrlbase", 400, 100, 0, 0); + places[0].dtype = places[1].dtype = MENUFORMPARTS_DTYPE_FUNCINFO; + background.parts = places; + background.parts_num = 2; + clip.x = 40; + clip.y = 96; + clip.clip_w = 400; + clip.clip_h = 240; // Ten entries. + menu.mode = MENU_ASK_MODE_NONE; + menu.list_y = 100; // Entry 6 spans y 240-264. + InputSetScriptedDevices({.mouse_dx = 1}); + MenuMouseManual(menu, &background, &clip); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 46, 10, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_MOVED); + EXPECT_EQ(menu.select, 6); + Tick(); + InputSetScriptedDevices({.mouse_wheel = -3}); + MenuMouseManual(menu, &background, &clip); + EXPECT_EQ(menu.top, 3); + MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 46, 10, MENU_CURSOR_LIMIT_CLAMP); + EXPECT_EQ(menu.select, 9); + Tick(); + // The drawn list has not eased yet: the pointer is still over entry 6, which is pictured there. + InputSetScriptedDevices({.mouse_buttons = 1}); + MenuMouseManual(menu, &background, &clip); + EXPECT_TRUE(GamePad__2.Down(PAD_CROSS)); + EXPECT_EQ(MenuKeySelectCheck(0, &menu.select, &menu.top, 0, 46, 10, MENU_CURSOR_LIMIT_CLAMP), MENU_CURSOR_MOVED); + EXPECT_EQ(menu.select, 6); + InputSetScriptedDevices({}); + Tick(); + // Entry 11 is drawn below the scissor (y 360-384); a click there opens nothing. + InputSetScriptedDevices({.mouse_dy = 125}); + MenuMouseManual(menu, &background, &clip); + Tick(); + InputSetScriptedDevices({.mouse_buttons = 1}); + MenuMouseManual(menu, &background, &clip); + EXPECT_FALSE(GamePad__2.Down(PAD_CROSS)); + InputSetScriptedDevices({}); + Tick(); + // Beside the list a click does not open an entry either. + InputSetScriptedDevices({.mouse_dx = 200, .mouse_dy = -125}); + MenuMouseManual(menu, &background, &clip); + Tick(); + InputSetScriptedDevices({.mouse_buttons = 1}); + MenuMouseManual(menu, &background, &clip); + EXPECT_FALSE(GamePad__2.Down(PAD_CROSS)); + InputSetScriptedDevices({}); + Tick(); + menu.key_arg_no = MANUAL_STEP_PLAY; + menu.pict_mode = true; + InputSetScriptedDevices({.mouse_wheel = -1}); + MenuMouseManual(menu, &background, &clip); + EXPECT_TRUE(GamePad__2.Down(PAD_RIGHT)); + Tick(); + InputSetScriptedDevices({.mouse_wheel = 1}); + MenuMouseManual(menu, &background, &clip); + EXPECT_TRUE(GamePad__2.Down(PAD_LEFT)); +} + +TEST_F(MenuMouseInput, NewestChoiceWindowTakesTheClickOverAListStillDrawingItsCursor) { + const MenuMouseRect commands[] = { + {20, 300, 100, 20}, + {20, 320, 100, 20} + }; + const MenuMouseRect question[] = { + {200, 220, 100, 30}, + {200, 250, 100, 30} + }; + MenuMouseEnable(true); + MenuMouseNoteChoices(this, 31, commands, 0, false); + Tick(); + // The question opens over the commands; both note their choices every tick, the commands first. + InputSetScriptedDevices({.mouse_buttons = 1}); + bool confirmed = false; + for (int i = 0; i < 9 && !confirmed; ++i) { + MenuMouseNoteChoices(this, 31, commands, 0, false); + MenuMouseNoteChoices(this, 145, question, 0, false); + confirmed = MenuMouseButtons() == PAD_CROSS; + InputSetScriptedDevices({}); + Tick(); + } + EXPECT_TRUE(confirmed); +} + +namespace { +// Draws a list's choices and a question's each tick, in either order, and returns the retail buttons. +int DrawCommandsAndQuestion(const void *commands, const void *question, std::span command_rows, + std::span question_rows, int selected, bool question_first) { + if (question_first) { + MenuMouseNoteChoices(question, 145, question_rows, selected, true); + MenuMouseNoteChoices(commands, 31, command_rows, 0, false); + } else { + MenuMouseNoteChoices(commands, 31, command_rows, 0, false); + MenuMouseNoteChoices(question, 145, question_rows, selected, true); + } + return MenuMouseButtons(); +} + +constexpr MenuMouseRect kCommandRows[] = { + {20, 300, 100, 30}, + {20, 330, 100, 30} +}; +// The initial pointer, 256,240, is on the question's second choice. +constexpr MenuMouseRect kQuestionRows[] = { + {100, 220, 100, 30}, + {220, 220, 100, 30} +}; +} // namespace + +TEST_F(MenuMouseInput, NewerQuestionMovesThenConfirmsWhateverTheRedrawOrderAndCommandsReturn) { + int question = 0; + MenuMouseEnable(true); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + Tick(); + int selected = 0; + bool confirmed = false; + for (int i = 0; i < 12; ++i) { + InputSetScriptedDevices({.mouse_buttons = i == 0 ? 1u : 0u}); + const int buttons = DrawCommandsAndQuestion(this, &question, kCommandRows, kQuestionRows, selected, i % 2); + selected = buttons & PAD_RIGHT ? 1 : selected; + confirmed = confirmed || (buttons & PAD_CROSS); + Tick(); + } + EXPECT_EQ(selected, 1); + EXPECT_TRUE(confirmed); + // Once the question closes, the commands take the mouse again. + for (int i = 0; i < 10; ++i) { + InputSetScriptedDevices({}); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + EXPECT_EQ(MenuMouseButtons(), 0); + Tick(); + } + InputSetScriptedDevices({.mouse_buttons = 1, .mouse_dx = -200, .mouse_dy = 70}); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + EXPECT_EQ(MenuMouseButtons(), PAD_CROSS); +} + +TEST_F(MenuMouseInput, WindowsAppearingInOneTickKeepTheLaterDrawnOneOnTop) { + for (const bool reversed_later : {false, true}) { + int question = 0; + MenuMouseEnable(false); + MenuMouseEnable(true); + int selected = 0; + bool confirmed = false; + // Both first draw in one tick, commands first; later redraws may swap the order. + for (int i = -10; i < 12; ++i) { + InputSetScriptedDevices({.mouse_buttons = i == 0 ? 1u : 0u}); + const bool question_first = i > -10 && reversed_later; + const int buttons = DrawCommandsAndQuestion(this, &question, kCommandRows, kQuestionRows, selected, question_first); + selected = buttons & PAD_RIGHT ? 1 : selected; + confirmed = confirmed || (buttons & PAD_CROSS); + Tick(); + } + EXPECT_EQ(selected, 1) << reversed_later; + EXPECT_TRUE(confirmed) << reversed_later; + InputSetScriptedDevices({}); + Tick(); + } +} + +TEST_F(MenuMouseInput, ReopenedQuestionTakesTheMouseBackFromTheCommands) { + int question = 0; + MenuMouseEnable(true); + for (int i = 0; i < 3; ++i) { + DrawCommandsAndQuestion(this, &question, kCommandRows, kQuestionRows, 0, false); + Tick(); + } + // The question closes for two ticks, then opens again over the commands. + for (int i = 0; i < 2; ++i) { + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + MenuMouseButtons(); + Tick(); + } + int selected = 0; + bool confirmed = false; + for (int i = 0; i < 12; ++i) { + InputSetScriptedDevices({.mouse_buttons = i == 0 ? 1u : 0u}); + const int buttons = DrawCommandsAndQuestion(this, &question, kCommandRows, kQuestionRows, selected, false); + selected = buttons & PAD_RIGHT ? 1 : selected; + confirmed = confirmed || (buttons & PAD_CROSS); + Tick(); + } + EXPECT_EQ(selected, 1); + EXPECT_TRUE(confirmed); +} + +TEST_F(MenuMouseInput, RepairBoardOwnsClicksWhileCedricsCommandsStillDraw) { + MenuMouseEnable(true); + CMenuPosDataForm board{}; + board.draw_flag = true; + board.x = 200; + board.y = 30; + const float old_x = MenuItemBrdUnderBrdPosXY[0], old_y = MenuItemBrdUnderBrdPosXY[1]; + MenuItemBrdUnderBrdPosXY[0] = 216; + MenuItemBrdUnderBrdPosXY[1] = 56; + int select = 0, top = 0; + for (int i = 0; i < 10; ++i) { + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices({}); + MenuMouseRepairBoard(&board, select, top, 60); + Tick(); + } + // A click on slot 19 confirms it; the commands' choices add no direction. + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices({.mouse_buttons = 1}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(select, 19); + EXPECT_EQ(MenuMouseButtons(), PAD_CROSS); + InputSetScriptedDevices({}); + Tick(); + // A click on a command behind the board neither confirms nor moves; right click backs out. + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices({.mouse_buttons = 1, .mouse_dx = -200, .mouse_dy = 70}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(MenuMouseButtons(), 0); + EXPECT_EQ(select, 19); + InputSetScriptedDevices({}); + Tick(); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices({.mouse_buttons = 2}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(MenuMouseButtons(), PAD_CIRCLE); + MenuItemBrdUnderBrdPosXY[0] = old_x; + MenuItemBrdUnderBrdPosXY[1] = old_y; +} + +TEST_F(MenuMouseInput, RepairBoardInputLeavesNoTargetForCedricsCommandsAfterItCloses) { + // Board clicks on a command behind it, or wheel notches, then right click closes the board. + for (const InputKeyboardMouse board_input : { + InputKeyboardMouse{.mouse_buttons = 1, .mouse_dx = -200, .mouse_dy = 100}, + InputKeyboardMouse{.mouse_wheel = -1} + }) { + InputSetScriptedDevices({}); + MenuMouseEnable(false); + Tick(); + MenuMouseEnable(true); + CMenuPosDataForm board{}; + board.draw_flag = true; + board.x = 200; + board.y = 30; + const float old_x = MenuItemBrdUnderBrdPosXY[0], old_y = MenuItemBrdUnderBrdPosXY[1]; + MenuItemBrdUnderBrdPosXY[0] = 216; + MenuItemBrdUnderBrdPosXY[1] = 56; + int select = 0, top = 0; + for (int i = 0; i < 10; ++i) { + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices({}); + MenuMouseRepairBoard(&board, select, top, 60); + Tick(); + } + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices(board_input); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(MenuMouseButtons(), 0); + Tick(); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + InputSetScriptedDevices({.mouse_buttons = 2}); + MenuMouseRepairBoard(&board, select, top, 60); + EXPECT_EQ(MenuMouseButtons(), PAD_CIRCLE); + Tick(); + // Back on the commands: idle ticks move and confirm nothing. + for (int i = 0; i < 10; ++i) { + InputSetScriptedDevices({}); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + EXPECT_EQ(MenuMouseButtons(), 0) << i; + Tick(); + } + // A fresh click on the first command confirms it. + const auto pos = MenuMouseTake(); + Tick(); + InputSetScriptedDevices({.mouse_buttons = 1, .mouse_dx = 50 - pos.x, .mouse_dy = 310 - pos.y}); + MenuMouseNoteChoices(this, 31, kCommandRows, 0, false); + EXPECT_EQ(MenuMouseButtons(), PAD_CROSS); + InputSetScriptedDevices({}); + Tick(); + MenuItemBrdUnderBrdPosXY[0] = old_x; + MenuItemBrdUnderBrdPosXY[1] = old_y; + } +} diff --git a/port/src/tests/mglib_present_test.cpp b/port/src/tests/mglib_present_test.cpp index fad5f1db9..759dcd740 100644 --- a/port/src/tests/mglib_present_test.cpp +++ b/port/src/tests/mglib_present_test.cpp @@ -1855,7 +1855,7 @@ TEST(MglibPresent, HandTipUsesTheHoverMappingAtFourThreeAndWidescreenFill) { const auto texture = gfx::CreateTexture({1, 1}); const uint8_t white[] = {255, 255, 255, 255}; ASSERT_TRUE(gfx::UpdateTexture(texture, 0, 0, 0, 1, 1, white)); - MenuMouseNoteHand({.texture = texture}, {0, 0, 40, 24}, 0.2f, 0, 128); + MenuMouseNoteHand({.texture = texture}, {0, 0, 40, 24}, 0.2f, 128); gfx::BeginRecording(); MenuMouseDraw(); const auto list = gfx::EndRecording(); @@ -1884,6 +1884,37 @@ TEST(MglibPresent, HandTipUsesTheHoverMappingAtFourThreeAndWidescreenFill) { InputSetMouseSettings(settings); } +TEST(MglibPresent, MenuPointerKeepsOneFacingWithItsFingertipOnTheHotspot) { + PresentRenderer renderer(1280, 960); + InputResetBindings(); + InputSetScriptedDevices({.mouse_dx = 1}); + MenuMouseEnable(true); + MenuMouseTake(); + const auto point = MenuMouseDisplaySample(); + ASSERT_TRUE(point.pointing); + const auto texture = gfx::CreateTexture({1, 1}); + const uint8_t white[] = {255, 255, 255, 255}; + ASSERT_TRUE(gfx::UpdateTexture(texture, 0, 0, 0, 1, 1, white)); + // Retail mirrors Max's Character page hand; the pointer is given no facing and keeps its usual one. + MenuMouseNoteHand({.texture = texture}, {0, 0, 40, 24}); + gfx::BeginRecording(); + MenuMouseDraw(); + const auto list = gfx::EndRecording(); + ASSERT_EQ(gfx::ListStats(*list).draws_2d, 1u); + const auto *draw = std::get_if(&list->entries.front()); + ASSERT_NE(draw, nullptr); + ASSERT_EQ(draw->vertices.size(), 4u); + // The wrist (u = 0) stays left of the fingertip (u = 40), which sits on the pointer. + EXPECT_LT(draw->vertices[0].x, draw->vertices[1].x); + EXPECT_LT(draw->vertices[0].u, draw->vertices[1].u); + EXPECT_NEAR((draw->vertices[1].x + draw->vertices[3].x) / 2, point.x, 0.0001f); + EXPECT_NEAR((draw->vertices[1].y + draw->vertices[3].y) / 2, point.y, 0.0001f); + MenuMouseEnable(false); + InputSetScriptedDevices({}); + InputSetMenuMouse(false); + gfx::DestroyTexture(texture); +} + namespace { void CaptureRidepodWarning(bool wide) { const char *data = std::getenv("DC2_DATA"); diff --git a/tools/input/character-mouse-cedric.input b/tools/input/character-mouse-cedric.input new file mode 100644 index 000000000..18e30c522 --- /dev/null +++ b/tools/input/character-mouse-cedric.input @@ -0,0 +1,38 @@ +# --jump town:10 --fast-load --window 1920x1080; use --screenshot-fps to see the pointer. +# Click Cedric, then View Status (950), click beside the commands (1040: nothing happens), +# Repair Weapons (1150), Choose weapon (1320: the repair board), click beside the board +# (1400: nothing is repaired), hover the Broad Sword (1430). +300 triangle +306 +370 down +371 +420 cross +426 +600 mouse:-425,345 +601 +610 mouse1 +612 +700 mouse:30,-340 +701 +720 mouse1 +722 +960 mouse:735,-95 +961 +970 mouse1 +972 +1060 mouse:-780,218 +1061 +1070 mouse1 +1072 +1160 mouse:240,-133 +1161 +1230 mouse1 +1232 +1340 mouse:-230,-355 +1341 +1350 mouse1 +1352 +1420 mouse:645,100 +1421 +1440 mouse1 +1442 diff --git a/tools/input/character-mouse.input b/tools/input/character-mouse.input new file mode 100644 index 000000000..2f2e2782e --- /dev/null +++ b/tools/input/character-mouse.input @@ -0,0 +1,20 @@ +# --jump town:10 --fast-load --window 1920x1080; use --screenshot-fps to see the pointer. +# Hover Monica (610), then Max with an unmirrored pointer (630); click Monica (650), then YES (740). +300 triangle +306 +370 down +371 +420 cross +426 +600 mouse:-85,-5 +601 +620 mouse:390,-245 +621 +640 mouse:-390,245 +641 +650 mouse1 +652 +720 mouse:-13,70 +721 +740 mouse1 +742 diff --git a/tools/input/help-mouse.input b/tools/input/help-mouse.input new file mode 100644 index 000000000..c8b44b6dc --- /dev/null +++ b/tools/input/help-mouse.input @@ -0,0 +1,53 @@ +# --jump dungeon:0:1 --fast-load --window 1920x1080; use --screenshot-fps to see the pointer. +# Hover entry 3 (2610), wheel down 12 rows (2660), hover entry 22 (2690), click it for its +# picture pages (2850), wheel to page 2 (2920) and back (2960), right click to the list (3100). +300 cross +306 +520 cross +526 +900 start +906 +940 triangle +946 +1100 cross +1106 +1250 cross +1256 +1400 cross +1406 +1550 cross +1556 +1700 cross +1706 +1850 cross +1856 +2000 cross +2006 +2400 triangle +2401 +2470 down +2471 +2482 down +2483 +2494 down +2495 +2506 down +2507 +2518 down +2519 +2520 cross +2526 +2600 mouse:-160,-202 +2601 +2620 wheel:-12 +2621 +2680 mouse:0,378 +2681 +2700 mouse1 +2702 +2900 wheel:-1 +2901 +2940 wheel:1 +2941 +2980 mouse2 +2982 diff --git a/tools/input/options-mouse.input b/tools/input/options-mouse.input new file mode 100644 index 000000000..0f5ce218e --- /dev/null +++ b/tools/input/options-mouse.input @@ -0,0 +1,29 @@ +# --jump town:10 --fast-load --window 1920x1080; use --screenshot-fps to see the pointer. +# Message Speed label (610), its FAST button (630), wheel down three rows (680), +# click Map Display 3 (740), hover Blur Background (770), open PC SETTINGS from its label (840). +300 triangle +306 +370 down +371 +382 down +383 +394 down +395 +406 down +407 +420 cross +426 +600 mouse:-360,-180 +601 +620 mouse:775,0 +621 +650 wheel:-3 +651 +700 mouse1 +702 +750 mouse:-775,330 +751 +790 mouse:660,-550 +791 +800 mouse1 +802 From cbcccfefb76adc85623990250d1af23dd4bb08c5 Mon Sep 17 00:00:00 2001 From: carrdylan52 <17089587+carrdylan52@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:02:00 -0400 Subject: [PATCH 3/5] Port: draw the menu pointer into --screenshot-fps captures The FPS capture and presentation now record the FPS counter and menu pointer through one helper, so a capture shows the pointer where a presented frame does. The usage text and PC.md say so. --- docs/PC.md | 7 ++++--- port/src/main.cpp | 2 +- port/src/present.cpp | 34 ++++++++++++++++++++++------------ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/PC.md b/docs/PC.md index 8fe8f1643..820e9986e 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -376,7 +376,7 @@ darkchronicle [options] | `--frames N` | exit with status 0 after frame N of the main loop. N is a positive integer | | `--screenshot PATH` | with `--frames`: write the last frame to PATH as a PNG on exit. A failed write exits with status 1 | | `--screenshot-display N` | with `--screenshot`: also write N interpolated display frames of that frame, as `-display-.png` for k = 0 to N-1 | -| `--screenshot-fps` | with `--screenshot`: draw the FPS counter into the image, from a presented frame at window size. With `--offscreen`, nothing is presented: the image is read back from the display image instead | +| `--screenshot-fps` | with `--screenshot`: draw the FPS counter and any menu pointer into the image, from a presented frame at window size. With `--offscreen`, nothing is presented: the image is read back from the display image instead | | `--input SCRIPT` | drive the pads from an input script at start-up. A bad script exits with status 2 | | `--display-per-tick N` | also render N interpolated display frames per clock tick, discarded. N is a positive integer | | `--show-fps` | draw the FPS counter on presented frames, headless too | @@ -707,7 +707,8 @@ be clicked, and a visible row above the new scroll still can. `tools/input/character-mouse.input`, `character-mouse-cedric.input`, `options-mouse.input` (`--jump town:10`) and `help-mouse.input` (`--jump dungeon:0:1`) replay these at `--window 1920x1080` -with `--fast-load`; their comments give capture frames. +with `--fast-load`; their comments give capture frames. `--screenshot-fps` +captures include the menu pointer. ### Mouse follow camera @@ -960,7 +961,7 @@ The PAL scripts (`.stb`) hold the NTSC frame counts and convert them while they - The FPS counter: `video.show_fps` turns it on (F3 and the `fps_toggle` host action flip it; headless only with `--show-fps`). It is drawn through `OverlayRecord`, as a display render's overlay, in the overlay's own font (the Dark Cloud Compendium Community Font baked into `overlay_font.inc`), so no canonical image holds it. The game's message font is not used: its texture lives only in the current mode's texture block (`ReLoadFontTexture` per mode) and `FontTex[0]` dangles across mode changes, so a badge in it would come and go with the modes. Its text is set by `video.fps_detail`; with all fields it reads `FPS 143.9 TICK 60.0/60 DRAWS 412`. TICK counts clock ticks per second, so it reads the tick rate whatever `mgFrameRate` is. DRAWS is the newest list's mesh plus 2D draws. `--log-fps` prints the same text on stderr once a second. - Interpolation diagnostic: set `KZ_INTERPOLATION_TINT=1` to tint draws on the main presentation image (default off). Green is a matched transformed mesh; cyan is an unmatched or pose-excluded mesh carried by the display camera; red is newest-only geometry; blue is flat 2D or a screen effect; yellow is a depth-tested sprite with reprojected corners. Unchanged matched meshes remain green. This classifies geometry, not all its inputs: texture contents, lighting, fog parameters and sprite motion can still be tick-exact. Alpha tests, coverage, depth and blending are retained, so translucent passes can mix tints. Reusable intermediate render targets keep their original colours; their final composites are tinted on the main image. Canonical renders and ordinary screenshots never receive the tint. Use `--screenshot-display 8` to inspect the diagnostic at eight interpolation phases. Optional `KZ_INTERPOLATION_TRACE=` appends per-draw CSV sections at alpha zero, including matching/cut reasons, texture IDs, camera IDs, target IDs, whether a draw is tinted on the main image, and changed fog/material/projection/local inputs. It writes only while tint is enabled; remove both environment variables after diagnosis. - Outlined models render into a shared transparent image. Canonical clears and copies retain the retail draw rectangle; presentation scratch clears and replacement vertices cover the full visible extent so the interpolated model/bone pose remains complete under the display camera, including fixed-camera mouse look. Each copy preserves its texture-to-screen offset. Fixed-look current and previous lists preserve presentation vertices and clear rectangles while leaving resource retirement ownership with their source lists. Transparent texels in a depth-writing presentation composite do not write depth. GPU regressions compare a rotating thin part at alpha 0.5 against an independently recorded horizontal pose and complete fixed-look outline pixels at alpha 1 and 0.5 against a direct full-extent reference render; translation, scratch-target reuse and transparent-depth regressions remain covered. `tools/input/area-dungeon-chest-turn.input` with `--jump dungeon:0:1 --fast-load --frames 2518 --screenshot chest.png --screenshot-display 8` exercises the unopened chest during a camera turn. For multi-part NPCs, use `--input tools/input/repro-town-npc-outline.input --jump town:10 --fast-load --frames 278 --window 1280x720 --screenshot woman.png --screenshot-display 8`; the woman's face must remain visible throughout the turn. `--input tools/input/repro-clown-outline.input --frames 27230 --window 1280x720 --screenshot clown.png --screenshot-display 8` checks the clown's face during the prologue camera move. -- `--screenshot-display N` writes N display frames of the screenshot's frame, at alpha k/N from the previous list to the last, as `-display-.png`. `--screenshot-fps` writes the screenshot with the FPS counter drawn into it, at window size, from a presented frame. With `--offscreen`, nothing is presented, and the image is read back from the display image instead. The counter reaches no other written image. +- `--screenshot-display N` writes N display frames of the screenshot's frame, at alpha k/N from the previous list to the last, as `-display-.png`. `--screenshot-fps` writes the screenshot with the FPS counter and any visible menu pointer drawn into it, at window size, from a presented frame (`RecordHostOverlay`, shared with presentation). With `--offscreen`, nothing is presented, and the image is read back from the display image instead. Neither reaches any other written image. - `video.max_fps=0` leaves presentation uncapped; a positive cap paces display starts without catching up in bursts. A due logic tick takes priority. FIFO follows the display's synchronization, while mailbox and immediate depend on device support. Interpolation off presents canonical frames at their game cadence; a high display cap does not increase the logic tick rate. ### Packets diff --git a/port/src/main.cpp b/port/src/main.cpp index 620ac48e1..fd45f1080 100644 --- a/port/src/main.cpp +++ b/port/src/main.cpp @@ -83,7 +83,7 @@ Options g_options; " --screenshot PATH with --frames: write the last frame to PATH as a PNG on exit\n" " --screenshot-display N with --screenshot: also write N interpolated display frames of that frame\n" " beside PATH, as PATH-display-K.png for K = 0..N-1\n" - " --screenshot-fps with --screenshot: draw the FPS counter into the written image\n" + " --screenshot-fps with --screenshot: draw the FPS counter and menu pointer into the written image\n" " --input SCRIPT drive the pads named in SCRIPT from an input script\n" " --window WxH window size in pixels (default: config.json, then the monitor's)\n" " --display-per-tick N headless: also render N interpolated display frames per tick (discarded)\n" diff --git a/port/src/present.cpp b/port/src/present.cpp index fa4dd90ea..f211ee3d6 100644 --- a/port/src/present.cpp +++ b/port/src/present.cpp @@ -48,6 +48,21 @@ void PollFpsToggle() { } } +// Records the host overlay of a presented frame: the FPS counter when asked, then the menu pointer. +gfx::DisplayListRef RecordHostOverlay(bool fps) { + gfx::BeginRecording(gfx::RecordingKind::Overlay); + if (!gfx::Recording()) { + return nullptr; + } + if (fps) { + const int pixel = OverlayPixelSize(gfx::GetLogicalMapping(gfx::kMainTarget)); + OverlayDrawText(PresentFpsText(), gfx::GetUiMapping(gfx::kMainTarget), pixel, + pixel * kOverlayPadding, pixel * kOverlayPadding); + } + MenuMouseDraw(); + return gfx::EndRecording(); +} + const gfx::DisplayList *DisplayOverlayList() { if (PresentMouseActive() && !ClockUnbounded()) { if (!WindowPollEvents()) { @@ -59,17 +74,7 @@ const gfx::DisplayList *DisplayOverlayList() { if (!overlay.on && !MenuMousePointerVisible()) { return nullptr; } - gfx::BeginRecording(gfx::RecordingKind::Overlay); - if (!gfx::Recording()) { - return nullptr; - } - if (overlay.on) { - const int pixel = OverlayPixelSize(gfx::GetLogicalMapping(gfx::kMainTarget)); - OverlayDrawText(PresentFpsText(), gfx::GetUiMapping(gfx::kMainTarget), pixel, - pixel * kOverlayPadding, pixel * kOverlayPadding); - } - MenuMouseDraw(); - overlay.list = gfx::EndRecording(); + overlay.list = RecordHostOverlay(overlay.on); return overlay.list.get(); } @@ -316,10 +321,15 @@ bool PresentScreenshotFps(std::vector &rgba, uint32_t &width, uint32_t // The overlay exists only in a presented image, and the canonical target never takes it, so the // composite is presented at alpha 1 and read back from the window-sized display image. - const gfx::DisplayListRef fps = OverlayRecord(PresentFpsText()); + // Like a presented frame, it carries the menu pointer in place of the retail hand it stands for. + const gfx::DisplayListRef fps = RecordHostOverlay(true); + if (!fps) { + return false; + } gfx::RenderOptions options; options.present = true; options.overlay = fps.get(); + options.suppressed_2d_key = MenuMouseReplacedHandKey(); return gfx::RenderList(*list, 1.0f, options) && gfx::ReadbackFrame(rgba, width, height); } From ee833d04c2e618024b4f836d14ed38eb36285875 Mon Sep 17 00:00:00 2001 From: carrdylan52 <17089587+carrdylan52@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:47:20 -0400 Subject: [PATCH 4/5] Port: scroll the shop's goods list and bag a row per wheel notch A notch over the bag moved the cursor a row while the board stayed put, so with the pointer on the top row the selection and its Sell price popup walked away from it. A goods list or bag longer than its window now scrolls one row per notch through the retail eased scroll (MenuMouseScroll for the list, the ITEM page's ScrollTop and WheelTarget for the bag), and the cursor takes the entry that settles under the pointer. Rows sliding out of the window can no longer be hovered, so a hover cannot scroll the pane back. A list that fits still moves its cursor through the retail keys. The wheel never enters an empty goods list, as retail keeps the bag in front when stock runs out. --- docs/PC.md | 15 ++- port/src/menu_mouse_shop.cpp | 117 +++++++++++++++-------- port/src/tests/menu_mouse_shop_test.cpp | 120 +++++++++++++++++++++--- tools/input/shop-mouse-wheel.input | 23 +++++ 4 files changed, 218 insertions(+), 57 deletions(-) create mode 100644 tools/input/shop-mouse-wheel.input diff --git a/docs/PC.md b/docs/PC.md index f172c199c..b9c9f5e50 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -652,9 +652,15 @@ Continue, a file-row click and its confirmation using a copied flat-save fixture `title-mouse-newgame.input` clicks New Game and reaches costume selection. File-list clicks wait through the retail cursor-settling delay before confirming. -Shops support hovering and clicking the goods list and bag grid. The wheel moves -through goods or bag rows; in a buy/sell quantity prompt it changes the quantity -within the game's limits. The quantity arrows, number panel, and localized Quit +Shops support hovering and clicking the goods list and bag grid. The wheel acts +on the pane under the pointer: a goods list or bag longer than its window scrolls +one row a notch with the retail eased scroll, stopping at its ends, and the cursor +takes the entry that settles under the pointer, or stays in view; a list that +fits moves its cursor a row. An empty goods list ignores the wheel and the bag +keeps the cursor, as it does for the D-pad. Rows still sliding out of the window +cannot be hovered; `tools/input/shop-mouse-wheel.input` replays both panes at 1920x1080. +In a buy/sell quantity prompt the wheel changes the quantity within the game's +limits. The quantity arrows, number panel, and localized Quit button accept clicks, and right click cancels. Item popups, stat grids, the ITEM page and PC SETTINGS use the shared pointer adapters described below. Some secondary destination screens still retain only their existing controls. @@ -699,7 +705,8 @@ is unchanged on all three pages. The wheel follows one rule on these pages: in a list longer than its window, each notch scrolls the list one row and the cursor takes the row that comes under the pointer, or stays in view (`MenuMouseScroll`; the ITEM page's bag scrolls the -same way). Elsewhere it moves the cursor, as in shops and the root menu; the +same way, as do the shop's goods list and bag). Elsewhere it moves the cursor, +as in the root menu; the Character page's portrait map is not a list, so the wheel does nothing there. Options and Help rows are hit where they are drawn while the list eases to a new scroll, and only inside the list's scissor (`clip0`): a row sliding under the footer cannot diff --git a/port/src/menu_mouse_shop.cpp b/port/src/menu_mouse_shop.cpp index 0f9bac333..9fb0535f3 100644 --- a/port/src/menu_mouse_shop.cpp +++ b/port/src/menu_mouse_shop.cpp @@ -6,29 +6,44 @@ #include "gamepad.hpp" #include "mainloop.hpp" #include "menu_mouse.hpp" +#include "menu_mouse_item.hpp" #include "menumain.hpp" #include "menushop.hpp" +#include "menusys.hpp" namespace { CShopMenu *shop_menu = nullptr; CShop *shop_stock = nullptr; CMenuPosDataManage *shop_forms = nullptr; -int BagHit(const MenuMouseSample &mouse, CMenuPosDataForm *board, int count) { - if (!board || !board->draw_flag) { - return -1; +struct ShopList { + bool over = false; // The pointer is inside the list's window. + int row = -1; // The drawn goods row under the pointer, if it stays in the window the list eases to. + int shown = -1; // The window row under the pointer once the list rests at its scroll. +}; + +ShopList ListHit(const CShopMenu &menu, const MenuMouseSample &mouse, int goods) { + ShopList hit; + if (!menu.item_brd || !menu.item_brd->draw_flag || !menu.item_list || !menu.item_list->draw_flag) { + return hit; } - float x, y; - board->GetPutPosXY(nullptr, x, y); - if (!MenuMouseRect{x + 16, y + 20, 240, 250}.Contains(mouse.x, mouse.y)) { - return -1; + float x, y, clip_x, clip_y; + menu.item_list->GetPutPosXY(nullptr, x, y); + menu.item_brd->GetPutPosXY(nullptr, clip_x, clip_y); + const MenuMouseRect clip{clip_x + 14, clip_y + 44, 184, SHOP_LIST_LINE * 44}; + if (!clip.Contains(mouse.x, mouse.y)) { + return hit; } - const float bx = MenuItemBrdUnderBrdPosXY[0], by = MenuItemBrdUnderBrdPosXY[1]; - if (mouse.x < bx || mouse.x >= bx + 240 || mouse.y < by) { - return -1; + hit.over = true; + hit.shown = int((mouse.y - clip.y) / 44); + if (mouse.y >= y + 44) { + const int row = int((mouse.y - y - 44) / 44); + // A row still sliding out of the eased list is no longer selectable. + if (row < goods && row >= menu.list_top && row < menu.list_top + SHOP_LIST_LINE) { + hit.row = row; + } } - const int slot = int((mouse.y - by) / 50) * 6 + int((mouse.x - bx) / 40); - return slot < count ? slot : -1; + return hit; } } // namespace @@ -47,40 +62,66 @@ void MenuMouseShopUpdate(CShopMenu &menu, CMenuKeyFunc &keys, int goods) { const bool choose = mouse.wheel == 0 && (mouse.moved || (mouse.clicked & 1)); int buttons = 0; if (menu.key_arg_no == SHOP_MENU_MODE_BUY_LIST || menu.key_arg_no == SHOP_MENU_MODE_BAG) { - auto *board = MenuPosData->GetFormInfo("itembrd"); - const int bag_count = GetNowBagMax(ITEM_BOARD_MAIN); - const int bag = BagHit(mouse, board, bag_count); - int row = -1; - if (menu.item_brd && menu.item_brd->draw_flag && menu.item_list && menu.item_list->draw_flag) { - float x, y, clip_x, clip_y; - menu.item_list->GetPutPosXY(nullptr, x, y); - menu.item_brd->GetPutPosXY(nullptr, clip_x, clip_y); - if (MenuMouseRect{clip_x + 14, clip_y + 44, 184, SHOP_LIST_LINE * 44}.Contains(mouse.x, mouse.y) && - mouse.y >= y + 44) { - const int hit = int((mouse.y - y - 44) / 44); - if (hit < goods) { - row = hit; - } + using namespace menumouse; + auto *board = MenuPosData->GetFormInfo("itembrd"); + const int bag_count = GetNowBagMax(ITEM_BOARD_MAIN); + const bool can_buy = keys.have_item.item_no <= 0; + const Point p{mouse.x, mouse.y}; + const bool on_board = board && board->draw_flag && ItemBoardClip(board).Contains(p); + int bag = -1; + if (on_board) { + const Target hit = GridHit(p, {MenuItemBrdUnderBrdPosXY[0], MenuItemBrdUnderBrdPosXY[1]}, ItemBoardClip(board), bag_count); + // A row still sliding out of the eased board is no longer selectable. + if (hit && VisibleRow(hit.cursor, menu.bag_top, MENU_ITEM_BOARD_ROWS)) { + bag = hit.cursor; } } - if (choose && row >= 0 && keys.have_item.item_no <= 0) { - menu.key_arg_no = SHOP_MENU_MODE_BUY_LIST; - menu.list_pos = row; - } else if (choose && bag >= 0) { - menu.key_arg_no = SHOP_MENU_MODE_BAG; - menu.bag_pos = bag; + const ShopList list = ListHit(menu, mouse, goods); + // A click confirms where it was pressed; a wheel notch in the same sample is dropped. + const float wheel = mouse.clicked & 1 ? 0 : mouse.wheel; + bool scrolled = false; + if (std::isfinite(wheel) && wheel != 0) { + // A pane longer than its window scrolls a row a notch, as the ITEM page's bag does, and the + // cursor takes the entry that comes under the pointer. One that fits moves its cursor. + const int direction = wheel > 0 ? PAD_UP : PAD_DOWN; + // An empty goods list keeps the bag in front, as the retail step does when stock runs out. + if (list.over && can_buy && goods > 0) { + menu.key_arg_no = SHOP_MENU_MODE_BUY_LIST; + if (goods > SHOP_LIST_LINE) { + const auto view = MenuMouseScroll(wheel, {menu.list_top, menu.list_pos}, goods, SHOP_LIST_LINE, list.shown); + if (view.top != menu.list_top) { + menu.list_top = view.top; + menu.list_pos = view.selected; + } + scrolled = true; + } else { + buttons |= direction; + } + } else if (on_board) { + menu.key_arg_no = SHOP_MENU_MODE_BAG; + if ((bag_count + MENU_ITEM_BOARD_COLUMNS - 1) / MENU_ITEM_BOARD_COLUMNS > MENU_ITEM_BOARD_ROWS) { + // The retail board eases to the new top line, as it does for a D-pad scroll. + const int top = ScrollTop(menu.bag_top, bag_count, MENU_ITEM_BOARD_ROWS, wheel); + if (top != menu.bag_top) { + menu.bag_pos = WheelTarget(p, ItemBoardRest(board), ItemBoardClip(board), top, bag_count, menu.bag_pos).cursor; + menu.bag_top = top; + } + scrolled = true; + } else { + buttons |= direction; + } + } } - if (std::isfinite(mouse.wheel) && mouse.wheel != 0) { - const int direction = mouse.wheel > 0 ? PAD_UP : PAD_DOWN; - if (row >= 0 && keys.have_item.item_no <= 0) { + if (!scrolled && (mouse.moved || (mouse.clicked & 1))) { + if (list.row >= 0 && can_buy) { menu.key_arg_no = SHOP_MENU_MODE_BUY_LIST; - buttons |= direction; + menu.list_pos = list.row; } else if (bag >= 0) { menu.key_arg_no = SHOP_MENU_MODE_BAG; - buttons |= direction; + menu.bag_pos = bag; } } - if ((mouse.clicked & 1) && (bag >= 0 || (row >= 0 && keys.have_item.item_no <= 0))) { + if ((mouse.clicked & 1) && (bag >= 0 || (list.row >= 0 && can_buy))) { buttons |= PAD_CROSS; } } else if (menu.key_arg_no == SHOP_MENU_MODE_BUY_NUM || menu.key_arg_no == SHOP_MENU_MODE_SELL_NUM) { diff --git a/port/src/tests/menu_mouse_shop_test.cpp b/port/src/tests/menu_mouse_shop_test.cpp index 1ba0ace9b..dbcde7a12 100644 --- a/port/src/tests/menu_mouse_shop_test.cpp +++ b/port/src/tests/menu_mouse_shop_test.cpp @@ -136,31 +136,121 @@ TEST_F(ShopMouse, HoverSelectsBothPanesBeforeClickConfirms) { EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); } -TEST_F(ShopMouse, WheelRoutesToPaneUnderPointerAndUsesRetailScroll) { - At(320, 170); - At(60, 136, 0, -1); +TEST_F(ShopMouse, WheelScrollsTheLongGoodsListARowAndTakesTheRowUnderThePointer) { + At(60, 100); + EXPECT_EQ(menu.list_pos, 0); + At(60, 100, 0, -1); EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); - EXPECT_EQ(keys.CheckLRKey(), MENU_SELECT_KEY_DOWN); - MenuListKeyCheck(keys.select_key, &menu.list_pos, &menu.list_top, stock.item_num, 6, 0, 0); + EXPECT_EQ(menu.list_top, 1); + EXPECT_EQ(menu.list_pos, 1); // The row that comes under the pointer. + EXPECT_EQ(keys.CheckLRKey(), 0); + // The retail key step keeps the new scroll. + MenuListKeyCheck(keys.CheckLRKey(), &menu.list_pos, &menu.list_top, stock.item_num, SHOP_LIST_LINE, 0, 0); + EXPECT_EQ(menu.list_top, 1); + EXPECT_EQ(menu.list_pos, 1); + // Row 0 is still drawn under the pointer while the list eases; it is sliding out. + At(61, 100); EXPECT_EQ(menu.list_pos, 1); - menu.bag_pos = 24; - At(320, 170, 0, -1); + boards[2].y = 40 - 44; // The list at rest. + At(60, 150); + EXPECT_EQ(menu.list_pos, 2); + At(60, 150, 0, 3); + EXPECT_EQ(menu.list_top, 0); // Stops at the top. + EXPECT_EQ(menu.list_pos, 1); + menu.list_top = stock.item_num - SHOP_LIST_LINE; + menu.list_pos = stock.item_num - 1; + At(60, 300, 0, -1); + EXPECT_EQ(menu.list_top, stock.item_num - SHOP_LIST_LINE); // And at the bottom, where it moves nothing. + EXPECT_EQ(menu.list_pos, stock.item_num - 1); + EXPECT_EQ(keys.CheckLRKey(), 0); +} + +TEST_F(ShopMouse, WheelScrollsTheBagARowAndTakesTheCellThatSettlesUnderThePointer) { + const auto saved_manner = MenuItemBrdCalcManner; + MenuItemBrdCalcManner = MENU_ITEM_BRD_CALC_EASE; + const int count = GetNowBagMax(ITEM_BOARD_MAIN); + ASSERT_GT(count, 30); + MenuItemBrdUnderBrdPosXY[1] = 66; // The board's resting origin at top line 0. + At(300, 80); EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BAG); + EXPECT_EQ(menu.bag_pos, 1); + // The playtest case: the pointer stays on the top row while the wheel turns. + At(300, 80, 0, -1); + EXPECT_EQ(menu.bag_top, 1); + EXPECT_EQ(menu.bag_pos, 7); + EXPECT_EQ(keys.CheckLRKey(), 0); + EXPECT_FLOAT_EQ(MenuItemBrdUnderBrdPosXY[1], 66); // Not snapped by the mouse. MenuItemBrdKey(keys.CheckLRKey(), &menu.bag_pos, &menu.bag_top, ITEM_BOARD_MAIN); - EXPECT_EQ(menu.bag_pos, 30); EXPECT_EQ(menu.bag_top, 1); + EXPECT_EQ(menu.bag_pos, 7); + Func_MenuItemBrdPosStep(menu.bag_top); + EXPECT_LT(MenuItemBrdUnderBrdPosXY[1], 66); // The retail board eases like a D-pad scroll. + EXPECT_GT(MenuItemBrdUnderBrdPosXY[1], 16); + MenuItemBrdUnderBrdPosXY[1] = 50; + At(301, 62); // Cell 1 is still drawn here, sliding out. + EXPECT_EQ(menu.bag_pos, 7); + At(341, 110); + EXPECT_EQ(menu.bag_pos, 8); + At(341, 110, 0, -2); + EXPECT_EQ(menu.bag_top, 3); + EXPECT_EQ(menu.bag_pos, 20); + At(341, 110, 0, 9); + EXPECT_EQ(menu.bag_top, 0); + EXPECT_EQ(menu.bag_pos, 2); + MenuItemBrdCalcManner = saved_manner; +} + +TEST_F(ShopMouse, WheelRoutesToThePaneUnderThePointer) { + menu.key_arg_no = SHOP_MENU_MODE_BAG; + At(60, 100, 0, -1); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); + EXPECT_EQ(menu.list_top, 1); + At(300, 80, 0, -1); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BAG); + EXPECT_EQ(menu.bag_top, 1); + EXPECT_EQ(menu.list_top, 1); At(510, 470, 0, -1); + EXPECT_EQ(menu.bag_top, 1); EXPECT_EQ(MenuMouseButtons(), 0); } -TEST_F(ShopMouse, HoverTracksAnimatedListAndGridOrigins) { - menu.list_top = 2; - boards[2].y = -26; +TEST_F(ShopMouse, WheelOverAListThatFitsMovesItsCursorThroughRetailKeys) { + stock.item_num = 4; At(60, 100); - EXPECT_EQ(menu.list_pos, 1); // Actual drawn row during the scroll, not the target top. - MenuItemBrdUnderBrdPosXY[1] = 10; - At(320, 170); - EXPECT_EQ(menu.bag_pos, 19); + At(60, 100, 0, -1); + EXPECT_EQ(menu.list_top, 0); + EXPECT_EQ(keys.CheckLRKey(), MENU_SELECT_KEY_DOWN); + MenuListKeyCheck(keys.select_key, &menu.list_pos, &menu.list_top, stock.item_num, SHOP_LIST_LINE, 0, 0); + EXPECT_EQ(menu.list_pos, 1); +} + +TEST_F(ShopMouse, WheelOverAnEmptyGoodsListKeepsTheBagInFront) { + // Retail moves to the bag when the stock runs out (an exhausted Donny reward list) and will not return. + stock.item_num = 0; + menu.key_arg_no = SHOP_MENU_MODE_BAG; + menu.bag_pos = 7; + menu.list_pos = menu.list_top = 0; + At(60, 100); + for (float wheel : {-1.0f, 1.0f, -1.0f}) { + At(60, 100, 0, wheel); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BAG); + EXPECT_EQ(menu.bag_pos, 7); + EXPECT_EQ(menu.list_pos, 0); + EXPECT_EQ(menu.list_top, 0); + EXPECT_EQ(MenuMouseButtons(), 0); + EXPECT_EQ(keys.CheckLRKey(), 0); + } + At(60, 150, 1); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BAG); + EXPECT_EQ(MenuMouseButtons(), 0); +} + +TEST_F(ShopMouse, ClickWithAWheelNotchConfirmsTheRowItWasPressedOn) { + At(60, 150); + At(60, 150, 1, -1); + EXPECT_EQ(menu.list_top, 0); + EXPECT_EQ(menu.list_pos, 1); + EXPECT_EQ(MenuMouseButtons(), PAD_CROSS); } TEST_F(ShopMouse, QuantityWheelUsesRetailDirectionsAtBothLimitsInBuyAndSell) { diff --git a/tools/input/shop-mouse-wheel.input b/tools/input/shop-mouse-wheel.input new file mode 100644 index 000000000..fd5dc4810 --- /dev/null +++ b/tools/input/shop-mouse-wheel.input @@ -0,0 +1,23 @@ +300 128 0 128 128 +365 cross 128 0 128 128 +366 +600 255 128 128 128 +618 +650 cross +656 +800 cross +806 +# --jump town:10 --fast-load --window 1920x1080; use --screenshot-fps to see the pointer. +# The pointer rests on the inventory's top row (980) while two wheel notches scroll the +# board a row each; the cell that settles under it is selected (1040, 1100; 1004 mid-ease). +# On the goods list a notch scrolls the list a row the same way (1240). +960 mouse:125,-370 +961 +1000 wheel:-1 +1001 +1060 wheel:-1 +1061 +1150 mouse:-560,200 +1151 +1200 wheel:-1 +1201 From 2058a9ecb59f95dba4098e5184cbc8eeac963ad5 Mon Sep 17 00:00:00 2001 From: carrdylan52 <17089587+carrdylan52@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:47:20 -0400 Subject: [PATCH 5/5] Port: play the cursor sound when the mouse moves a menu cursor Hover and the wheel moved several menu cursors silently: shop goods, bag and quantity board, the ITEM page's build-up list and the same index on another ITEM panel. MenuMouseCursorSound plays SYSTEM_SE_CURSOR at most once a tick from the adapters that set a cursor themselves; paths where retail already sounds the move (synthesized directions, MenuMouseMove, the post-hook cursor check) are left to retail. In the shop, the adapter sounds its own pane or quantity change only when the retail press it sends will not move, so each real change sounds once, at limits too. The fishing-rod stats no longer sound twice. PC SETTINGS rows stay silent, as with the keyboard. --- docs/PC.md | 16 +++ port/src/menu_mouse.hpp | 7 ++ port/src/menu_mouse_cursor.cpp | 27 +++++ port/src/menu_mouse_item.cpp | 19 ++-- port/src/menu_mouse_make.cpp | 4 +- port/src/menu_mouse_menus.cpp | 10 +- port/src/menu_mouse_monster.cpp | 4 +- port/src/menu_mouse_shop.cpp | 46 ++++++++ port/src/tests/menu_mouse_item_test.cpp | 41 ++++++++ port/src/tests/menu_mouse_shop_test.cpp | 134 ++++++++++++++++++++++++ port/src/tests/menu_mouse_test.cpp | 14 +++ 11 files changed, 307 insertions(+), 15 deletions(-) diff --git a/docs/PC.md b/docs/PC.md index b9c9f5e50..7fa1d934c 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -673,6 +673,22 @@ pointer immediately. Ordinary dialogue choices share the message adapter; the rendered confirmation replays and drawn-message unit tests cover that input path, but campaign dialogue choices still need a dedicated replay. +Hover and the wheel play the menu's retail cursor-move sound once for each +change of its cursor, never while the target stays the same. Where the mouse +moves a cursor through the retail key step (`MenuMouseMove`, a synthesized +D-pad press, or a cursor the step compares after the hook: the root menu, file +lists, Options rows, Help, the floor map, message choices and the ITEM page's +bag and panels), that step plays it. Adapters that set a cursor themselves call +`MenuMouseCursorSound`, which plays at most once a tick: shops, item popups, +the title menu, Make, Monster Transform, Character portraits and Cedric's +board, Options choices, the ITEM page's command and build-up lists, and a move +to the same index on another ITEM panel. A shop wheel notch or arrow click +that changes the pane or leaves Quit also presses Up or Down. The retail step +sounds that press only if it moves, so at a list end or quantity limit the +shop adapter sounds the change instead. Fishing rod stats are the ITEM +cursor, so only the retail check sounds them. PC SETTINGS rows stay silent, as +they are with keys. + ### Character, Options and Help mouse On the Character page, hovering a shown portrait (Max, Monica, the Ridepod, diff --git a/port/src/menu_mouse.hpp b/port/src/menu_mouse.hpp index 078ffb477..da8fa3464 100644 --- a/port/src/menu_mouse.hpp +++ b/port/src/menu_mouse.hpp @@ -45,6 +45,13 @@ const MenuMouseSample &MenuMouseTake(); int MenuMouseButtons(); void MenuMousePress(int buttons); void MenuMouseMove(int *cursor, int selected); +// The retail cursor-move sound for an adapter that sets a retail cursor itself, at most once a tick. +// Cursors moved through MenuMouseMove or a synthesized D-pad press sound in the retail key step. +void MenuMouseCursorSound(); +// How many cursor sounds the mouse has played, for tests. +int MenuMouseCursorSounds(); +// How many cursor sounds any menu has played through MenuSePlay, retail or mouse, for tests. +int MenuCursorSounds(); // Drawn choice geometry is copied, never a retained pointer to game storage. void MenuMouseNoteChoices(const void *owner, int message, std::span rows, int selected, bool horizontal); diff --git a/port/src/menu_mouse_cursor.cpp b/port/src/menu_mouse_cursor.cpp index 6d48750d6..a98438ac3 100644 --- a/port/src/menu_mouse_cursor.cpp +++ b/port/src/menu_mouse_cursor.cpp @@ -1,14 +1,41 @@ #include "common.h" +#include "mainloop.hpp" #include "menu_mouse.hpp" +#include "menucommon.hpp" #include "menusys.hpp" #include "mglib_port.hpp" +#include "snd_mngr.hpp" namespace { int *pending_cursor = nullptr; int pending_selection = 0, pending_frame = -1; +int sound_frame = -1, sounds = 0, all_sounds = 0; } // namespace +void MenuMouseCursorSound() { + if (sound_frame == mgPortFrameCount()) { + return; + } + sound_frame = mgPortFrameCount(); + ++sounds; + MenuSePlay(SYSTEM_SE_CURSOR); +} + +int MenuMouseCursorSounds() { return sounds; } + +// Retail's body; it counts every menu cursor sound, retail or mouse, for tests. +PORT_OVERRIDE void MenuSePlay(int sound_no) { + if (sound_no == SYSTEM_SE_CURSOR) { + ++all_sounds; + } + if (sound_no >= 0) { + MenuSePlay(SystemSND_ID, sound_no); + } +} + +int MenuCursorSounds() { return all_sounds; } + void MenuMouseMove(int *cursor, int selected) { pending_cursor = cursor; pending_selection = selected; diff --git a/port/src/menu_mouse_item.cpp b/port/src/menu_mouse_item.cpp index 5174113bd..de1a43cbc 100644 --- a/port/src/menu_mouse_item.cpp +++ b/port/src/menu_mouse_item.cpp @@ -240,6 +240,10 @@ void Select(CMenuItemInfo &menu, CMenuKeyFunc &keys, Target target, MENU_INPUTKE if (menu.key_arg_no == ITEM_MENU_PANEL_BAG) { bag_top = keys.top_line; } + // KeyStepLocal sounds a cursor that differs from the tick's saved one; the same index on another panel needs its own. + if (menu.key_arg_no != target.panel && target.cursor == keys.save_cursor) { + MenuMouseCursorSound(); + } menu.key_arg_no = target.panel; keys.key_arg = &layouts[target.panel]; keys.cursor = target.cursor; @@ -296,7 +300,7 @@ void Commands(CMenuItemInfo &menu, const Take &take) { const int row = MenuMouseHit(rows, pointer.pos.x, pointer.pos.y); if (row >= 0 && take.moved && row != message->GetMsgCursor()) { message->SetMsgCursor(row); - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } if (row >= 0 && (take.pressed & 1)) { synthetic = PAD_CROSS; @@ -339,7 +343,12 @@ void Question(CMenuItemInfo &menu, CMenuKeyFunc &keys, const Take &take) { for (int i = 0; i < std::clamp(int(BuildUpWeaponInfo.select_num), 0, 3); ++i) { rows[i] = {float(BuildUpNameXY[i][0]), float(BuildUpNameXY[i][1]), 200, 24}; } - BuildUpWeaponInfo.select_no = MenuMouseRows({rows, static_cast(std::clamp(int(BuildUpWeaponInfo.select_num), 0, 3))}, BuildUpWeaponInfo.select_no); + const int selected = MenuMouseRows({rows, static_cast(std::clamp(int(BuildUpWeaponInfo.select_num), 0, 3))}, BuildUpWeaponInfo.select_no); + // The retail build-up step reads its cursor after this hook, so it cannot sound this move. + if (selected != BuildUpWeaponInfo.select_no) { + BuildUpWeaponInfo.select_no = selected; + MenuMouseCursorSound(); + } synthetic = MenuMouseButtons(); return; } @@ -357,7 +366,7 @@ void Question(CMenuItemInfo &menu, CMenuKeyFunc &keys, const Take &take) { const int row = MenuMouseHit(rows, pointer.pos.x, pointer.pos.y); if (row >= 0 && take.moved && row != message->GetMsgCursor()) { message->SetMsgCursor(row); - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } if (row >= 0 && (take.pressed & 1)) { synthetic = PAD_CROSS; @@ -460,9 +469,7 @@ void MenuMouseUpdate(CMenuKeyFunc &keys) { if (menu.mode == MENU_ASK_MODE_ROD_POINTS && menu.step == 0) { const Target hit = Hit(menu, pointer.pos, 0); if (hit.panel == ITEM_MENU_PANEL_FISHING_ROD_STATS && take.moved) { - if (keys.select_pos[0] != hit.cursor) { - MenuSePlay(SYSTEM_SE_CURSOR); - } + // select_pos[0] shares keys.cursor, whose change KeyStepLocal sounds; sounding it here played it twice. keys.cursor = hit.cursor; keys.select_pos[0] = hit.cursor; keys.key_input = true; diff --git a/port/src/menu_mouse_make.cpp b/port/src/menu_mouse_make.cpp index 85e3d48db..0d26bf0cb 100644 --- a/port/src/menu_mouse_make.cpp +++ b/port/src/menu_mouse_make.cpp @@ -146,7 +146,7 @@ void MakeBoardMouse(CMenuInvent &menu, const MenuMouseSample &mouse) { const MakeInput input = MakeBoardInput(board, {mouse.x, mouse.y}, mouse.moved, mouse.clicked, mouse.wheel); if (input.row >= 0 && input.row != menu.make_cursor) { menu.make_cursor = input.row; - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } MenuMouseSetButtons(input.buttons); } @@ -190,7 +190,7 @@ void ListsMouse(CMenuInvent &menu, CMenuKeyFunc &keys, const MenuMouseSample &mo menu.item_cursor = next.item_cursor; menu.item_top = next.item_top; if (result.sound) { - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } MenuMouseSetButtons(result.buttons); } diff --git a/port/src/menu_mouse_menus.cpp b/port/src/menu_mouse_menus.cpp index 4a9fa0f5d..e37d45595 100644 --- a/port/src/menu_mouse_menus.cpp +++ b/port/src/menu_mouse_menus.cpp @@ -67,7 +67,7 @@ void ItemPopupMouse() { if (selected != items.cursor) { items.cursor = selected; MenuCheckLine(&items.top_line, selected / ITEM_SELECT_COLUMNS, 2); - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } } @@ -85,7 +85,7 @@ void TitleMouse() { if (selected != MouseTitle->select) { MouseTitle->select = selected; MouseTitle->idle_count = 0; - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } } else if (MouseTitlePhase <= TITLE_PHASE_PUSH_START) { if (MenuMouseTake().clicked & 1) { @@ -298,7 +298,7 @@ void MenuMouseOptionHover(CMenuOption &menu, CMenuPosDataForm *form, const CMenu // KeyStep compares the choice after this hook, so a move along one row sounds here. if (choice >= 0 && choice != menu.choice && (mouse.moved || mouse.wheel != 0)) { if (same_row) { - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } menu.choice = choice; } @@ -350,7 +350,7 @@ void MenuMouseRepairBoard(CMenuPosDataForm *board, int &select, int &top, int co target = std::clamp(view.selected * kColumns + column, 0, std::max(0, count - 1)); if (target != select) { select = target; - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } // The board owns this tick's buttons, as the shop's does: Cedric's command list stays drawn behind // it, and its choices must neither move nor swallow a click. Repairs spend his points, so only a @@ -385,7 +385,7 @@ void MenuMouseChara(CMenuChrCngMenu &menu) { // KeyChangeMain reads its cursor change after this hook; repeat that change's effects here. const int last = menu.select; menu.select = selected; - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); if (selected == CHR_CNG_SELECT_NPC) { MenuDCMsg[0]->SetBuff(menu.mes_data); } diff --git a/port/src/menu_mouse_monster.cpp b/port/src/menu_mouse_monster.cpp index 65bcf3b95..f2d0af233 100644 --- a/port/src/menu_mouse_monster.cpp +++ b/port/src/menu_mouse_monster.cpp @@ -37,7 +37,7 @@ void BadgeMouse(CMenuMosSelect &box) { if (selected < MOS_SELECT_BADGE_KIND_NUM && box.badge[selected].enable) { box.view_monster = box.badge[selected].monster_id; } - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } void ClassMouse() { @@ -49,7 +49,7 @@ void ClassMouse() { const int selected = MenuMouseRows({rows, static_cast(count)}, BuildUpWeaponInfo.select_no); if (selected != BuildUpWeaponInfo.select_no) { BuildUpWeaponInfo.select_no = selected; - MenuSePlay(SYSTEM_SE_CURSOR); + MenuMouseCursorSound(); } } } // namespace diff --git a/port/src/menu_mouse_shop.cpp b/port/src/menu_mouse_shop.cpp index 9fb0535f3..d9f5d08e6 100644 --- a/port/src/menu_mouse_shop.cpp +++ b/port/src/menu_mouse_shop.cpp @@ -7,6 +7,7 @@ #include "mainloop.hpp" #include "menu_mouse.hpp" #include "menu_mouse_item.hpp" +#include "menucommon.hpp" #include "menumain.hpp" #include "menushop.hpp" #include "menusys.hpp" @@ -45,6 +46,44 @@ ShopList ListHit(const CShopMenu &menu, const MenuMouseSample &mouse, int goods) } return hit; } + +// The cursor of the shop page in front: a goods row, a bag cell or the quantity board's choice. +int ShopCursor(const CShopMenu &menu) { + switch (menu.key_arg_no) { + case SHOP_MENU_MODE_BUY_LIST: + return menu.list_pos; + case SHOP_MENU_MODE_BAG: + return menu.bag_pos; + case SHOP_MENU_MODE_BUY_NUM: + case SHOP_MENU_MODE_SELL_NUM: + return menu.num_cursor; + default: + return -1; + } +} + +// Whether the retail key step moves the cursor of the page in front for a synthesized Up or Down, which +// it then sounds. A direction stops at the ends of the goods list, the bag and the quantity's limits. +bool RetailMoves(const CShopMenu &menu, int direction, int goods) { + const bool up = direction & PAD_UP; + switch (menu.key_arg_no) { + case SHOP_MENU_MODE_BUY_LIST: { + int pos = menu.list_pos, top = menu.list_top; + const int moved = MenuListKeyCheck(up ? MENU_SELECT_KEY_UP : MENU_SELECT_KEY_DOWN, &pos, &top, goods, SHOP_LIST_LINE, 0, 0); + return moved || top != menu.list_top; + } + case SHOP_MENU_MODE_BAG: { + int pos = menu.bag_pos; + CalcMenuAdd2(&pos, up ? -MENU_ITEM_BOARD_COLUMNS : MENU_ITEM_BOARD_COLUMNS, up ? 0 : GetNowBagMax(ITEM_BOARD_MAIN) - 1); + return pos != menu.bag_pos; + } + case SHOP_MENU_MODE_BUY_NUM: + case SHOP_MENU_MODE_SELL_NUM: + return std::min(std::max(menu.num + (up ? 1 : -1), 1), int(menu.num_max)) != menu.num; + default: + return false; + } +} } // namespace void MenuMouseShopUpdate(CShopMenu &menu, CMenuKeyFunc &keys, int goods) { @@ -60,6 +99,7 @@ void MenuMouseShopUpdate(CShopMenu &menu, CMenuKeyFunc &keys, int goods) { return; } const bool choose = mouse.wheel == 0 && (mouse.moved || (mouse.clicked & 1)); + const int pane = menu.key_arg_no, cursor = ShopCursor(menu); int buttons = 0; if (menu.key_arg_no == SHOP_MENU_MODE_BUY_LIST || menu.key_arg_no == SHOP_MENU_MODE_BAG) { using namespace menumouse; @@ -159,6 +199,12 @@ void MenuMouseShopUpdate(CShopMenu &menu, CMenuKeyFunc &keys, int goods) { // Yes/No windows retain the shared drawn-choice navigation. buttons = shared_buttons; } + // The retail key step reads its cursor after this hook, so it cannot sound a move made here. It + // sounds a synthesized Up or Down only if that moves on, not one stopped at a limit. + const int direction = buttons & (PAD_UP | PAD_DOWN); + if ((menu.key_arg_no != pane || ShopCursor(menu) != cursor) && !(direction && RetailMoves(menu, direction, goods))) { + MenuMouseCursorSound(); + } MenuMouseSetButtons(buttons); } diff --git a/port/src/tests/menu_mouse_item_test.cpp b/port/src/tests/menu_mouse_item_test.cpp index 2a9777c8b..f345826ac 100644 --- a/port/src/tests/menu_mouse_item_test.cpp +++ b/port/src/tests/menu_mouse_item_test.cpp @@ -256,6 +256,20 @@ TEST_F(ItemMouse, AllActiveAndEquippedSlotsUseTheirDrawnIconPositions) { EXPECT_EQ(keys.key_arg, &layouts[ITEM_MENU_PANEL_EQUIPMENT]); } +TEST_F(ItemMouse, HoverSoundsOnlyTheMovesTheRetailCursorCheckCannotSee) { + const int sounds = MenuMouseCursorSounds(); + Tick(-196, -1); // Bag cell 0 to active item 0: the same index on another panel. + EXPECT_EQ(class_menu_item_info.key_arg_no, ITEM_MENU_PANEL_ACTIVE_ITEMS); + EXPECT_EQ(keys.cursor, keys.save_cursor); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); + Tick(41, 0); // KeyStepLocal sounds a cursor that differs from the saved one. + EXPECT_EQ(keys.cursor, 1); + EXPECT_NE(keys.cursor, keys.save_cursor); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); + Tick(1, 0); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); +} + TEST_F(ItemMouse, HoverCharacterSetsTheRetailCharacterTarget) { Tick(-116, 150); EXPECT_EQ(class_menu_item_info.key_arg_no, ITEM_MENU_PANEL_CHARA); @@ -644,11 +658,14 @@ TEST_F(ItemMouse, BuildUpListHoverSelectsBeforeClickConfirms) { BuildUpNameXY[i][0] = 220; BuildUpNameXY[i][1] = 100 + 40 * i; } + const int sounds = MenuMouseCursorSounds(); Tick(1, 115); EXPECT_EQ(BuildUpWeaponInfo.select_no, 2); EXPECT_EQ(GamePad__2.GetPadDown(), 0); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); // The retail step reads the cursor after the mouse. Tick(0, 0, 1); EXPECT_EQ(GamePad__2.GetPadDown(), PAD_CROSS); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); BuildUpWeaponInfo = saved; std::copy(&saved_pos[0][0], &saved_pos[0][0] + 6, &BuildUpNameXY[0][0]); } @@ -708,6 +725,30 @@ TEST_F(ItemMouse, RodPointsRejectBackgroundAndAcceptStatHit) { menu.view_form[5] = nullptr; } +TEST_F(ItemMouse, RodPointHoverLeavesTheSoundToTheRetailCursorCheck) { + auto &menu = class_menu_item_info; + menu.mode = MENU_ASK_MODE_ROD_POINTS; + menu.view_mode = MENU_ITEM_VIEW_FISHING_ROD; + CMenuPosDataForm form{}; + MENUFORMPARTS_TYPE stat{}; + form.draw_flag = stat.draw_flag = true; + form.parts = &stat; + form.parts_num = 1; + stat.name = const_cast("index0"); + stat.x = stat.y = 100; + stat.w = 100; + stat.h = 24; + menu.view_form[5] = &form; + Tick(-140, 35); + keys.cursor = 2; // The keys moved the stat cursor, which is select_pos[0]. + const int sounds = MenuMouseCursorSounds(); + Tick(1, 0); + EXPECT_EQ(keys.select_pos[0], 0); + EXPECT_NE(keys.cursor, keys.save_cursor); // KeyStepLocal sounds this move... + EXPECT_EQ(MenuMouseCursorSounds(), sounds); // ...so the mouse does not. + menu.view_form[5] = nullptr; +} + TEST_F(ItemMouse, RodWheelSpendsRefundsAndKeepsRetailPointLimits) { auto &menu = class_menu_item_info; const auto saved_spectol = SpectolInfoStay; diff --git a/port/src/tests/menu_mouse_shop_test.cpp b/port/src/tests/menu_mouse_shop_test.cpp index dbcde7a12..74e0113d7 100644 --- a/port/src/tests/menu_mouse_shop_test.cpp +++ b/port/src/tests/menu_mouse_shop_test.cpp @@ -6,6 +6,7 @@ #include "gamepad.hpp" #include "mainloop.hpp" #include "menu_mouse.hpp" +#include "menucommon.hpp" #include "menumain.hpp" #include "menushop.hpp" #include "mglib.hpp" @@ -120,6 +121,43 @@ class ShopMouse : public testing::Test { keys.SelDataInit(); keys.CheckSelectKey(); } + + // At, then the cursor part of the retail CShopMenu::KeyStep (ps2/src/menushop.cpp:808-855, :870-871, :918-981), + // which reads its cursors after the mouse hook. The whole step needs the shop's messages and textures. + void Step(float x, float y, unsigned buttons = 0, float wheel = 0) { + At(x, y, buttons, wheel); + const int lr = keys.CheckLRKey(); + switch (menu.key_arg_no) { + case SHOP_MENU_MODE_BUY_LIST: { + const int old_pos = menu.list_pos, old_top = menu.list_top; + const int moved = MenuListKeyCheck(lr, &menu.list_pos, &menu.list_top, stock.item_num, SHOP_LIST_LINE, 0, 0); + if (moved || old_pos != menu.list_pos || old_top != menu.list_top) { + MenuSePlay(SYSTEM_SE_CURSOR); + } + break; + } + case SHOP_MENU_MODE_BAG: + MenuItemBrdKey(lr, &menu.bag_pos, &menu.bag_top, ITEM_BOARD_MAIN); // Retail's own sound. + break; + case SHOP_MENU_MODE_BUY_NUM: + case SHOP_MENU_MODE_SELL_NUM: { + const int old_cursor = menu.num_cursor, old_num = menu.num; + if (menu.num_cursor == 0) { + menu.num += (lr & MENU_SELECT_KEY_UP ? 1 : 0) - (lr & MENU_SELECT_KEY_DOWN ? 1 : 0); + } + if (menu.num <= 0) { + menu.num = 1; + } + if (menu.num > menu.num_max) { + menu.num = menu.num_max; + } + if (old_cursor != menu.num_cursor || old_num != menu.num) { + MenuSePlay(SYSTEM_SE_CURSOR); + } + break; + } + } + } }; TEST_F(ShopMouse, HoverSelectsBothPanesBeforeClickConfirms) { @@ -253,6 +291,102 @@ TEST_F(ShopMouse, ClickWithAWheelNotchConfirmsTheRowItWasPressedOn) { EXPECT_EQ(MenuMouseButtons(), PAD_CROSS); } +TEST_F(ShopMouse, CursorMovesSoundOnceAndSynthesizedKeysLeaveTheSoundToRetail) { + const int sounds = MenuMouseCursorSounds(); + At(60, 150); + EXPECT_EQ(menu.list_pos, 1); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); + At(61, 150); // The same row. + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); + At(320, 170); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BAG); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 2); + At(320, 170); // Holding still. + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 2); + At(320, 170, 0, -1); + EXPECT_EQ(menu.bag_pos, 19); // A scroll that brings another cell under the pointer. + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 3); + // A notch over a list that fits presses Down, and the retail key step sounds the move. + stock.item_num = 4; + At(60, 150, 0, -1); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); + EXPECT_EQ(keys.CheckLRKey(), MENU_SELECT_KEY_DOWN); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 3); + menu.key_arg_no = SHOP_MENU_MODE_BUY_NUM; + At(250, 210); + EXPECT_EQ(menu.num_cursor, 1); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 4); +} + +TEST_F(ShopMouse, WheelIntoAShortGoodsListSoundsOncePerChangeEvenAtItsEnds) { + stock.item_num = 4; + Step(60, 100); // Onto the list's first row. + ASSERT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); + ASSERT_EQ(menu.list_pos, 0); + // Back to the bag with the keys; Up then switches to the list but stops at its first entry. + menu.key_arg_no = SHOP_MENU_MODE_BAG; + int sounds = MenuCursorSounds(); + Step(60, 100, 0, 1); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); + EXPECT_EQ(menu.list_pos, 0); + EXPECT_EQ(MenuCursorSounds(), sounds + 1); + // Down switches and moves: retail's one sound. + menu.key_arg_no = SHOP_MENU_MODE_BAG; + Step(60, 100, 0, -1); + EXPECT_EQ(menu.list_pos, 1); + EXPECT_EQ(MenuCursorSounds(), sounds + 2); + Step(60, 100, 0, -1); + Step(60, 100, 0, -1); + EXPECT_EQ(menu.list_pos, 3); + EXPECT_EQ(MenuCursorSounds(), sounds + 4); + // At the last entry nothing changes and nothing sounds. + Step(60, 100, 0, -1); + EXPECT_EQ(menu.list_pos, 3); + EXPECT_EQ(MenuCursorSounds(), sounds + 4); + sounds = MenuCursorSounds(); + // A scroll pane switch at the long list's end sounds once too. + stock.item_num = 20; + menu.key_arg_no = SHOP_MENU_MODE_BAG; + menu.list_pos = menu.list_top = 0; + Step(60, 100, 0, 1); + EXPECT_EQ(menu.key_arg_no, SHOP_MENU_MODE_BUY_LIST); + EXPECT_EQ(menu.list_top, 0); + EXPECT_EQ(MenuCursorSounds(), sounds + 1); +} + +TEST_F(ShopMouse, QuantityWheelFromQuitSoundsOnceEvenAtEachLimit) { + for (int mode : {SHOP_MENU_MODE_BUY_NUM, SHOP_MENU_MODE_SELL_NUM}) { + menu.key_arg_no = mode; + menu.num_max = 10; + int sounds = MenuCursorSounds(); + // Quit to the number, whose quantity retail clamps back: the cursor change still sounds, once. + menu.num = 1; + menu.num_cursor = 1; + Step(160, 210, 0, -1); + EXPECT_EQ(menu.num_cursor, 0); + EXPECT_EQ(menu.num, 1); + EXPECT_EQ(MenuCursorSounds(), sounds + 1); + menu.num = 10; + menu.num_cursor = 1; + Step(160, 210, 0, 1); + EXPECT_EQ(menu.num, 10); + EXPECT_EQ(MenuCursorSounds(), sounds + 2); + // Already on the number at a limit: nothing changes. + Step(160, 210, 0, 1); + EXPECT_EQ(MenuCursorSounds(), sounds + 2); + // A quantity that moves as the cursor leaves Quit: retail's one sound. + menu.num = 5; + menu.num_cursor = 1; + Step(160, 210, 0, 1); + EXPECT_EQ(menu.num_cursor, 0); + EXPECT_EQ(menu.num, 6); + EXPECT_EQ(MenuCursorSounds(), sounds + 3); + Step(160, 210, 0, -1); + EXPECT_EQ(menu.num, 5); + EXPECT_EQ(MenuCursorSounds(), sounds + 4); + } +} + TEST_F(ShopMouse, QuantityWheelUsesRetailDirectionsAtBothLimitsInBuyAndSell) { for (int mode : {SHOP_MENU_MODE_BUY_NUM, SHOP_MENU_MODE_SELL_NUM}) { menu.key_arg_no = mode; diff --git a/port/src/tests/menu_mouse_test.cpp b/port/src/tests/menu_mouse_test.cpp index 77d21a7ff..a26b58059 100644 --- a/port/src/tests/menu_mouse_test.cpp +++ b/port/src/tests/menu_mouse_test.cpp @@ -48,6 +48,20 @@ TEST(MenuMouse, StationaryPointerDoesNotUndoControllerSelection) { EXPECT_EQ(MenuMouseChoose(mouse, rows, 2), 2); } +TEST(MenuMouse, CursorSoundPlaysAtMostOnceATick) { + const u32 saved_sound = SystemSND_ID; + SystemSND_ID = static_cast(-1); + mgEndFrame(nullptr); + const int sounds = MenuMouseCursorSounds(); + MenuMouseCursorSound(); + MenuMouseCursorSound(); // A second adapter in the same tick. + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 1); + mgEndFrame(nullptr); + MenuMouseCursorSound(); + EXPECT_EQ(MenuMouseCursorSounds(), sounds + 2); + SystemSND_ID = saved_sound; +} + TEST(MenuMouse, ClickSelectsTheHoveredRowAndWheelClamps) { MenuMouseSample mouse{.x = 40, .y = 90, .pointing = true, .clicked = 1}; EXPECT_EQ(MenuMouseChoose(mouse, rows, 0), 1);