CS2-Kit
C++23 library for CS2 Metamod:Source plugin development
Loading...
Searching...
No Matches
MenuManager.cpp
Go to the documentation of this file.
2
3#include <CS2Kit/Menu/MenuManager.hpp>
4#include <CS2Kit/Menu/MenuOption.hpp>
5#include <CS2Kit/Sdk/ChatInputCapture.hpp>
6#include <CS2Kit/Sdk/Entity.hpp>
7#include <CS2Kit/Sdk/UserMessage.hpp>
8#include <CS2Kit/Utils/Log.hpp>
9#include <algorithm>
10#include <chrono>
11
12namespace CS2Kit::Menu
13{
14
15using namespace CS2Kit::Utils;
16using namespace CS2Kit::Sdk;
17
18static int64_t GetCurrentTimeMs()
19{
20 return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch())
21 .count();
22}
23
24namespace
25{
26
27bool IsCursorTarget(const std::shared_ptr<MenuOption>& opt)
28{
29 return opt && opt->IsEnabled() && opt->IsSelectable();
30}
31
32// Step the cursor by `step` (typically ±1), wrapping over the full item list and skipping
33// disabled or non-selectable rows (Text, ProgressBar).
34void StepCursor(const std::vector<std::shared_ptr<MenuOption>>& items, int& idx, int step)
35{
36 int n = static_cast<int>(items.size());
37 if (n == 0)
38 return;
39
40 int attempts = n;
41 do
42 {
43 idx = ((idx + step) % n + n) % n;
44 }
45 while (!IsCursorTarget(items[idx]) && --attempts > 0);
46}
47
48// Jump by `pageDelta` pages, preserving the in-page offset, then skip forward over disabled
49// or non-selectable rows within the new page.
50void JumpPage(const std::vector<std::shared_ptr<MenuOption>>& items, int& idx, int pageDelta)
51{
52 int n = static_cast<int>(items.size());
53 if (n == 0)
54 return;
55
56 int pageCount = (n + ItemsPerPage - 1) / ItemsPerPage;
57 int currentPage = idx / ItemsPerPage;
58 int offset = idx % ItemsPerPage;
59 int newPage = ((currentPage + pageDelta) % pageCount + pageCount) % pageCount;
60
61 int pageStart = newPage * ItemsPerPage;
62 int pageEnd = std::min(n, pageStart + ItemsPerPage);
63
64 idx = std::min(pageStart + offset, pageEnd - 1);
65 int attempts = pageEnd - pageStart;
66 while (!IsCursorTarget(items[idx]) && --attempts > 0)
67 {
68 idx = (idx + 1 < pageEnd) ? idx + 1 : pageStart;
69 }
70}
71
72} // namespace
73
74void MenuManager::OpenMenu(int slot, std::shared_ptr<Menu> menu)
75{
76 if (slot < 0 || slot >= 64 || !menu)
77 return;
78
79 auto& state = _states[slot];
80 state.MenuStack.push(std::move(menu));
81 state.SelectedIndex = 0;
82 state.LastInputTime = GetCurrentTimeMs();
83
84 auto* current = state.GetCurrentMenu();
85 if (current)
86 {
87 // Move cursor onto the first selectable row so disabled/Text/ProgressBar entries
88 // are not greeted as the initial selection.
89 if (!current->Items.empty() && !IsCursorTarget(current->Items[0]))
90 StepCursor(current->Items, state.SelectedIndex, +1);
91
92 Log::Info("Menu opened for slot {} (title: {}, items: {})", slot, current->Title, current->Items.size());
93 }
94}
95
96void MenuManager::CloseMenu(int slot)
97{
98 if (slot < 0 || slot >= 64)
99 return;
100
101 auto& state = _states[slot];
102 if (state.MenuStack.empty())
103 return;
104
105 auto menu = state.MenuStack.top();
106 state.MenuStack.pop();
107
108 if (menu->OnClose)
109 menu->OnClose(slot);
110
111 if (state.MenuStack.empty())
112 {
113 MessageSystem::Instance().ClearCenterHtml(slot);
114 state.Reset();
115 }
116 else
117 {
118 state.SelectedIndex = 0;
119 if (auto* parent = state.GetCurrentMenu(); parent && !parent->Items.empty() &&
120 !IsCursorTarget(parent->Items[0]))
121 {
122 StepCursor(parent->Items, state.SelectedIndex, +1);
123 }
124 }
125}
126
127void MenuManager::CloseAllMenus(int slot)
128{
129 if (slot < 0 || slot >= 64)
130 return;
131
132 auto& state = _states[slot];
133 state.Reset();
134 MessageSystem::Instance().ClearCenterHtml(slot);
135}
136
137bool MenuManager::HasActiveMenu(int slot) const
138{
139 if (slot < 0 || slot >= 64)
140 return false;
141
142 return _states[slot].HasMenu();
143}
144
145void MenuManager::OnGameFrame()
146{
147 for (int slot = 0; slot < 64; ++slot)
148 {
149 auto& state = _states[slot];
150 if (!state.HasMenu())
151 continue;
152
153 uint64_t buttons = EntitySystem::Instance().GetPlayerButtons(slot);
154 auto prev = state.PrevButtons;
155 state.PrevButtons = buttons;
156
157 HandleInput(slot, buttons, prev);
158 RenderMenu(slot);
159 }
160}
161
162void MenuManager::HandleInput(int slot, uint64_t buttons, uint64_t prevButtons)
163{
164 auto& state = _states[slot];
165 auto* menu = state.GetCurrentMenu();
166 if (!menu)
167 return;
168
169 uint64_t pressed = buttons & ~prevButtons;
170 if (pressed == 0)
171 return;
172
173 auto now = GetCurrentTimeMs();
174 if (now - state.LastInputTime < InputDebounceMs)
175 return;
176
177 // While a chat-input capture is active, the only key we honor is R (cancel) — every
178 // other input is ignored so the menu doesn't drift while the player types in chat.
179 auto& capture = ChatInputCapture::Instance();
180 if (capture.IsCapturing(slot))
181 {
182 if (pressed & IN_RELOAD)
183 {
184 capture.CancelCapture(slot);
185 state.LastInputTime = now;
186 }
187 return;
188 }
189
190 int itemCount = static_cast<int>(menu->Items.size());
191 if (itemCount == 0)
192 return;
193
194 bool isPaginated = itemCount > ItemsPerPage;
195 bool inputHandled = true;
196
197 auto& currentOption = menu->Items[state.SelectedIndex];
198
199 if (pressed & IN_FORWARD)
200 StepCursor(menu->Items, state.SelectedIndex, -1);
201 else if (pressed & IN_BACK)
202 StepCursor(menu->Items, state.SelectedIndex, +1);
203 else if (pressed & IN_MOVELEFT)
204 {
205 bool consumed = currentOption && currentOption->IsEnabled() && currentOption->OnHorizontal(slot, -1);
206 if (!consumed && isPaginated)
207 JumpPage(menu->Items, state.SelectedIndex, -1);
208 else if (!consumed)
209 inputHandled = false;
210 }
211 else if (pressed & IN_MOVERIGHT)
212 {
213 bool consumed = currentOption && currentOption->IsEnabled() && currentOption->OnHorizontal(slot, +1);
214 if (!consumed && isPaginated)
215 JumpPage(menu->Items, state.SelectedIndex, +1);
216 else if (!consumed)
217 inputHandled = false;
218 }
219 else if (pressed & IN_USE)
220 {
221 if (currentOption && currentOption->IsEnabled() && currentOption->IsSelectable())
222 currentOption->OnActivate(slot);
223 }
224 else if (pressed & IN_RELOAD)
225 CloseMenu(slot);
226 else
227 inputHandled = false;
228
229 if (inputHandled)
230 state.LastInputTime = now;
231}
232
233void MenuManager::RenderMenu(int slot)
234{
235 auto& state = _states[slot];
236 auto* menu = state.GetCurrentMenu();
237 if (!menu)
238 return;
239
240 // While a capture is pending, render a prompt overlay instead of the item list.
241 if (auto* prompt = ChatInputCapture::Instance().GetPrompt(slot); prompt != nullptr)
242 {
243 auto html = RenderCaptureOverlay(menu->Title, *prompt);
244 MessageSystem::Instance().SendCenterHtml(slot, html);
245 return;
246 }
247
248 bool isSubmenu = state.MenuStack.size() > 1;
249 auto html = RenderMenuHtml(menu, slot, state.SelectedIndex, isSubmenu);
250 MessageSystem::Instance().SendCenterHtml(slot, html);
251}
252
253void MenuManager::OnPlayerDisconnect(int slot)
254{
255 if (slot < 0 || slot >= 64)
256 return;
257
258 _states[slot].Reset();
259}
260
261} // namespace CS2Kit::Menu
std::string RenderMenuHtml(const Menu *menu, int slot, int selectedIndex, bool isSubmenu)
static int64_t GetCurrentTimeMs()
std::string RenderCaptureOverlay(const std::string &menuTitle, std::string_view prompt)