From 377664fdaa6daaf6468bd873cda499ce17a15486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20M=C3=BCller?= Date: Sat, 13 Jul 2024 22:25:34 +0200 Subject: [PATCH] Add ingame touch controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new client component `CTouchControls` for playing the game with touch inputs. The touch controls can be enabled/disabled with the config variable `cl_touch_controls`, which defaults to `1` on Android and `0` on other platforms. The touch controls consist of various on-screen touch buttons. The different buttons are only shown when they are usable depending on the context. The touch button configuration is stored in `touch_controls.json`. Closes #8359. Default touch button configuration ---------------------------------- Movement buttons for left, right and jump actions are arranged in a `⊥`-pattern similar to WASD controls. For the fire and hook action, two modes are implemented: 1. Direct touch input: the mouse is moved exactly where the player touches on the screen. 2. Virtual joystick: a button is used to emulate a joystick, which moves the mouse relative to the center of the screen. In either mode, a button is used to switch between the active actions (fire and hook). While the virtual joystick is being held down, this button uses the other action directly instead of switching it. The direct touch input mode can be adjusted separately for ingame/spectating, to prevent accidental direct touch input when using a joystick. When spectating, direct touch input is used to allow panning the map directly like in an image/map viewer. Virtual joysticks can also be used to pan the map while spectating. Two separate buttons are shown to switch to the previous and next weapons. A hamburger menu button `☰` is used to toggle the visibility of lesser used touch buttons. This includes buttons for showing the scoreboard, showing the emoticon selector, showing the spectator menu, opening team and team chat, voting yes/no, and zooming. Long pressing the hamburger menu button will open the ingame menu. When the dummy is connected, a button for swapping between main and dummy is shown. The emoticon selector and spectator menu are activated with the respective touch buttons and can be deactivated by touching outside of them or by using the back-button, as toggling them while the ingame touch button is pressed down is currently not feasible and also inconvenient at least for using the spectator menu. Touch button configuration format --------------------------------- The default button layout described above is loaded from `data/touch_controls.json`. This layout can be overridden by creating `touch_controls.json` in the user directory. The attribute `"touch-buttons"` of the root object in this file specifies an array of touch button objects. Each touch button object has the following adjustable properties: - Position and size (attributes `x`, `y`, `w`, `h`): the X/Y position and width/height are integers on a 1,000,000² grid. The unit grid values are converted to screen grid values at runtime in relation to the aspect ratio of the screen. This means buttons may appear stretched if the resolution is changed, but it allows us to provide a reasonable default for slightly different aspect ratios. If we save the absolute position instead, we would have the problem of buttons appearing outside the screen or not being aligned on different aspect ratios. - Shape (attribute `shape`): determines the shape of the button being rendered. - `"rect"`: rectangle shape. - `"circle"`: circle shape. The button size will automatically be adjusted so that width and height are identical. - Visibility (attribute `visibilities`): an array of predefined visibility classes can be selected and the button is only shown if all conditions are satisfied. An empty array means that the button is always shown. The following visibility classes are defined: - `"ingame"`: player is ingame, i.e. not spectating. - `"extra-menu"`, `"extra-menu-2"`, ..., `"extra-menu-5"`: the extra menu with the given number is activated. - `"zoom-allowed"`: zoom is allowed on this server. - `"vote-active"`: a vote is currently active. - `"dummy-allowed"`: dummy is allowed on this server. - `"dummy-connected"`: dummy is currently connected. - `"rcon-authed"`: player is currently authed in rcon. - `"demo-player"`: demo player is currently active. - All visibility classes can be inverted by prefixing them with `-`, e.g. `"-ingame"` is satisfied when the player is not ingame, i.e. spectating. - Behavior (attribute `behavior`): an object which describes the behavior of this touch button when it is activated/deactivated as well as its label. The attribute `type` is used to differentiate which type of behavior is used. The behavior is either predefined (hard-coded) or based on generic console commands (like bind). Predefined behavior is only used where necessary, all other buttons are represented as generic binds. - Predefined behavior (attribute `type` set to `"predefined"`): The attribute `id` is set to a fixed string value which determines the specific predefined behavior. The following predefined behaviors exist: - `"ingame-menu"`: Opens the ingame menu immediately when released. - `"extra-menu"`: The extra menu button which toggles visibility of buttons with `"extra-menu"`, `"extra-menu-2"`, `"extra-menu-3"` etc. visibilities. Also opens the ingame menu on long press. - The attribute `"number"` specifies an integer between 1 and 5 to associate this button with the respective visibilities `"extra-menu"`, `"extra-menu-2"`, ..., `"extra-menu-5"`. - `"emoticon"`: Opens the emoticon selector (this does not work with binds). - `"spectate"`: Opens the spectator menu (this does not work with binds). - `"swap-action"`: Swaps the active action (fire and hook) for direct touch input and virtual joysticks. - `"use-action"`: Uses the active action with the current aiming position. - `"joystick-action"`: Virtual joystick which uses the active action. - `"joystick-aim"`: Virtual joystick which only aims without using an action. - `"joystick-fire"`: Virtual joystick which always uses fire. - `"joystick-hook"`: Virtual joystick which always uses hook. - Bind behavior (attribute `type` set to `"bind"`). Buttons with this behavior work like regular key binds. - The attribute `"label"` specifies as a string the label of the button. - The attribute `"label-type"` specifies as a string the type of the label of the button, i.e. how the attribute `"label"` is interpreted: - `"plain"`: Label is used as is. - `"localized"`: Label is localized. Only usable for the default buttons for which there are translations available. - `"icon"`: Icon font is used for the label. Icons must be encoded in UTF-16 using `\uXXXX`, e.g. `\uf3ce` for the mobile phone icon. - The attribute `"command"` specifies as a string the command to execute in the console like a bind when this button is used. - Bind toggle behavior (attribute `type` set to `"bind-toggle"`). Buttons with this behavior cycle between executing one of two or more specified commands. - The attribute `"commands"` specifies an array of two or more command in the order in which they are shown and executed. Each command is an object with the attributes `"label"`, `"label-type"` and `"command"` which are defined the same as for the bind behavior described above. At least two commands must be specified. The root object additionally has the following attributes: - `"direct-touch-ingame"`: specifies the mode of direct touch input while ingame. Possible values: - `"disabled"`: Direct touch input is not used while ingame. This means a virtual joystick is necessary. - `"action"`: Direct touch input uses the active action (see above). - `"aim"`: Direct touch input only changes the aiming position without using an action. This means separate buttons for using the actions are necessary. - `"fire"`: Direct touch input always uses fire. - `"hook"`: Direct touch input always uses hook. - `"direct-touch-spectate"`: specifies the mode of direct touch input while ingame. Possible values: - `"disabled"`: Direct touch input is not used while spectating. This means a virtual joystick is necessary. - `"aim"`: Direct touch input is used for spectating. Ingame menu buttons ------------------- In addition to the separate on-screen touch controls, a second row is added to the main page of the ingame menu when `cl_touch_controls` is enabled for less frequently used functions which are otherwise not usable without a keyboard: - Buttons to open the local and remote consoles. Opening the local console without the touch controls is useful because error messages would be shown there if the touch controls configuration could not be loaded. - Button to close the menu, which is more convenient than using the virtual back button if it's not always shown. - Checkbox for toggling the touch controls editor UI (see below). Ingame touch controls editor ---------------------------- The user interface to adjust the touch controls is rendered over the main screen of the ingame menu when enabled. For now, the implementation of the touch controls editor is limited to basic configuration management functions. - Saving the configuration to the `touch_controls.json` file in the config directory. - Discarding the current changes by reloading the `touch_controls.json` file from the config directory. - Restoring the default configuration by reloading the `touch_controls.json` file from the data directory. - Displaying whether there are unsaved changes. - Importing and exporting the configuration from and to the clipboard. This is the only way to edit the configuration on newer Android versions, as editing files within apps' storage is not possible anymore. Furthermore, the global touch controls settings can be adjusted in this UI: - The direct touch input modes while ingame and spectating (see Touch button configuration format) can be adjusted using dropdown menus. While the touch controls editor is active, all touch buttons are shown regardless of their visibilities, to better support arranging the buttons. Future tasks and ideas ---------------------- - Implement more usable UI for adjusting the button layout. - Add `color` property to touch buttons to adjust the default background color? - Add `choice` predefined behavior which shows a selection popup for 2 or more other behaviors? - Add combined weapon picker button that shows all currently available weapons. - Support zooming gesture while spectating. - Optimize touch button rendering using text and quad containers. --- CMakeLists.txt | 3 + data/touch_controls.json | 311 ++++ src/engine/shared/config_variables.h | 5 + src/game/client/components/controls.h | 2 +- src/game/client/components/menus.h | 7 +- src/game/client/components/menus_ingame.cpp | 245 ++- src/game/client/components/touch_controls.cpp | 1568 +++++++++++++++++ src/game/client/components/touch_controls.h | 548 ++++++ src/game/client/gameclient.cpp | 2 + src/game/client/gameclient.h | 2 + 10 files changed, 2679 insertions(+), 14 deletions(-) create mode 100644 data/touch_controls.json create mode 100644 src/game/client/components/touch_controls.cpp create mode 100644 src/game/client/components/touch_controls.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ba403347f62..1ff72da59a5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1846,6 +1846,7 @@ set(EXPECTED_DATA themes/winter.png themes/winter_day.map themes/winter_night.map + touch_controls.json wordlist.txt ) @@ -2402,6 +2403,8 @@ if(CLIENT) components/statboard.h components/tooltips.cpp components/tooltips.h + components/touch_controls.cpp + components/touch_controls.h components/voting.cpp components/voting.h gameclient.cpp diff --git a/data/touch_controls.json b/data/touch_controls.json new file mode 100644 index 00000000000..5d4aa431b6c --- /dev/null +++ b/data/touch_controls.json @@ -0,0 +1,311 @@ +{ + "direct-touch-ingame": "action", + "direct-touch-spectate": "aim", + "touch-buttons": [ + { + "x": 0, + "y": 833333, + "w": 200000, + "h": 166667, + "shape": "rect", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "bind", + "label": "Move left", + "label-type": "localized", + "command": "+left" + } + }, + { + "x": 200000, + "y": 833333, + "w": 200000, + "h": 166667, + "shape": "rect", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "bind", + "label": "Move right", + "label-type": "localized", + "command": "+right" + } + }, + { + "x": 100000, + "y": 666667, + "w": 200000, + "h": 166667, + "shape": "rect", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "bind", + "label": "Jump", + "label-type": "localized", + "command": "+jump" + } + }, + { + "x": 116667, + "y": 16667, + "w": 83333, + "h": 83333, + "shape": "rect", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "bind", + "label": "Prev. weapon", + "label-type": "localized", + "command": "+prevweapon" + } + }, + { + "x": 200000, + "y": 16667, + "w": 83333, + "h": 83333, + "shape": "rect", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "bind", + "label": "Next weapon", + "label-type": "localized", + "command": "+nextweapon" + } + }, + { + "x": 16667, + "y": 16667, + "w": 83333, + "h": 83333, + "shape": "rect", + "visibilities": [ + ], + "behavior": { + "type": "predefined", + "id": "extra-menu", + "number": 1 + } + }, + { + "x": 300000, + "y": 16667, + "w": 83333, + "h": 83333, + "shape": "rect", + "visibilities": [ + "extra-menu", + "zoom-allowed" + ], + "behavior": { + "type": "bind", + "label": "Zoom out", + "label-type": "localized", + "command": "zoom-" + } + }, + { + "x": 383333, + "y": 16667, + "w": 83333, + "h": 83333, + "shape": "rect", + "visibilities": [ + "extra-menu", + "zoom-allowed" + ], + "behavior": { + "type": "bind", + "label": "Default zoom", + "label-type": "localized", + "command": "zoom" + } + }, + { + "x": 466666, + "y": 16667, + "w": 83333, + "h": 83333, + "shape": "rect", + "visibilities": [ + "extra-menu", + "zoom-allowed" + ], + "behavior": { + "type": "bind", + "label": "Zoom in", + "label-type": "localized", + "command": "zoom+" + } + }, + { + "x": 16667, + "y": 133333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "extra-menu" + ], + "behavior": { + "type": "bind", + "label": "Scoreboard", + "label-type": "localized", + "command": "+scoreboard" + } + }, + { + "x": 116667, + "y": 133333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "ingame", + "extra-menu" + ], + "behavior": { + "type": "predefined", + "id": "emoticon" + } + }, + { + "x": 116667, + "y": 133333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "-ingame", + "extra-menu" + ], + "behavior": { + "type": "predefined", + "id": "spectate" + } + }, + { + "x": 216667, + "y": 133333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "extra-menu", + "-demo-player" + ], + "behavior": { + "type": "bind", + "label": "Chat", + "label-type": "localized", + "command": "chat all" + } + }, + { + "x": 316667, + "y": 133333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "extra-menu", + "-demo-player" + ], + "behavior": { + "type": "bind", + "label": "Team chat", + "label-type": "localized", + "command": "chat team" + } + }, + { + "x": 16667, + "y": 333333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "extra-menu", + "vote-active", + "-demo-player" + ], + "behavior": { + "type": "bind", + "label": "Vote yes", + "label-type": "localized", + "command": "vote yes" + } + }, + { + "x": 116667, + "y": 333333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "extra-menu", + "vote-active", + "-demo-player" + ], + "behavior": { + "type": "bind", + "label": "Vote no", + "label-type": "localized", + "command": "vote no" + } + }, + { + "x": 766667, + "y": 16667, + "w": 100000, + "h": 100000, + "shape": "rect", + "visibilities": [ + "dummy-connected" + ], + "behavior": { + "type": "bind", + "label": "Toggle dummy", + "label-type": "localized", + "command": "toggle cl_dummy 0 1" + } + }, + { + "x": 883333, + "y": 16667, + "w": 100000, + "h": 100000, + "shape": "rect", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "predefined", + "id": "swap-action" + } + }, + { + "x": 755000, + "y": 580000, + "w": 225000, + "h": 400000, + "shape": "circle", + "visibilities": [ + "ingame" + ], + "behavior": { + "type": "predefined", + "id": "joystick-action" + } + } + ] +} diff --git a/src/engine/shared/config_variables.h b/src/engine/shared/config_variables.h index 73bf017ca92..d676b4e8a82 100644 --- a/src/engine/shared/config_variables.h +++ b/src/engine/shared/config_variables.h @@ -22,6 +22,11 @@ MACRO_CONFIG_INT(ClAntiPingSmooth, cl_antiping_smooth, 0, 0, 1, CFGFLAG_CLIENT | MACRO_CONFIG_INT(ClAntiPingGunfire, cl_antiping_gunfire, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Predict gunfire and show predicted weapon physics (with cl_antiping_grenade 1 and cl_antiping_weapons 1)") MACRO_CONFIG_INT(ClPredictionMargin, cl_prediction_margin, 10, 1, 300, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Prediction margin in ms (adds latency, can reduce lag from ping jumps)") MACRO_CONFIG_INT(ClSubTickAiming, cl_sub_tick_aiming, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Send aiming data at sub-tick accuracy") +#if defined(CONF_PLATFORM_ANDROID) +MACRO_CONFIG_INT(ClTouchControls, cl_touch_controls, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Enable ingame touch controls") +#else +MACRO_CONFIG_INT(ClTouchControls, cl_touch_controls, 0, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Enable ingame touch controls") +#endif MACRO_CONFIG_INT(ClNameplates, cl_nameplates, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Show name plates") MACRO_CONFIG_INT(ClAfkEmote, cl_afk_emote, 1, 0, 1, CFGFLAG_CLIENT | CFGFLAG_SAVE, "Show zzz emote next to afk players") diff --git a/src/game/client/components/controls.h b/src/game/client/components/controls.h index ce6bc664dd1..11eecdde03d 100644 --- a/src/game/client/components/controls.h +++ b/src/game/client/components/controls.h @@ -12,10 +12,10 @@ class CControls : public CComponent { +public: float GetMinMouseDistance() const; float GetMaxMouseDistance() const; -public: vec2 m_aMousePos[NUM_DUMMIES]; vec2 m_aMousePosOnAction[NUM_DUMMIES]; vec2 m_aTargetPos[NUM_DUMMIES]; diff --git a/src/game/client/components/menus.h b/src/game/client/components/menus.h index f8e4afccdc8..1afda415090 100644 --- a/src/game/client/components/menus.h +++ b/src/game/client/components/menus.h @@ -479,8 +479,12 @@ class CMenus : public CComponent // found in menus_ingame.cpp STextContainerIndex m_MotdTextContainerIndex; void RenderGame(CUIRect MainView); + void RenderTouchControlsEditor(CUIRect MainView); void PopupConfirmDisconnect(); void PopupConfirmDisconnectDummy(); + void PopupConfirmDiscardTouchControlsChanges(); + void PopupConfirmResetTouchControls(); + void PopupConfirmImportTouchControlsClipboard(); void RenderPlayers(CUIRect MainView); void RenderServerInfo(CUIRect MainView); void RenderServerInfoMotd(CUIRect Motd); @@ -641,7 +645,6 @@ class CMenus : public CComponent static CUi::EPopupMenuFunctionResult PopupMapPicker(void *pContext, CUIRect View, bool Active); void SetNeedSendInfo(); - void SetActive(bool Active); void UpdateColors(); IGraphics::CTextureHandle m_TextureBlob; @@ -661,6 +664,8 @@ class CMenus : public CComponent bool IsInit() { return m_IsInit; } bool IsActive() const { return m_MenuActive; } + void SetActive(bool Active); + void KillServer(); virtual void OnInit() override; diff --git a/src/game/client/components/menus_ingame.cpp b/src/game/client/components/menus_ingame.cpp index fdc8459e347..b68361a6429 100644 --- a/src/game/client/components/menus_ingame.cpp +++ b/src/game/client/components/menus_ingame.cpp @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -40,21 +41,19 @@ using namespace std::chrono_literals; void CMenus::RenderGame(CUIRect MainView) { - CUIRect Button, ButtonBar, ButtonBar2; + CUIRect Button, ButtonBars, ButtonBar, ButtonBar2; bool ShowDDRaceButtons = MainView.w > 855.0f; - MainView.HSplitTop(45.0f, &ButtonBar, &MainView); - ButtonBar.Draw(ms_ColorTabbarActive, IGraphics::CORNER_B, 10.0f); - - // button bar - ButtonBar.HSplitTop(10.0f, 0, &ButtonBar); - ButtonBar.HSplitTop(25.0f, &ButtonBar, 0); - ButtonBar.VMargin(10.0f, &ButtonBar); - - ButtonBar.HSplitTop(30.0f, 0, &ButtonBar2); - ButtonBar2.HSplitTop(25.0f, &ButtonBar2, 0); + MainView.HSplitTop(45.0f + (g_Config.m_ClTouchControls ? 35.0f : 0.0f), &ButtonBars, &MainView); + ButtonBars.Draw(ms_ColorTabbarActive, IGraphics::CORNER_B, 10.0f); + ButtonBars.Margin(10.0f, &ButtonBars); + ButtonBars.HSplitTop(25.0f, &ButtonBar, &ButtonBars); + if(g_Config.m_ClTouchControls) + { + ButtonBars.HSplitTop(10.0f, nullptr, &ButtonBars); + ButtonBars.HSplitTop(25.0f, &ButtonBar2, &ButtonBars); + } ButtonBar.VSplitRight(120.0f, &ButtonBar, &Button); - static CButtonContainer s_DisconnectButton; if(DoButton_Menu(&s_DisconnectButton, Localize("Disconnect"), 0, &Button)) { @@ -214,6 +213,173 @@ void CMenus::RenderGame(CUIRect MainView) } } } + + if(g_Config.m_ClTouchControls) + { + ButtonBar2.VSplitLeft(200.0f, &Button, &ButtonBar2); + static char s_TouchControlsEditCheckbox; + if(DoButton_CheckBox(&s_TouchControlsEditCheckbox, Localize("Edit touch controls"), GameClient()->m_TouchControls.IsEditingActive(), &Button)) + { + GameClient()->m_TouchControls.SetEditingActive(!GameClient()->m_TouchControls.IsEditingActive()); + } + + ButtonBar2.VSplitRight(80.0f, &ButtonBar2, &Button); + static CButtonContainer s_CloseButton; + if(DoButton_Menu(&s_CloseButton, Localize("Close"), 0, &Button)) + { + SetActive(false); + } + + ButtonBar2.VSplitRight(5.0f, &ButtonBar2, nullptr); + ButtonBar2.VSplitRight(160.0f, &ButtonBar2, &Button); + static CButtonContainer s_RemoveConsoleButton; + if(DoButton_Menu(&s_RemoveConsoleButton, Localize("Remote console"), 0, &Button)) + { + Console()->ExecuteLine("toggle_remote_console"); + } + + ButtonBar2.VSplitRight(5.0f, &ButtonBar2, nullptr); + ButtonBar2.VSplitRight(120.0f, &ButtonBar2, &Button); + static CButtonContainer s_LocalConsoleButton; + if(DoButton_Menu(&s_LocalConsoleButton, Localize("Console"), 0, &Button)) + { + Console()->ExecuteLine("toggle_local_console"); + } + + if(GameClient()->m_TouchControls.IsEditingActive()) + { + CUIRect TouchControlsEditor; + MainView.VMargin((MainView.w - 505.0f) / 2.0f, &TouchControlsEditor); + TouchControlsEditor.HMargin((TouchControlsEditor.h - 230.0f) / 2.0f, &TouchControlsEditor); + RenderTouchControlsEditor(TouchControlsEditor); + } + } +} + +void CMenus::RenderTouchControlsEditor(CUIRect MainView) +{ + CUIRect Label, Button, Row; + MainView.Draw(ms_ColorTabbarActive, IGraphics::CORNER_ALL, 10.0f); + MainView.Margin(10.0f, &MainView); + + MainView.HSplitTop(25.0f, &Label, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + Ui()->DoLabel(&Label, Localize("Edit touch controls"), 20.0f, TEXTALIGN_MC); + + MainView.HSplitTop(25.0f, &Row, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + + Row.VSplitLeft(240.0f, &Button, &Row); + static CButtonContainer s_SaveConfigurationButton; + if(DoButton_Menu(&s_SaveConfigurationButton, Localize("Save changes"), GameClient()->m_TouchControls.HasEditingChanges() ? 0 : 1, &Button)) + { + if(GameClient()->m_TouchControls.SaveConfigurationToFile()) + { + GameClient()->m_TouchControls.SetEditingChanges(false); + } + else + { + SWarning Warning(Localize("Error saving touch controls"), Localize("Could not save touch controls to file. See local console for details.")); + Warning.m_AutoHide = false; + Client()->AddWarning(Warning); + } + } + + Row.VSplitLeft(5.0f, nullptr, &Row); + Row.VSplitLeft(240.0f, &Button, &Row); + if(GameClient()->m_TouchControls.HasEditingChanges()) + { + TextRender()->TextColor(ColorRGBA(1.0f, 0.0f, 0.0f, 1.0f)); + Ui()->DoLabel(&Button, Localize("Unsaved changes"), 14.0f, TEXTALIGN_MC); + TextRender()->TextColor(TextRender()->DefaultTextColor()); + } + + MainView.HSplitTop(25.0f, &Row, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + + Row.VSplitLeft(240.0f, &Button, &Row); + static CButtonContainer s_DiscardChangesButton; + if(DoButton_Menu(&s_DiscardChangesButton, Localize("Discard changes"), GameClient()->m_TouchControls.HasEditingChanges() ? 0 : 1, &Button)) + { + PopupConfirm(Localize("Discard changes"), + Localize("Are you sure that you want to discard the current changes to the touch controls?"), + Localize("Yes"), Localize("No"), + &CMenus::PopupConfirmDiscardTouchControlsChanges); + } + + Row.VSplitLeft(5.0f, nullptr, &Row); + Row.VSplitLeft(240.0f, &Button, &Row); + static CButtonContainer s_ResetButton; + if(DoButton_Menu(&s_ResetButton, Localize("Reset to default"), 0, &Button)) + { + PopupConfirm(Localize("Reset to default"), + Localize("Are you sure that you want to reset the touch controls to default?"), + Localize("Yes"), Localize("No"), + &CMenus::PopupConfirmResetTouchControls); + } + + MainView.HSplitTop(25.0f, &Row, &MainView); + MainView.HSplitTop(10.0f, nullptr, &MainView); + + Row.VSplitLeft(240.0f, &Button, &Row); + static CButtonContainer s_ClipboardImportButton; + if(DoButton_Menu(&s_ClipboardImportButton, Localize("Import from clipboard"), 0, &Button)) + { + PopupConfirm(Localize("Import from clipboard"), + Localize("Are you sure that you want to import the touch controls from the clipboard? The will overwrite your current touch controls."), + Localize("Yes"), Localize("No"), + &CMenus::PopupConfirmImportTouchControlsClipboard); + } + + Row.VSplitLeft(5.0f, nullptr, &Row); + Row.VSplitLeft(240.0f, &Button, &Row); + static CButtonContainer s_ClipboardExportButton; + if(DoButton_Menu(&s_ClipboardExportButton, Localize("Export to clipboard"), 0, &Button)) + { + GameClient()->m_TouchControls.SaveConfigurationToClipboard(); + } + + MainView.HSplitTop(25.0f, &Label, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + Ui()->DoLabel(&Label, Localize("Settings"), 20.0f, TEXTALIGN_MC); + + MainView.HSplitTop(25.0f, &Row, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + + Row.VSplitLeft(300.0f, &Label, &Row); + Ui()->DoLabel(&Label, Localize("Direct touch input while ingame"), 16.0f, TEXTALIGN_ML); + + Row.VSplitLeft(5.0f, nullptr, &Row); + Row.VSplitLeft(180.0f, &Button, &Row); + const char *apIngameTouchModes[(int)CTouchControls::EDirectTouchIngameMode::NUM_STATES] = {Localize("Disabled", "Direct touch input"), Localize("Active action", "Direct touch input"), Localize("Aim", "Direct touch input"), Localize("Fire", "Direct touch input"), Localize("Hook", "Direct touch input")}; + const CTouchControls::EDirectTouchIngameMode OldDirectTouchIngame = GameClient()->m_TouchControls.DirectTouchIngame(); + static CUi::SDropDownState s_DirectTouchIngameDropDownState; + static CScrollRegion s_DirectTouchIngameDropDownScrollRegion; + s_DirectTouchIngameDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_DirectTouchIngameDropDownScrollRegion; + const CTouchControls::EDirectTouchIngameMode NewDirectTouchIngame = (CTouchControls::EDirectTouchIngameMode)Ui()->DoDropDown(&Button, (int)OldDirectTouchIngame, apIngameTouchModes, std::size(apIngameTouchModes), s_DirectTouchIngameDropDownState); + if(OldDirectTouchIngame != NewDirectTouchIngame) + { + GameClient()->m_TouchControls.SetDirectTouchIngame(NewDirectTouchIngame); + } + + MainView.HSplitTop(25.0f, &Row, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + + Row.VSplitLeft(300.0f, &Label, &Row); + Ui()->DoLabel(&Label, Localize("Direct touch input while spectating"), 16.0f, TEXTALIGN_ML); + + Row.VSplitLeft(5.0f, nullptr, &Row); + Row.VSplitLeft(180.0f, &Button, &Row); + const char *apSpectateTouchModes[(int)CTouchControls::EDirectTouchSpectateMode::NUM_STATES] = {Localize("Disabled", "Direct touch input"), Localize("Aim", "Direct touch input")}; + const CTouchControls::EDirectTouchSpectateMode OldDirectTouchSpectate = GameClient()->m_TouchControls.DirectTouchSpectate(); + static CUi::SDropDownState s_DirectTouchSpectateDropDownState; + static CScrollRegion s_DirectTouchSpectateDropDownScrollRegion; + s_DirectTouchSpectateDropDownState.m_SelectionPopupContext.m_pScrollRegion = &s_DirectTouchSpectateDropDownScrollRegion; + const CTouchControls::EDirectTouchSpectateMode NewDirectTouchSpectate = (CTouchControls::EDirectTouchSpectateMode)Ui()->DoDropDown(&Button, (int)OldDirectTouchSpectate, apSpectateTouchModes, std::size(apSpectateTouchModes), s_DirectTouchSpectateDropDownState); + if(OldDirectTouchSpectate != NewDirectTouchSpectate) + { + GameClient()->m_TouchControls.SetDirectTouchSpectate(NewDirectTouchSpectate); + } } void CMenus::PopupConfirmDisconnect() @@ -227,6 +393,57 @@ void CMenus::PopupConfirmDisconnectDummy() SetActive(false); } +void CMenus::PopupConfirmDiscardTouchControlsChanges() +{ + if(GameClient()->m_TouchControls.LoadConfigurationFromFile(IStorage::TYPE_ALL)) + { + GameClient()->m_TouchControls.SetEditingChanges(false); + } + else + { + SWarning Warning(Localize("Error loading touch controls"), Localize("Could not load touch controls from file. See local console for details.")); + Warning.m_AutoHide = false; + Client()->AddWarning(Warning); + } +} + +void CMenus::PopupConfirmResetTouchControls() +{ + bool Success = false; + for(int StorageType = IStorage::TYPE_SAVE + 1; StorageType < Storage()->NumPaths(); ++StorageType) + { + if(GameClient()->m_TouchControls.LoadConfigurationFromFile(StorageType)) + { + Success = true; + break; + } + } + if(Success) + { + GameClient()->m_TouchControls.SetEditingChanges(true); + } + else + { + SWarning Warning(Localize("Error loading touch controls"), Localize("Could not load default touch controls from file. See local console for details.")); + Warning.m_AutoHide = false; + Client()->AddWarning(Warning); + } +} + +void CMenus::PopupConfirmImportTouchControlsClipboard() +{ + if(GameClient()->m_TouchControls.LoadConfigurationFromClipboard()) + { + GameClient()->m_TouchControls.SetEditingChanges(true); + } + else + { + SWarning Warning(Localize("Error loading touch controls"), Localize("Could not load touch controls from clipboard. See local console for details.")); + Warning.m_AutoHide = false; + Client()->AddWarning(Warning); + } +} + void CMenus::RenderPlayers(CUIRect MainView) { CUIRect Button, Button2, ButtonBar, PlayerList, Player; @@ -1281,6 +1498,10 @@ void CMenus::RenderGhost(CUIRect MainView) void CMenus::RenderIngameHint() { + // With touch controls enabled there is a Close button in the menu and usually no Escape key available. + if(g_Config.m_ClTouchControls) + return; + float Width = 300 * Graphics()->ScreenAspect(); Graphics()->MapScreen(0, 0, Width, 300); TextRender()->TextColor(1, 1, 1, 1); diff --git a/src/game/client/components/touch_controls.cpp b/src/game/client/components/touch_controls.cpp new file mode 100644 index 00000000000..07942591658 --- /dev/null +++ b/src/game/client/components/touch_controls.cpp @@ -0,0 +1,1568 @@ +#include "touch_controls.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; + +// TODO: Add user interface to adjust button layout +// TODO: Add "color" property for touch buttons? +// TODO: Add combined weapon picker button that shows all currently available weapons +// TODO: Add "joystick-aim-relative", a virtual joystick that moves the mouse pointer relatively. And add "aim-relative" ingame direct touch input. +// TODO: Add "choice" predefined behavior which shows a selection popup for 2 or more other behaviors? +// TODO: Support changing labels of menu buttons (or support overriding label for all predefined button behaviors)? + +static constexpr const char *const ACTION_NAMES[] = {Localizable("Aim"), Localizable("Fire"), Localizable("Hook")}; +static constexpr const char *const ACTION_SWAP_NAMES[] = {/* unused */ "", Localizable("Active: Fire"), Localizable("Active: Hook")}; +static constexpr const char *const ACTION_COMMANDS[] = {/* unused */ "", "+fire", "+hook"}; + +static constexpr std::chrono::milliseconds LONG_TOUCH_DURATION = 500ms; +static constexpr std::chrono::milliseconds BIND_REPEAT_INITIAL_DELAY = 250ms; +static constexpr std::chrono::nanoseconds BIND_REPEAT_RATE = std::chrono::nanoseconds(1s) / 15; + +static constexpr const char *const CONFIGURATION_FILENAME = "touch_controls.json"; +static constexpr int BUTTON_SIZE_SCALE = 1000000; +static constexpr int BUTTON_SIZE_MINIMUM = 50000; +static constexpr int BUTTON_SIZE_MAXIMUM = 500000; + +/* This is required for the localization script to find the labels of the default bind buttons specified in the configuration file: +Localizable("Move left") Localizable("Move right") Localizable("Jump") Localizable("Prev. weapon") Localizable("Next weapon") +Localizable("Zoom out") Localizable("Default zoom") Localizable("Zoom in") Localizable("Scoreboard") Localizable("Chat") Localizable("Team chat") +Localizable("Vote yes") Localizable("Vote no") Localizable("Toggle dummy") +*/ + +CTouchControls::CTouchButton::CTouchButton(CTouchControls *pTouchControls) : + m_pTouchControls(pTouchControls), + m_VisibilityCached(false) +{ +} + +CTouchControls::CTouchButton::CTouchButton(CTouchButton &&Other) noexcept : + m_pTouchControls(Other.m_pTouchControls), + m_UnitRect(Other.m_UnitRect), + m_Shape(Other.m_Shape), + m_vVisibilities(Other.m_vVisibilities), + m_pBehavior(std::move(Other.m_pBehavior)), + m_VisibilityCached(false) +{ + Other.m_pTouchControls = nullptr; +} + +CTouchControls::CTouchButton &CTouchControls::CTouchButton::operator=(CTouchButton &&Other) noexcept +{ + m_pTouchControls = Other.m_pTouchControls; + Other.m_pTouchControls = nullptr; + m_UnitRect = Other.m_UnitRect; + m_Shape = Other.m_Shape; + m_vVisibilities = Other.m_vVisibilities; + m_pBehavior = std::move(Other.m_pBehavior); + m_VisibilityCached = false; + return *this; +} + +void CTouchControls::CTouchButton::UpdatePointers() +{ + m_pBehavior->Init(this); +} + +void CTouchControls::CTouchButton::UpdateScreenFromUnitRect() +{ + const vec2 ScreenSize = m_pTouchControls->CalculateScreenSize(); + m_ScreenRect.x = m_UnitRect.m_X * ScreenSize.x / BUTTON_SIZE_SCALE; + m_ScreenRect.y = m_UnitRect.m_Y * ScreenSize.y / BUTTON_SIZE_SCALE; + m_ScreenRect.w = m_UnitRect.m_W * ScreenSize.x / BUTTON_SIZE_SCALE; + m_ScreenRect.h = m_UnitRect.m_H * ScreenSize.y / BUTTON_SIZE_SCALE; + + // Enforce circle shape so the screen rect can be used for mapping the touch input position + if(m_Shape == EButtonShape::CIRCLE) + { + if(m_ScreenRect.h > m_ScreenRect.w) + { + m_ScreenRect.y += (m_ScreenRect.h - m_ScreenRect.w) / 2.0f; + m_ScreenRect.h = m_ScreenRect.w; + } + else if(m_ScreenRect.w > m_ScreenRect.h) + { + m_ScreenRect.x += (m_ScreenRect.w - m_ScreenRect.h) / 2.0f; + m_ScreenRect.w = m_ScreenRect.h; + } + } +} + +void CTouchControls::CTouchButton::UpdateBackgroundCorners() +{ + if(m_Shape != EButtonShape::RECT) + { + m_BackgroundCorners = IGraphics::CORNER_NONE; + return; + } + + // Determine rounded corners based on button layout + m_BackgroundCorners = IGraphics::CORNER_ALL; + + if(m_UnitRect.m_X == 0) + { + m_BackgroundCorners &= ~IGraphics::CORNER_L; + } + if(m_UnitRect.m_X + m_UnitRect.m_W == BUTTON_SIZE_SCALE) + { + m_BackgroundCorners &= ~IGraphics::CORNER_R; + } + if(m_UnitRect.m_Y == 0) + { + m_BackgroundCorners &= ~IGraphics::CORNER_T; + } + if(m_UnitRect.m_Y + m_UnitRect.m_H == BUTTON_SIZE_SCALE) + { + m_BackgroundCorners &= ~IGraphics::CORNER_B; + } + + const auto &&PointInOrOnRect = [](ivec2 Point, CUnitRect Rect) { + return Point.x >= Rect.m_X && Point.x <= Rect.m_X + Rect.m_W && Point.y >= Rect.m_Y && Point.y <= Rect.m_Y + Rect.m_H; + }; + for(const CTouchButton &OtherButton : m_pTouchControls->m_vTouchButtons) + { + if(&OtherButton == this || OtherButton.m_Shape != EButtonShape::RECT) + continue; + // TODO: This does not consider that button visibilities can change independently, also update corners when any visibility changed + const bool ExcludingVisibilities = std::any_of(OtherButton.m_vVisibilities.begin(), OtherButton.m_vVisibilities.end(), [&](const CButtonVisibility &OtherVisibility) { + return std::any_of(m_vVisibilities.begin(), m_vVisibilities.end(), [&](const CButtonVisibility &OurVisibility) { + return OtherVisibility.m_Type == OurVisibility.m_Type && OtherVisibility.m_Parity != OurVisibility.m_Parity; + }); + }); + if(ExcludingVisibilities) + continue; + + if((m_BackgroundCorners & IGraphics::CORNER_TL) && PointInOrOnRect(ivec2(m_UnitRect.m_X, m_UnitRect.m_Y), OtherButton.m_UnitRect)) + { + m_BackgroundCorners &= ~IGraphics::CORNER_TL; + } + if((m_BackgroundCorners & IGraphics::CORNER_TR) && PointInOrOnRect(ivec2(m_UnitRect.m_X + m_UnitRect.m_W, m_UnitRect.m_Y), OtherButton.m_UnitRect)) + { + m_BackgroundCorners &= ~IGraphics::CORNER_TR; + } + if((m_BackgroundCorners & IGraphics::CORNER_BL) && PointInOrOnRect(ivec2(m_UnitRect.m_X, m_UnitRect.m_Y + m_UnitRect.m_H), OtherButton.m_UnitRect)) + { + m_BackgroundCorners &= ~IGraphics::CORNER_BL; + } + if((m_BackgroundCorners & IGraphics::CORNER_BR) && PointInOrOnRect(ivec2(m_UnitRect.m_X + m_UnitRect.m_W, m_UnitRect.m_Y + m_UnitRect.m_H), OtherButton.m_UnitRect)) + { + m_BackgroundCorners &= ~IGraphics::CORNER_BR; + } + if(m_BackgroundCorners == IGraphics::CORNER_NONE) + { + break; + } + } +} + +vec2 CTouchControls::CTouchButton::ClampTouchPosition(vec2 TouchPosition) const +{ + switch(m_Shape) + { + case EButtonShape::RECT: + { + TouchPosition.x = clamp(TouchPosition.x, m_ScreenRect.x, m_ScreenRect.x + m_ScreenRect.w); + TouchPosition.y = clamp(TouchPosition.y, m_ScreenRect.y, m_ScreenRect.y + m_ScreenRect.h); + break; + } + case EButtonShape::CIRCLE: + { + const vec2 Center = m_ScreenRect.Center(); + const float MaxLength = minimum(m_ScreenRect.w, m_ScreenRect.h) / 2.0f; + const vec2 TouchDirection = TouchPosition - Center; + const float Length = length(TouchDirection); + if(Length > MaxLength) + { + TouchPosition = normalize_pre_length(TouchDirection, Length) * MaxLength + Center; + } + break; + } + default: + dbg_assert(false, "Unhandled shape"); + break; + } + return TouchPosition; +} + +bool CTouchControls::CTouchButton::IsInside(vec2 TouchPosition) const +{ + switch(m_Shape) + { + case EButtonShape::RECT: + return m_ScreenRect.Inside(TouchPosition); + case EButtonShape::CIRCLE: + return distance(TouchPosition, m_ScreenRect.Center()) <= minimum(m_ScreenRect.w, m_ScreenRect.h) / 2.0f; + default: + dbg_assert(false, "Unhandled shape"); + return false; + } +} + +void CTouchControls::CTouchButton::UpdateVisibility() +{ + const bool PrevVisibility = m_VisibilityCached; + m_VisibilityCached = m_pTouchControls->m_EditingActive || std::all_of(m_vVisibilities.begin(), m_vVisibilities.end(), [&](CButtonVisibility Visibility) { + return m_pTouchControls->m_aVisibilityFunctions[(int)Visibility.m_Type].m_Function() == Visibility.m_Parity; + }); + if(m_VisibilityCached && !PrevVisibility) + { + m_VisibilityStartTime = time_get_nanoseconds(); + } +} + +bool CTouchControls::CTouchButton::IsVisible() const +{ + return m_VisibilityCached; +} + +// TODO: Optimization: Use text and quad containers for rendering +void CTouchControls::CTouchButton::Render() const +{ + const ColorRGBA ButtonColor = m_pBehavior->IsActive() ? ColorRGBA(0.2f, 0.2f, 0.2f, 0.25f) : ColorRGBA(0.0f, 0.0f, 0.0f, 0.25f); + + switch(m_Shape) + { + case EButtonShape::RECT: + { + m_ScreenRect.Draw(ButtonColor, m_BackgroundCorners, 10.0f); + break; + } + case EButtonShape::CIRCLE: + { + const vec2 Center = m_ScreenRect.Center(); + const float Radius = minimum(m_ScreenRect.w, m_ScreenRect.h) / 2.0f; + m_pTouchControls->Graphics()->TextureClear(); + m_pTouchControls->Graphics()->QuadsBegin(); + m_pTouchControls->Graphics()->SetColor(ButtonColor); + m_pTouchControls->Graphics()->DrawCircle(Center.x, Center.y, Radius, maximum(round_truncate(Radius / 4.0f) & ~1, 32)); + m_pTouchControls->Graphics()->QuadsEnd(); + break; + } + default: + dbg_assert(false, "Unhandled shape"); + break; + } + + const float FontSize = 22.0f; + CButtonLabel LabelData = m_pBehavior->GetLabel(); + CUIRect LabelRect; + m_ScreenRect.Margin(10.0f, &LabelRect); + SLabelProperties LabelProps; + LabelProps.m_MaxWidth = LabelRect.w; + if(LabelData.m_Type == CButtonLabel::EType::ICON) + { + m_pTouchControls->TextRender()->SetFontPreset(EFontPreset::ICON_FONT); + m_pTouchControls->TextRender()->SetRenderFlags(ETextRenderFlags::TEXT_RENDER_FLAG_ONLY_ADVANCE_WIDTH | ETextRenderFlags::TEXT_RENDER_FLAG_NO_X_BEARING | ETextRenderFlags::TEXT_RENDER_FLAG_NO_Y_BEARING); + m_pTouchControls->Ui()->DoLabel(&LabelRect, LabelData.m_pLabel, FontSize, TEXTALIGN_MC, LabelProps); + m_pTouchControls->TextRender()->SetRenderFlags(0); + m_pTouchControls->TextRender()->SetFontPreset(EFontPreset::DEFAULT_FONT); + } + else + { + const char *pLabel = LabelData.m_Type == CButtonLabel::EType::LOCALIZED ? Localize(LabelData.m_pLabel) : LabelData.m_pLabel; + m_pTouchControls->Ui()->DoLabel(&LabelRect, pLabel, FontSize, TEXTALIGN_MC, LabelProps); + } +} + +void CTouchControls::CTouchButton::WriteToConfiguration(CJsonWriter *pWriter) +{ + char aBuf[256]; + + pWriter->BeginObject(); + + pWriter->WriteAttribute("x"); + pWriter->WriteIntValue(m_UnitRect.m_X); + pWriter->WriteAttribute("y"); + pWriter->WriteIntValue(m_UnitRect.m_Y); + pWriter->WriteAttribute("w"); + pWriter->WriteIntValue(m_UnitRect.m_W); + pWriter->WriteAttribute("h"); + pWriter->WriteIntValue(m_UnitRect.m_H); + + pWriter->WriteAttribute("shape"); + pWriter->WriteStrValue(SHAPE_NAMES[(int)m_Shape]); + + pWriter->WriteAttribute("visibilities"); + pWriter->BeginArray(); + for(CButtonVisibility Visibility : m_vVisibilities) + { + str_format(aBuf, sizeof(aBuf), "%s%s", Visibility.m_Parity ? "" : "-", m_pTouchControls->m_aVisibilityFunctions[(int)Visibility.m_Type].m_pId); + pWriter->WriteStrValue(aBuf); + } + pWriter->EndArray(); + + pWriter->WriteAttribute("behavior"); + pWriter->BeginObject(); + m_pBehavior->WriteToConfiguration(pWriter); + pWriter->EndObject(); + + pWriter->EndObject(); +} + +void CTouchControls::CTouchButtonBehavior::Init(CTouchButton *pTouchButton) +{ + m_pTouchButton = pTouchButton; + m_pTouchControls = pTouchButton->m_pTouchControls; +} + +void CTouchControls::CTouchButtonBehavior::Reset() +{ + m_Active = false; +} + +void CTouchControls::CTouchButtonBehavior::SetActive(const IInput::CTouchFingerState &FingerState) +{ + const vec2 ScreenSize = m_pTouchControls->CalculateScreenSize(); + const CUIRect ButtonScreenRect = m_pTouchButton->m_ScreenRect; + const vec2 Position = (m_pTouchButton->ClampTouchPosition(FingerState.m_Position * ScreenSize) - ButtonScreenRect.TopLeft()) / ButtonScreenRect.Size(); + const vec2 Delta = FingerState.m_Delta * ScreenSize / ButtonScreenRect.Size(); + if(!m_Active) + { + m_Active = true; + m_ActivePosition = Position; + m_AccumulatedDelta = Delta; + m_ActivationStartTime = time_get_nanoseconds(); + m_Finger = FingerState.m_Finger; + OnActivate(); + } + else if(m_Finger == FingerState.m_Finger) + { + m_ActivePosition = Position; + m_AccumulatedDelta += Delta; + OnUpdate(); + } + else + { + dbg_assert(false, "Touch button must be inactive or use same finger"); + } +} + +void CTouchControls::CTouchButtonBehavior::SetInactive() +{ + if(m_Active) + { + m_Active = false; + OnDeactivate(); + } +} + +bool CTouchControls::CTouchButtonBehavior::IsActive() const +{ + return m_Active; +} + +bool CTouchControls::CTouchButtonBehavior::IsActive(const IInput::CTouchFinger &Finger) const +{ + return m_Active && m_Finger == Finger; +} + +void CTouchControls::CPredefinedTouchButtonBehavior::WriteToConfiguration(CJsonWriter *pWriter) +{ + pWriter->WriteAttribute("type"); + pWriter->WriteStrValue(BEHAVIOR_TYPE); + + pWriter->WriteAttribute("id"); + pWriter->WriteStrValue(m_pId); +} + +// Ingame menu button: always opens ingame menu. +CTouchControls::CButtonLabel CTouchControls::CIngameMenuTouchButtonBehavior::GetLabel() const +{ + return {CButtonLabel::EType::ICON, "\xEF\x85\x8E"}; +} + +void CTouchControls::CIngameMenuTouchButtonBehavior::OnDeactivate() +{ + m_pTouchControls->GameClient()->m_Menus.SetActive(true); +} + +// Extra menu button: +// - Short press: show/hide additional buttons (toggle extra-menu visibilities) +// - Long press: open ingame menu +CTouchControls::CExtraMenuTouchButtonBehavior::CExtraMenuTouchButtonBehavior(int Number) : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID), + m_Number(Number) +{ + if(m_Number == 0) + { + str_copy(m_aLabel, "\xEF\x83\x89"); + } + else + { + str_format(m_aLabel, sizeof(m_aLabel), "\xEF\x83\x89%d", m_Number + 1); + } +} + +CTouchControls::CButtonLabel CTouchControls::CExtraMenuTouchButtonBehavior::GetLabel() const +{ + if(m_Active && time_get_nanoseconds() - m_ActivationStartTime >= LONG_TOUCH_DURATION) + { + return {CButtonLabel::EType::ICON, "\xEF\x95\x90"}; + } + else + { + return {CButtonLabel::EType::ICON, m_aLabel}; + } +} + +void CTouchControls::CExtraMenuTouchButtonBehavior::OnDeactivate() +{ + if(time_get_nanoseconds() - m_ActivationStartTime >= LONG_TOUCH_DURATION) + { + m_pTouchControls->GameClient()->m_Menus.SetActive(true); + } + else + { + m_pTouchControls->m_aExtraMenuActive[m_Number] = !m_pTouchControls->m_aExtraMenuActive[m_Number]; + } +} + +void CTouchControls::CExtraMenuTouchButtonBehavior::WriteToConfiguration(CJsonWriter *pWriter) +{ + CPredefinedTouchButtonBehavior::WriteToConfiguration(pWriter); + + pWriter->WriteAttribute("number"); + pWriter->WriteIntValue(m_Number + 1); +} + +// Emoticon button: keeps the emoticon HUD open, next touch in emoticon HUD will close it again. +CTouchControls::CButtonLabel CTouchControls::CEmoticonTouchButtonBehavior::GetLabel() const +{ + return {CButtonLabel::EType::LOCALIZED, Localizable("Emoticon")}; +} + +void CTouchControls::CEmoticonTouchButtonBehavior::OnDeactivate() +{ + m_pTouchControls->Console()->ExecuteLineStroked(1, "+emote"); +} + +// Spectate button: keeps the spectate menu open, next touch in spectate menu will close it again. +CTouchControls::CButtonLabel CTouchControls::CSpectateTouchButtonBehavior::GetLabel() const +{ + return {CButtonLabel::EType::LOCALIZED, Localizable("Spectator mode")}; +} + +void CTouchControls::CSpectateTouchButtonBehavior::OnDeactivate() +{ + m_pTouchControls->Console()->ExecuteLineStroked(1, "+spectate"); +} + +// Swap action button: +// - If joystick is currently active with one action: activate the other action. +// - Else: swap active action. +CTouchControls::CButtonLabel CTouchControls::CSwapActionTouchButtonBehavior::GetLabel() const +{ + if(m_ActiveAction != NUM_ACTIONS) + { + return {CButtonLabel::EType::LOCALIZED, ACTION_NAMES[m_ActiveAction]}; + } + else if(m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior != nullptr && + m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior->ActiveAction() != NUM_ACTIONS) + { + return {CButtonLabel::EType::LOCALIZED, ACTION_NAMES[m_pTouchControls->NextActiveAction(m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior->ActiveAction())]}; + } + return {CButtonLabel::EType::LOCALIZED, ACTION_SWAP_NAMES[m_pTouchControls->m_ActionSelected]}; +} + +void CTouchControls::CSwapActionTouchButtonBehavior::OnActivate() +{ + if(m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior != nullptr && + m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior->ActiveAction() != NUM_ACTIONS) + { + m_ActiveAction = m_pTouchControls->NextActiveAction(m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior->ActiveAction()); + m_pTouchControls->Console()->ExecuteLineStroked(1, ACTION_COMMANDS[m_ActiveAction]); + } + else + { + m_pTouchControls->m_ActionSelected = m_pTouchControls->NextActiveAction(m_pTouchControls->m_ActionSelected); + } +} + +void CTouchControls::CSwapActionTouchButtonBehavior::OnDeactivate() +{ + if(m_ActiveAction != NUM_ACTIONS) + { + m_pTouchControls->Console()->ExecuteLineStroked(0, ACTION_COMMANDS[m_ActiveAction]); + m_ActiveAction = NUM_ACTIONS; + } +} + +// Use action button: always uses the active action. +CTouchControls::CButtonLabel CTouchControls::CUseActionTouchButtonBehavior::GetLabel() const +{ + if(m_ActiveAction != NUM_ACTIONS) + { + return {CButtonLabel::EType::LOCALIZED, ACTION_NAMES[m_ActiveAction]}; + } + return {CButtonLabel::EType::LOCALIZED, ACTION_NAMES[m_pTouchControls->m_ActionSelected]}; +} + +void CTouchControls::CUseActionTouchButtonBehavior::OnActivate() +{ + m_ActiveAction = m_pTouchControls->m_ActionSelected; + m_pTouchControls->Console()->ExecuteLineStroked(1, ACTION_COMMANDS[m_ActiveAction]); +} + +void CTouchControls::CUseActionTouchButtonBehavior::OnDeactivate() +{ + m_pTouchControls->Console()->ExecuteLineStroked(0, ACTION_COMMANDS[m_ActiveAction]); + m_ActiveAction = NUM_ACTIONS; +} + +// Generic joystick button behavior: aim with virtual joystick and use action (defined by subclass). +CTouchControls::CButtonLabel CTouchControls::CJoystickTouchButtonBehavior::GetLabel() const +{ + if(m_ActiveAction != NUM_ACTIONS) + { + return {CButtonLabel::EType::LOCALIZED, ACTION_NAMES[m_ActiveAction]}; + } + return {CButtonLabel::EType::LOCALIZED, ACTION_NAMES[SelectedAction()]}; +} + +void CTouchControls::CJoystickTouchButtonBehavior::OnActivate() +{ + m_ActiveAction = SelectedAction(); + OnUpdate(); + if(m_ActiveAction != ACTION_AIM) + { + m_pTouchControls->Console()->ExecuteLineStroked(1, ACTION_COMMANDS[m_ActiveAction]); + } +} + +void CTouchControls::CJoystickTouchButtonBehavior::OnDeactivate() +{ + if(m_ActiveAction != ACTION_AIM) + { + m_pTouchControls->Console()->ExecuteLineStroked(0, ACTION_COMMANDS[m_ActiveAction]); + } + m_ActiveAction = NUM_ACTIONS; +} + +void CTouchControls::CJoystickTouchButtonBehavior::OnUpdate() +{ + CControls &Controls = m_pTouchControls->GameClient()->m_Controls; + if(m_pTouchControls->GameClient()->m_Snap.m_SpecInfo.m_Active) + { + vec2 WorldScreenSize; + m_pTouchControls->RenderTools()->CalcScreenParams(m_pTouchControls->Graphics()->ScreenAspect(), m_pTouchControls->GameClient()->m_Camera.m_Zoom, &WorldScreenSize.x, &WorldScreenSize.y); + Controls.m_aMousePos[g_Config.m_ClDummy] += -m_AccumulatedDelta * WorldScreenSize; + Controls.m_aMousePos[g_Config.m_ClDummy].x = clamp(Controls.m_aMousePos[g_Config.m_ClDummy].x, -201.0f * 32, (m_pTouchControls->Collision()->GetWidth() + 201.0f) * 32.0f); + Controls.m_aMousePos[g_Config.m_ClDummy].y = clamp(Controls.m_aMousePos[g_Config.m_ClDummy].y, -201.0f * 32, (m_pTouchControls->Collision()->GetHeight() + 201.0f) * 32.0f); + m_AccumulatedDelta = vec2(0.0f, 0.0f); + } + else + { + const vec2 AbsolutePosition = (m_ActivePosition - vec2(0.5f, 0.5f)) * 2.0f; + Controls.m_aMousePos[g_Config.m_ClDummy] = AbsolutePosition * (Controls.GetMaxMouseDistance() - Controls.GetMinMouseDistance()) + normalize(AbsolutePosition) * Controls.GetMinMouseDistance(); + if(length(Controls.m_aMousePos[g_Config.m_ClDummy]) < 0.001f) + { + Controls.m_aMousePos[g_Config.m_ClDummy].x = 0.001f; + Controls.m_aMousePos[g_Config.m_ClDummy].y = 0.0f; + } + } +} + +// Joystick that uses the active action. Registers itself as the primary joystick. +void CTouchControls::CJoystickActionTouchButtonBehavior::Init(CTouchButton *pTouchButton) +{ + CPredefinedTouchButtonBehavior::Init(pTouchButton); + m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior = this; +} + +int CTouchControls::CJoystickActionTouchButtonBehavior::SelectedAction() const +{ + return m_pTouchControls->m_ActionSelected; +} + +// Joystick that only aims. +int CTouchControls::CJoystickAimTouchButtonBehavior::SelectedAction() const +{ + return ACTION_AIM; +} + +// Joystick that always uses fire. +int CTouchControls::CJoystickFireTouchButtonBehavior::SelectedAction() const +{ + return ACTION_FIRE; +} + +// Joystick that always uses hook. +int CTouchControls::CJoystickHookTouchButtonBehavior::SelectedAction() const +{ + return ACTION_HOOK; +} + +// Bind button behavior that executes a command like a bind. +CTouchControls::CButtonLabel CTouchControls::CBindTouchButtonBehavior::GetLabel() const +{ + return {m_LabelType, m_Label.c_str()}; +} + +void CTouchControls::CBindTouchButtonBehavior::OnActivate() +{ + m_pTouchControls->Console()->ExecuteLineStroked(1, m_Command.c_str()); + m_Repeating = false; +} + +void CTouchControls::CBindTouchButtonBehavior::OnDeactivate() +{ + m_pTouchControls->Console()->ExecuteLineStroked(0, m_Command.c_str()); +} + +void CTouchControls::CBindTouchButtonBehavior::OnUpdate() +{ + const auto Now = time_get_nanoseconds(); + if(m_Repeating) + { + m_AccumulatedRepeatingTime += Now - m_LastUpdateTime; + m_LastUpdateTime = Now; + if(m_AccumulatedRepeatingTime >= BIND_REPEAT_RATE) + { + m_AccumulatedRepeatingTime -= BIND_REPEAT_RATE; + m_pTouchControls->Console()->ExecuteLineStroked(1, m_Command.c_str()); + } + } + else if(Now - m_ActivationStartTime >= BIND_REPEAT_INITIAL_DELAY) + { + m_Repeating = true; + m_LastUpdateTime = Now; + m_AccumulatedRepeatingTime = 0ns; + } +} + +void CTouchControls::CBindTouchButtonBehavior::WriteToConfiguration(CJsonWriter *pWriter) +{ + pWriter->WriteAttribute("type"); + pWriter->WriteStrValue(BEHAVIOR_TYPE); + + pWriter->WriteAttribute("label"); + pWriter->WriteStrValue(m_Label.c_str()); + + pWriter->WriteAttribute("label-type"); + pWriter->WriteStrValue(LABEL_TYPE_NAMES[(int)m_LabelType]); + + pWriter->WriteAttribute("command"); + pWriter->WriteStrValue(m_Command.c_str()); +} + +// Bind button behavior that switches between executing one of two or more console commands. +CTouchControls::CButtonLabel CTouchControls::CBindToggleTouchButtonBehavior::GetLabel() const +{ + const auto &ActiveCommand = m_vCommands[m_ActiveCommandIndex]; + return {ActiveCommand.m_LabelType, ActiveCommand.m_Label.c_str()}; +} + +void CTouchControls::CBindToggleTouchButtonBehavior::OnActivate() +{ + m_pTouchControls->Console()->ExecuteLine(m_vCommands[m_ActiveCommandIndex].m_Command.c_str()); + m_ActiveCommandIndex = (m_ActiveCommandIndex + 1) % m_vCommands.size(); +} + +void CTouchControls::CBindToggleTouchButtonBehavior::WriteToConfiguration(CJsonWriter *pWriter) +{ + pWriter->WriteAttribute("type"); + pWriter->WriteStrValue(BEHAVIOR_TYPE); + + pWriter->WriteAttribute("commands"); + pWriter->BeginArray(); + + for(const auto &Command : m_vCommands) + { + pWriter->BeginObject(); + + pWriter->WriteAttribute("label"); + pWriter->WriteStrValue(Command.m_Label.c_str()); + + pWriter->WriteAttribute("label-type"); + pWriter->WriteStrValue(LABEL_TYPE_NAMES[(int)Command.m_LabelType]); + + pWriter->WriteAttribute("command"); + pWriter->WriteStrValue(Command.m_Command.c_str()); + + pWriter->EndObject(); + } + + pWriter->EndArray(); +} + +void CTouchControls::OnInit() +{ + InitVisibilityFunctions(); + if(!LoadConfigurationFromFile(IStorage::TYPE_ALL)) + { + Client()->AddWarning(SWarning(Localize("Error loading touch controls"), Localize("Could not load touch controls from file. See local console for details."))); + } +} + +void CTouchControls::OnReset() +{ + ResetButtons(); + m_EditingActive = false; +} + +void CTouchControls::OnWindowResize() +{ + ResetButtons(); + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.UpdateScreenFromUnitRect(); + } +} + +bool CTouchControls::OnTouchState(const std::vector &vTouchFingerStates) +{ + if(!g_Config.m_ClTouchControls) + return false; + if(Client()->State() != IClient::STATE_ONLINE && Client()->State() != IClient::STATE_DEMOPLAYBACK) + return false; + if(GameClient()->m_Chat.IsActive() || + !GameClient()->m_GameConsole.IsClosed() || + GameClient()->m_Menus.IsActive() || + GameClient()->m_Emoticon.IsActive() || + GameClient()->m_Spectator.IsActive()) + { + ResetButtons(); + return false; + } + + UpdateButtons(vTouchFingerStates); + return true; +} + +void CTouchControls::OnRender() +{ + if(!g_Config.m_ClTouchControls) + return; + if(Client()->State() != IClient::STATE_ONLINE && Client()->State() != IClient::STATE_DEMOPLAYBACK) + return; + if(GameClient()->m_Chat.IsActive() || + GameClient()->m_Emoticon.IsActive() || + GameClient()->m_Spectator.IsActive()) + { + return; + } + + const vec2 ScreenSize = CalculateScreenSize(); + Graphics()->MapScreen(0.0f, 0.0f, ScreenSize.x, ScreenSize.y); + + RenderButtons(); +} + +bool CTouchControls::LoadConfigurationFromFile(int StorageType) +{ + void *pFileData; + unsigned FileLength; + if(!Storage()->ReadFile(CONFIGURATION_FILENAME, StorageType, &pFileData, &FileLength)) + { + log_error("touch_controls", "Failed to read configuration from '%s'", CONFIGURATION_FILENAME); + return false; + } + + const bool Result = ParseConfiguration(pFileData, FileLength); + free(pFileData); + return Result; +} + +bool CTouchControls::LoadConfigurationFromClipboard() +{ + std::string Clipboard = Input()->GetClipboardText(); + return ParseConfiguration(Clipboard.c_str(), Clipboard.size()); +} + +bool CTouchControls::SaveConfigurationToFile() +{ + IOHANDLE File = Storage()->OpenFile(CONFIGURATION_FILENAME, IOFLAG_WRITE, IStorage::TYPE_SAVE); + if(!File) + { + log_error("touch_controls", "Failed to open '%s' for writing configuration", CONFIGURATION_FILENAME); + return false; + } + + CJsonFileWriter Writer(File); + WriteConfiguration(&Writer); + return true; +} + +void CTouchControls::SaveConfigurationToClipboard() +{ + CJsonStringWriter Writer; + WriteConfiguration(&Writer); + std::string ConfigurationString = Writer.GetOutputString(); + Input()->SetClipboardText(ConfigurationString.c_str()); +} + +void CTouchControls::InitVisibilityFunctions() +{ + m_aVisibilityFunctions[(int)EButtonVisibility::INGAME].m_pId = "ingame"; + m_aVisibilityFunctions[(int)EButtonVisibility::INGAME].m_Function = [&]() { + return !GameClient()->m_Snap.m_SpecInfo.m_Active; + }; + m_aVisibilityFunctions[(int)EButtonVisibility::ZOOM_ALLOWED].m_pId = "zoom-allowed"; + m_aVisibilityFunctions[(int)EButtonVisibility::ZOOM_ALLOWED].m_Function = [&]() { + return GameClient()->m_Camera.ZoomAllowed(); + }; + m_aVisibilityFunctions[(int)EButtonVisibility::VOTE_ACTIVE].m_pId = "vote-active"; + m_aVisibilityFunctions[(int)EButtonVisibility::VOTE_ACTIVE].m_Function = [&]() { + return GameClient()->m_Voting.IsVoting(); + }; + m_aVisibilityFunctions[(int)EButtonVisibility::DUMMY_ALLOWED].m_pId = "dummy-allowed"; + m_aVisibilityFunctions[(int)EButtonVisibility::DUMMY_ALLOWED].m_Function = [&]() { + return Client()->DummyAllowed(); + }; + m_aVisibilityFunctions[(int)EButtonVisibility::DUMMY_CONNECTED].m_pId = "dummy-connected"; + m_aVisibilityFunctions[(int)EButtonVisibility::DUMMY_CONNECTED].m_Function = [&]() { + return Client()->DummyConnected(); + }; + m_aVisibilityFunctions[(int)EButtonVisibility::RCON_AUTHED].m_pId = "rcon-authed"; + m_aVisibilityFunctions[(int)EButtonVisibility::RCON_AUTHED].m_Function = [&]() { + return Client()->RconAuthed(); + }; + m_aVisibilityFunctions[(int)EButtonVisibility::DEMO_PLAYER].m_pId = "demo-player"; + m_aVisibilityFunctions[(int)EButtonVisibility::DEMO_PLAYER].m_Function = [&]() { + return Client()->State() == IClient::STATE_DEMOPLAYBACK; + }; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_1].m_pId = "extra-menu"; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_1].m_Function = [&]() { + return m_aExtraMenuActive[0]; + }; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_2].m_pId = "extra-menu-2"; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_2].m_Function = [&]() { + return m_aExtraMenuActive[1]; + }; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_3].m_pId = "extra-menu-3"; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_3].m_Function = [&]() { + return m_aExtraMenuActive[2]; + }; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_4].m_pId = "extra-menu-4"; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_4].m_Function = [&]() { + return m_aExtraMenuActive[3]; + }; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_5].m_pId = "extra-menu-5"; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU_5].m_Function = [&]() { + return m_aExtraMenuActive[4]; + }; +} + +int CTouchControls::NextActiveAction(int Action) const +{ + switch(Action) + { + case ACTION_FIRE: + return ACTION_HOOK; + case ACTION_HOOK: + return ACTION_FIRE; + default: + dbg_assert(false, "Action invalid for NextActiveAction"); + return NUM_ACTIONS; + } +} + +int CTouchControls::NextDirectTouchAction() const +{ + if(m_pClient->m_Snap.m_SpecInfo.m_Active) + { + switch(m_DirectTouchSpectate) + { + case EDirectTouchSpectateMode::DISABLED: + return NUM_ACTIONS; + case EDirectTouchSpectateMode::AIM: + return ACTION_AIM; + default: + dbg_assert(false, "m_DirectTouchSpectate invalid"); + return NUM_ACTIONS; + } + } + else + { + switch(m_DirectTouchIngame) + { + case EDirectTouchIngameMode::DISABLED: + return NUM_ACTIONS; + case EDirectTouchIngameMode::ACTION: + return m_ActionSelected; + case EDirectTouchIngameMode::AIM: + return ACTION_AIM; + case EDirectTouchIngameMode::FIRE: + return ACTION_FIRE; + case EDirectTouchIngameMode::HOOK: + return ACTION_HOOK; + default: + dbg_assert(false, "m_DirectTouchIngame invalid"); + return NUM_ACTIONS; + } + } +} + +void CTouchControls::UpdateButtons(const std::vector &vTouchFingerStates) +{ + // Update cached button visibilities and store time that buttons become visible. + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.UpdateVisibility(); + } + + const int DirectTouchAction = NextDirectTouchAction(); + const vec2 ScreenSize = CalculateScreenSize(); + + std::vector vRemainingTouchFingerStates = vTouchFingerStates; + + // Remove remaining finger states for fingers which are responsible for active actions + // and release action when the finger responsible for it is not pressed down anymore. + bool GotDirectFingerState = false; // Whether DirectFingerState is valid + IInput::CTouchFingerState DirectFingerState{}; // The finger that will be used to update the mouse position + for(int Action = ACTION_AIM; Action < NUM_ACTIONS; ++Action) + { + if(!m_aDirectTouchActionStates[Action].m_Active) + { + continue; + } + + const auto ActiveFinger = std::find_if(vRemainingTouchFingerStates.begin(), vRemainingTouchFingerStates.end(), [&](const IInput::CTouchFingerState &TouchFingerState) { + return TouchFingerState.m_Finger == m_aDirectTouchActionStates[Action].m_Finger; + }); + if(ActiveFinger == vRemainingTouchFingerStates.end() || DirectTouchAction == NUM_ACTIONS) + { + m_aDirectTouchActionStates[Action].m_Active = false; + if(Action != ACTION_AIM) + { + Console()->ExecuteLineStroked(0, ACTION_COMMANDS[Action]); + } + } + else + { + if(Action == m_DirectTouchLastAction) + { + GotDirectFingerState = true; + DirectFingerState = *ActiveFinger; + } + vRemainingTouchFingerStates.erase(ActiveFinger); + } + } + + // Update touch button states after the active action fingers were removed from the vector + // so that current cursor movement can cross over touch buttons without activating them. + + // Activate visible, inactive buttons with hovered finger. Deactivate previous button being + // activated by the same finger. Touch buttons are only activated if they became visible + // before the respective touch finger was pressed down, to prevent repeatedly activating + // overlapping buttons of excluding visibilities. + for(CTouchButton &TouchButton : m_vTouchButtons) + { + if(!TouchButton.IsVisible() || TouchButton.m_pBehavior->IsActive()) + { + continue; + } + const auto FingerInsideButton = std::find_if(vRemainingTouchFingerStates.begin(), vRemainingTouchFingerStates.end(), [&](const IInput::CTouchFingerState &TouchFingerState) { + return TouchButton.m_VisibilityStartTime < TouchFingerState.m_PressTime && + TouchButton.IsInside(TouchFingerState.m_Position * ScreenSize); + }); + if(FingerInsideButton == vRemainingTouchFingerStates.end()) + { + continue; + } + const auto OtherHoveredTouchButton = std::find_if(m_vTouchButtons.begin(), m_vTouchButtons.end(), [&](const CTouchButton &Button) { + return &Button != &TouchButton && Button.IsVisible() && Button.IsInside(FingerInsideButton->m_Position * ScreenSize); + }); + if(OtherHoveredTouchButton != m_vTouchButtons.end()) + { + // Do not activate any button if multiple overlapping buttons are hovered. + // TODO: Prevent overlapping buttons entirely when parsing the button configuration? + vRemainingTouchFingerStates.erase(FingerInsideButton); + continue; + } + auto PrevActiveTouchButton = std::find_if(m_vTouchButtons.begin(), m_vTouchButtons.end(), [&](const CTouchButton &Button) { + return Button.m_pBehavior->IsActive(FingerInsideButton->m_Finger); + }); + if(PrevActiveTouchButton != m_vTouchButtons.end()) + { + PrevActiveTouchButton->m_pBehavior->SetInactive(); + } + TouchButton.m_pBehavior->SetActive(*FingerInsideButton); + } + + // Deactivate touch buttons only when the respective finger is released, so touch buttons + // are kept active also if the finger is moved outside the button. + for(CTouchButton &TouchButton : m_vTouchButtons) + { + if(!TouchButton.IsVisible()) + { + TouchButton.m_pBehavior->SetInactive(); + continue; + } + if(!TouchButton.m_pBehavior->IsActive()) + { + continue; + } + const auto ActiveFinger = std::find_if(vRemainingTouchFingerStates.begin(), vRemainingTouchFingerStates.end(), [&](const IInput::CTouchFingerState &TouchFingerState) { + return TouchFingerState.m_Finger == TouchButton.m_pBehavior->m_Finger; + }); + if(ActiveFinger == vRemainingTouchFingerStates.end()) + { + TouchButton.m_pBehavior->SetInactive(); + } + else + { + // Update the already active touch button with the current finger state + TouchButton.m_pBehavior->SetActive(*ActiveFinger); + } + } + + // Remove remaining fingers for active buttons after updating the buttons. + for(CTouchButton &TouchButton : m_vTouchButtons) + { + if(!TouchButton.m_pBehavior->IsActive()) + { + continue; + } + const auto ActiveFinger = std::find_if(vRemainingTouchFingerStates.begin(), vRemainingTouchFingerStates.end(), [&](const IInput::CTouchFingerState &TouchFingerState) { + return TouchFingerState.m_Finger == TouchButton.m_pBehavior->m_Finger; + }); + dbg_assert(ActiveFinger != vRemainingTouchFingerStates.end(), "Active button finger not found"); + vRemainingTouchFingerStates.erase(ActiveFinger); + } + + // TODO: Support standard gesture to zoom (enabled separately for ingame and spectator) + + // Activate action if there is an unhandled pressed down finger. + int ActivateAction = NUM_ACTIONS; + if(DirectTouchAction != NUM_ACTIONS && !vRemainingTouchFingerStates.empty() && !m_aDirectTouchActionStates[DirectTouchAction].m_Active) + { + GotDirectFingerState = true; + DirectFingerState = vRemainingTouchFingerStates[0]; + vRemainingTouchFingerStates.erase(vRemainingTouchFingerStates.begin()); + m_aDirectTouchActionStates[DirectTouchAction].m_Active = true; + m_aDirectTouchActionStates[DirectTouchAction].m_Finger = DirectFingerState.m_Finger; + m_DirectTouchLastAction = DirectTouchAction; + ActivateAction = DirectTouchAction; + } + + // Update mouse position based on the finger responsible for the last active action. + if(GotDirectFingerState) + { + const float Zoom = m_pClient->m_Snap.m_SpecInfo.m_Active ? m_pClient->m_Camera.m_Zoom : 1.0f; + vec2 WorldScreenSize; + RenderTools()->CalcScreenParams(Graphics()->ScreenAspect(), Zoom, &WorldScreenSize.x, &WorldScreenSize.y); + CControls &Controls = GameClient()->m_Controls; + if(m_pClient->m_Snap.m_SpecInfo.m_Active) + { + Controls.m_aMousePos[g_Config.m_ClDummy] += -DirectFingerState.m_Delta * WorldScreenSize; + Controls.m_aMousePos[g_Config.m_ClDummy].x = clamp(Controls.m_aMousePos[g_Config.m_ClDummy].x, -201.0f * 32, (Collision()->GetWidth() + 201.0f) * 32.0f); + Controls.m_aMousePos[g_Config.m_ClDummy].y = clamp(Controls.m_aMousePos[g_Config.m_ClDummy].y, -201.0f * 32, (Collision()->GetHeight() + 201.0f) * 32.0f); + } + else + { + Controls.m_aMousePos[g_Config.m_ClDummy] = (DirectFingerState.m_Position - vec2(0.5f, 0.5f)) * WorldScreenSize; + } + } + + // Activate action after the mouse position is set. + if(ActivateAction != ACTION_AIM && ActivateAction != NUM_ACTIONS) + { + Console()->ExecuteLineStroked(1, ACTION_COMMANDS[ActivateAction]); + } +} + +void CTouchControls::ResetButtons() +{ + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.m_pBehavior->Reset(); + } + for(CActionState &ActionState : m_aDirectTouchActionStates) + { + ActionState.m_Active = false; + } +} + +void CTouchControls::RenderButtons() +{ + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.UpdateVisibility(); + if(!TouchButton.IsVisible()) + { + continue; + } + TouchButton.Render(); + } +} + +vec2 CTouchControls::CalculateScreenSize() const +{ + const float ScreenHeight = 400.0f * 3.0f; + const float ScreenWidth = ScreenHeight * Graphics()->ScreenAspect(); + return vec2(ScreenWidth, ScreenHeight); +} + +bool CTouchControls::ParseConfiguration(const void *pFileData, unsigned FileLength) +{ + json_settings JsonSettings{}; + char aError[256]; + json_value *pConfiguration = json_parse_ex(&JsonSettings, static_cast(pFileData), FileLength, aError); + + if(pConfiguration == nullptr) + { + log_error("touch_controls", "Failed to parse configuration (invalid json): '%s'", aError); + return false; + } + if(pConfiguration->type != json_object) + { + log_error("touch_controls", "Failed to parse configuration: root must be an object"); + json_value_free(pConfiguration); + return false; + } + + std::optional ParsedDirectTouchIngame = ParseDirectTouchIngameMode(&(*pConfiguration)["direct-touch-ingame"]); + if(!ParsedDirectTouchIngame.has_value()) + { + json_value_free(pConfiguration); + return false; + } + + std::optional ParsedDirectTouchSpectate = ParseDirectTouchSpectateMode(&(*pConfiguration)["direct-touch-spectate"]); + if(!ParsedDirectTouchSpectate.has_value()) + { + json_value_free(pConfiguration); + return false; + } + + const json_value &TouchButtons = (*pConfiguration)["touch-buttons"]; + if(TouchButtons.type != json_array) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'touch-buttons' must specify an array"); + json_value_free(pConfiguration); + return false; + } + + std::vector vParsedTouchButtons; + vParsedTouchButtons.reserve(TouchButtons.u.array.length); + for(unsigned ButtonIndex = 0; ButtonIndex < TouchButtons.u.array.length; ++ButtonIndex) + { + std::optional ParsedButton = ParseButton(&TouchButtons[ButtonIndex]); + if(!ParsedButton.has_value()) + { + log_error("touch_controls", "Failed to parse configuration: could not parse button at index '%d'", ButtonIndex); + json_value_free(pConfiguration); + return false; + } + + vParsedTouchButtons.push_back(std::move(ParsedButton.value())); + } + + // Parsing successful. Apply parsed configuration. + m_DirectTouchIngame = ParsedDirectTouchIngame.value(); + m_DirectTouchSpectate = ParsedDirectTouchSpectate.value(); + + m_pPrimaryJoystickTouchButtonBehavior = nullptr; + m_vTouchButtons = std::move(vParsedTouchButtons); + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.UpdatePointers(); + TouchButton.UpdateScreenFromUnitRect(); + TouchButton.UpdateBackgroundCorners(); + } + + json_value_free(pConfiguration); + + return true; +} + +std::optional CTouchControls::ParseDirectTouchIngameMode(const json_value *pModeValue) +{ + // TODO: Remove json_boolean backwards compatibility + const json_value &DirectTouchIngame = *pModeValue; + if(DirectTouchIngame.type != json_boolean && DirectTouchIngame.type != json_string) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'direct-touch-ingame' must specify a string"); + return {}; + } + if(DirectTouchIngame.type == json_boolean) + { + return DirectTouchIngame.u.boolean ? EDirectTouchIngameMode::ACTION : EDirectTouchIngameMode::DISABLED; + } + EDirectTouchIngameMode ParsedDirectTouchIngame = EDirectTouchIngameMode::NUM_STATES; + for(int CurrentMode = (int)EDirectTouchIngameMode::DISABLED; CurrentMode < (int)EDirectTouchIngameMode::NUM_STATES; ++CurrentMode) + { + if(str_comp(DirectTouchIngame.u.string.ptr, DIRECT_TOUCH_INGAME_MODE_NAMES[CurrentMode]) == 0) + { + ParsedDirectTouchIngame = (EDirectTouchIngameMode)CurrentMode; + break; + } + } + if(ParsedDirectTouchIngame == EDirectTouchIngameMode::NUM_STATES) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'direct-touch-ingame' specifies unknown value '%s'", DirectTouchIngame.u.string.ptr); + return {}; + } + return ParsedDirectTouchIngame; +} + +std::optional CTouchControls::ParseDirectTouchSpectateMode(const json_value *pModeValue) +{ + // TODO: Remove json_boolean backwards compatibility + const json_value &DirectTouchSpectate = *pModeValue; + if(DirectTouchSpectate.type != json_boolean && DirectTouchSpectate.type != json_string) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'direct-touch-spectate' must specify a string"); + return {}; + } + if(DirectTouchSpectate.type == json_boolean) + { + return DirectTouchSpectate.u.boolean ? EDirectTouchSpectateMode::AIM : EDirectTouchSpectateMode::DISABLED; + } + EDirectTouchSpectateMode ParsedDirectTouchSpectate = EDirectTouchSpectateMode::NUM_STATES; + for(int CurrentMode = (int)EDirectTouchSpectateMode::DISABLED; CurrentMode < (int)EDirectTouchSpectateMode::NUM_STATES; ++CurrentMode) + { + if(str_comp(DirectTouchSpectate.u.string.ptr, DIRECT_TOUCH_SPECTATE_MODE_NAMES[CurrentMode]) == 0) + { + ParsedDirectTouchSpectate = (EDirectTouchSpectateMode)CurrentMode; + break; + } + } + if(ParsedDirectTouchSpectate == EDirectTouchSpectateMode::NUM_STATES) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'direct-touch-spectate' specifies unknown value '%s'", DirectTouchSpectate.u.string.ptr); + return {}; + } + return ParsedDirectTouchSpectate; +} + +std::optional CTouchControls::ParseButton(const json_value *pButtonObject) +{ + const json_value &ButtonObject = *pButtonObject; + if(ButtonObject.type != json_object) + { + log_error("touch_controls", "Failed to parse touch button: must be an object"); + return {}; + } + + const auto &&ParsePositionSize = [&](const char *pAttribute, int &ParsedValue, int Min, int Max) { + const json_value &AttributeValue = ButtonObject[pAttribute]; + if(AttributeValue.type != json_integer || !in_range(AttributeValue.u.integer, Min, Max)) + { + log_error("touch_controls", "Failed to parse touch button: attribute '%s' must specify an integer between '%d' and '%d'", pAttribute, Min, Max); + return false; + } + ParsedValue = AttributeValue.u.integer; + return true; + }; + CUnitRect ParsedUnitRect; + if(!ParsePositionSize("w", ParsedUnitRect.m_W, BUTTON_SIZE_MINIMUM, BUTTON_SIZE_MAXIMUM) || + !ParsePositionSize("h", ParsedUnitRect.m_H, BUTTON_SIZE_MINIMUM, BUTTON_SIZE_MAXIMUM)) + { + return {}; + } + if(!ParsePositionSize("x", ParsedUnitRect.m_X, 0, BUTTON_SIZE_SCALE - ParsedUnitRect.m_W) || + !ParsePositionSize("y", ParsedUnitRect.m_Y, 0, BUTTON_SIZE_SCALE - ParsedUnitRect.m_H)) + { + return {}; + } + + const json_value &Shape = ButtonObject["shape"]; + if(Shape.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button: attribute 'shape' must specify a string"); + return {}; + } + EButtonShape ParsedShape = EButtonShape::NUM_SHAPES; + for(int CurrentShape = (int)EButtonShape::RECT; CurrentShape < (int)EButtonShape::NUM_SHAPES; ++CurrentShape) + { + if(str_comp(Shape.u.string.ptr, SHAPE_NAMES[CurrentShape]) == 0) + { + ParsedShape = (EButtonShape)CurrentShape; + break; + } + } + if(ParsedShape == EButtonShape::NUM_SHAPES) + { + log_error("touch_controls", "Failed to parse touch button: attribute 'shape' specifies unknown value '%s'", Shape.u.string.ptr); + return {}; + } + + const json_value &Visibilities = ButtonObject["visibilities"]; + if(Visibilities.type != json_array) + { + log_error("touch_controls", "Failed to parse touch button: attribute 'visibilities' must specify an array"); + return {}; + } + std::vector vParsedVisibilities; + for(unsigned VisibilityIndex = 0; VisibilityIndex < Visibilities.u.array.length; ++VisibilityIndex) + { + const json_value &Visibility = Visibilities[VisibilityIndex]; + if(Visibility.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button: attribute 'visibilities' does not specify string at index '%d'", VisibilityIndex); + return {}; + } + EButtonVisibility ParsedVisibility = EButtonVisibility::NUM_VISIBILITIES; + const bool ParsedParity = Visibility.u.string.ptr[0] != '-'; + const char *pVisibilityString = ParsedParity ? Visibility.u.string.ptr : &Visibility.u.string.ptr[1]; + for(int CurrentVisibility = (int)EButtonVisibility::INGAME; CurrentVisibility < (int)EButtonVisibility::NUM_VISIBILITIES; ++CurrentVisibility) + { + if(str_comp(pVisibilityString, m_aVisibilityFunctions[CurrentVisibility].m_pId) == 0) + { + ParsedVisibility = (EButtonVisibility)CurrentVisibility; + break; + } + } + if(ParsedVisibility == EButtonVisibility::NUM_VISIBILITIES) + { + log_error("touch_controls", "Failed to parse touch button: attribute 'visibilities' specifies unknown value '%s' at index '%d'", pVisibilityString, VisibilityIndex); + return {}; + } + const bool VisibilityAlreadyUsed = std::any_of(vParsedVisibilities.begin(), vParsedVisibilities.end(), [&](CButtonVisibility OtherParsedVisibility) { + return OtherParsedVisibility.m_Type == ParsedVisibility; + }); + if(VisibilityAlreadyUsed) + { + log_error("touch_controls", "Failed to parse touch button: attribute 'visibilities' specifies duplicate value '%s' at '%d'", pVisibilityString, VisibilityIndex); + return {}; + } + vParsedVisibilities.emplace_back(ParsedVisibility, ParsedParity); + } + + std::unique_ptr pParsedBehavior = ParseBehavior(&ButtonObject["behavior"]); + if(pParsedBehavior == nullptr) + { + log_error("touch_controls", "Failed to parse touch button: failed to parse attribute 'behavior' (see details above)"); + return {}; + } + + CTouchButton Button(this); + Button.m_UnitRect = ParsedUnitRect; + Button.m_Shape = ParsedShape; + Button.m_vVisibilities = std::move(vParsedVisibilities); + Button.m_pBehavior = std::move(pParsedBehavior); + return Button; +} + +std::unique_ptr CTouchControls::ParseBehavior(const json_value *pBehaviorObject) +{ + const json_value &BehaviorObject = *pBehaviorObject; + if(BehaviorObject.type != json_object) + { + log_error("touch_controls", "Failed to parse touch button behavior: must be an object"); + return nullptr; + } + + const json_value &BehaviorType = BehaviorObject["type"]; + if(BehaviorType.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior: attribute 'type' must specify a string"); + return nullptr; + } + + if(str_comp(BehaviorType.u.string.ptr, CPredefinedTouchButtonBehavior::BEHAVIOR_TYPE) == 0) + { + return ParsePredefinedBehavior(&BehaviorObject); + } + else if(str_comp(BehaviorType.u.string.ptr, CBindTouchButtonBehavior::BEHAVIOR_TYPE) == 0) + { + return ParseBindBehavior(&BehaviorObject); + } + else if(str_comp(BehaviorType.u.string.ptr, CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE) == 0) + { + return ParseBindToggleBehavior(&BehaviorObject); + } + else + { + log_error("touch_controls", "Failed to parse touch button behavior: attribute 'type' specifies unknown value '%s'", BehaviorType.u.string.ptr); + return nullptr; + } +} + +std::unique_ptr CTouchControls::ParsePredefinedBehavior(const json_value *pBehaviorObject) +{ + const json_value &BehaviorObject = *pBehaviorObject; + const json_value &PredefinedId = BehaviorObject["id"]; + if(PredefinedId.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'id' must specify a string", CPredefinedTouchButtonBehavior::BEHAVIOR_TYPE); + return nullptr; + } + + class CBehaviorFactory + { + public: + const char *m_pId; + std::function(const json_value *pBehaviorObject)> m_Factory; + }; + static const CBehaviorFactory BEHAVIOR_FACTORIES[] = { + {CIngameMenuTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CExtraMenuTouchButtonBehavior::BEHAVIOR_ID, [&](const json_value *pBehavior) { return ParseExtraMenuBehavior(pBehavior); }}, + {CEmoticonTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CSpectateTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CSwapActionTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CUseActionTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CJoystickActionTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CJoystickAimTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CJoystickFireTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}, + {CJoystickHookTouchButtonBehavior::BEHAVIOR_ID, [](const json_value *pBehavior) { return std::make_unique(); }}}; + for(const CBehaviorFactory &BehaviorFactory : BEHAVIOR_FACTORIES) + { + if(str_comp(PredefinedId.u.string.ptr, BehaviorFactory.m_pId) == 0) + { + return BehaviorFactory.m_Factory(&BehaviorObject); + } + } + + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'id' specifies unknown value '%s'", CPredefinedTouchButtonBehavior::BEHAVIOR_TYPE, PredefinedId.u.string.ptr); + return nullptr; +} + +std::unique_ptr CTouchControls::ParseExtraMenuBehavior(const json_value *pBehaviorObject) +{ + const json_value &BehaviorObject = *pBehaviorObject; + const json_value &MenuNumber = BehaviorObject["number"]; + // TODO: Remove json_none backwards compatibility + const int MaxNumber = (int)EButtonVisibility::EXTRA_MENU_5 - (int)EButtonVisibility::EXTRA_MENU_1 + 1; + if(MenuNumber.type != json_none && (MenuNumber.type != json_integer || !in_range(MenuNumber.u.integer, 1, MaxNumber))) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s' and ID '%s': attribute 'number' must specify an integer between '%d' and '%d'", + CPredefinedTouchButtonBehavior::BEHAVIOR_TYPE, CExtraMenuTouchButtonBehavior::BEHAVIOR_ID, 1, MaxNumber); + return nullptr; + } + int ParsedMenuNumber = MenuNumber.type == json_none ? 0 : (MenuNumber.u.integer - 1); + + return std::make_unique(ParsedMenuNumber); +} + +std::unique_ptr CTouchControls::ParseBindBehavior(const json_value *pBehaviorObject) +{ + const json_value &BehaviorObject = *pBehaviorObject; + const json_value &Label = BehaviorObject["label"]; + if(Label.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'label' must specify a string", CBindTouchButtonBehavior::BEHAVIOR_TYPE); + return nullptr; + } + + const json_value &LabelType = BehaviorObject["label-type"]; + if(LabelType.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'label-type' must specify a string", CBindTouchButtonBehavior::BEHAVIOR_TYPE); + return {}; + } + CButtonLabel::EType ParsedLabelType = CButtonLabel::EType::NUM_TYPES; + for(int CurrentType = (int)CButtonLabel::EType::PLAIN; CurrentType < (int)CButtonLabel::EType::NUM_TYPES; ++CurrentType) + { + if(str_comp(LabelType.u.string.ptr, LABEL_TYPE_NAMES[CurrentType]) == 0) + { + ParsedLabelType = (CButtonLabel::EType)CurrentType; + break; + } + } + if(ParsedLabelType == CButtonLabel::EType::NUM_TYPES) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'label-type' specifies unknown value '%s'", CBindTouchButtonBehavior::BEHAVIOR_TYPE, LabelType.u.string.ptr); + return {}; + } + + const json_value &Command = BehaviorObject["command"]; + if(Command.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'command' must specify a string", CBindTouchButtonBehavior::BEHAVIOR_TYPE); + return nullptr; + } + + return std::make_unique(Label.u.string.ptr, ParsedLabelType, Command.u.string.ptr); +} + +std::unique_ptr CTouchControls::ParseBindToggleBehavior(const json_value *pBehaviorObject) +{ + const json_value &CommandsObject = (*pBehaviorObject)["commands"]; + if(CommandsObject.type != json_array || CommandsObject.u.array.length < 2) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': attribute 'commands' must specify an array with at least 2 entries", CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE); + return {}; + } + + std::vector vCommands; + vCommands.reserve(CommandsObject.u.array.length); + for(unsigned CommandIndex = 0; CommandIndex < CommandsObject.u.array.length; ++CommandIndex) + { + const json_value &CommandObject = CommandsObject[CommandIndex]; + if(CommandObject.type != json_object) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': failed to parse command at index '%d': attribute 'commands' must specify an array of objects", CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE, CommandIndex); + return nullptr; + } + + const json_value &Label = CommandObject["label"]; + if(Label.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': failed to parse command at index '%d': attribute 'label' must specify a string", CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE, CommandIndex); + return nullptr; + } + + const json_value &LabelType = CommandObject["label-type"]; + if(LabelType.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': failed to parse command at index '%d': attribute 'label-type' must specify a string", CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE, CommandIndex); + return {}; + } + CButtonLabel::EType ParsedLabelType = CButtonLabel::EType::NUM_TYPES; + for(int CurrentType = (int)CButtonLabel::EType::PLAIN; CurrentType < (int)CButtonLabel::EType::NUM_TYPES; ++CurrentType) + { + if(str_comp(LabelType.u.string.ptr, LABEL_TYPE_NAMES[CurrentType]) == 0) + { + ParsedLabelType = (CButtonLabel::EType)CurrentType; + break; + } + } + if(ParsedLabelType == CButtonLabel::EType::NUM_TYPES) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': failed to parse command at index '%d': attribute 'label-type' specifies unknown value '%s'", CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE, CommandIndex, LabelType.u.string.ptr); + return {}; + } + + const json_value &Command = CommandObject["command"]; + if(Command.type != json_string) + { + log_error("touch_controls", "Failed to parse touch button behavior of type '%s': failed to parse command at index '%d': attribute 'command' must specify a string", CBindToggleTouchButtonBehavior::BEHAVIOR_TYPE, CommandIndex); + return nullptr; + } + vCommands.emplace_back(Label.u.string.ptr, ParsedLabelType, Command.u.string.ptr); + } + return std::make_unique(std::move(vCommands)); +} + +void CTouchControls::WriteConfiguration(CJsonWriter *pWriter) +{ + pWriter->BeginObject(); + + pWriter->WriteAttribute("direct-touch-ingame"); + pWriter->WriteStrValue(DIRECT_TOUCH_INGAME_MODE_NAMES[(int)m_DirectTouchIngame]); + + pWriter->WriteAttribute("direct-touch-spectate"); + pWriter->WriteStrValue(DIRECT_TOUCH_SPECTATE_MODE_NAMES[(int)m_DirectTouchSpectate]); + + pWriter->WriteAttribute("touch-buttons"); + pWriter->BeginArray(); + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.WriteToConfiguration(pWriter); + } + pWriter->EndArray(); + + pWriter->EndObject(); +} diff --git a/src/game/client/components/touch_controls.h b/src/game/client/components/touch_controls.h new file mode 100644 index 00000000000..e13c4a35b87 --- /dev/null +++ b/src/game/client/components/touch_controls.h @@ -0,0 +1,548 @@ +#ifndef GAME_CLIENT_COMPONENTS_TOUCH_CONTROLS_H +#define GAME_CLIENT_COMPONENTS_TOUCH_CONTROLS_H + +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +class CJsonWriter; +typedef struct _json_value json_value; + +class CTouchControls : public CComponent +{ +public: + enum class EDirectTouchIngameMode + { + DISABLED, + ACTION, + AIM, + FIRE, + HOOK, + NUM_STATES + }; + enum class EDirectTouchSpectateMode + { + DISABLED, + AIM, + NUM_STATES + }; + + int Sizeof() const override { return sizeof(*this); } + void OnInit() override; + void OnReset() override; + void OnWindowResize() override; + bool OnTouchState(const std::vector &vTouchFingerStates) override; + void OnRender() override; + + bool LoadConfigurationFromFile(int StorageType); + bool LoadConfigurationFromClipboard(); + bool SaveConfigurationToFile(); + void SaveConfigurationToClipboard(); + + EDirectTouchIngameMode DirectTouchIngame() const { return m_DirectTouchIngame; } + void SetDirectTouchIngame(EDirectTouchIngameMode DirectTouchIngame) + { + m_DirectTouchIngame = DirectTouchIngame; + m_EditingChanges = true; + } + EDirectTouchSpectateMode DirectTouchSpectate() const { return m_DirectTouchSpectate; } + void SetDirectTouchSpectate(EDirectTouchSpectateMode DirectTouchSpectate) + { + m_DirectTouchSpectate = DirectTouchSpectate; + m_EditingChanges = true; + } + bool IsEditingActive() const { return m_EditingActive; } + void SetEditingActive(bool EditingActive) { m_EditingActive = EditingActive; } + bool HasEditingChanges() const { return m_EditingChanges; } + void SetEditingChanges(bool EditingChanges) { m_EditingChanges = EditingChanges; } + +private: + static constexpr const char *const DIRECT_TOUCH_INGAME_MODE_NAMES[(int)EDirectTouchIngameMode::NUM_STATES] = {"disabled", "action", "aim", "fire", "hook"}; + static constexpr const char *const DIRECT_TOUCH_SPECTATE_MODE_NAMES[(int)EDirectTouchSpectateMode::NUM_STATES] = {"disabled", "aim"}; + + enum class EButtonShape + { + RECT, + CIRCLE, + NUM_SHAPES + }; + + static constexpr const char *const SHAPE_NAMES[(int)EButtonShape::NUM_SHAPES] = {"rect", "circle"}; + + enum class EButtonVisibility + { + INGAME, + ZOOM_ALLOWED, + VOTE_ACTIVE, + DUMMY_ALLOWED, + DUMMY_CONNECTED, + RCON_AUTHED, + DEMO_PLAYER, + EXTRA_MENU_1, + EXTRA_MENU_2, + EXTRA_MENU_3, + EXTRA_MENU_4, + EXTRA_MENU_5, + NUM_VISIBILITIES + }; + + class CButtonVisibility + { + public: + EButtonVisibility m_Type; + bool m_Parity; + + CButtonVisibility(EButtonVisibility Type, bool Parity) : + m_Type(Type), m_Parity(Parity) {} + }; + + class CButtonVisibilityData + { + public: + const char *m_pId; + std::function m_Function; + }; + + CButtonVisibilityData m_aVisibilityFunctions[(int)EButtonVisibility::NUM_VISIBILITIES]; + + enum + { + ACTION_AIM, + ACTION_FIRE, + ACTION_HOOK, + NUM_ACTIONS + }; + + class CButtonLabel + { + public: + enum class EType + { + /** + * Label is used as is. + */ + PLAIN, + /** + * Label is localized. Only usable for default button labels for which there must be + * corresponding `Localizable`-calls in code and string in the translation files. + */ + LOCALIZED, + /** + * Icon font is used for the label. + */ + ICON, + /** + * Number of label types. + */ + NUM_TYPES + }; + + EType m_Type; + const char *m_pLabel; + }; + + static constexpr const char *const LABEL_TYPE_NAMES[(int)CButtonLabel::EType::NUM_TYPES] = {"plain", "localized", "icon"}; + + class CUnitRect + { + public: + int m_X; + int m_Y; + int m_W; + int m_H; + }; + + class CTouchButtonBehavior; + + class CTouchButton + { + public: + CTouchButton(CTouchControls *pTouchControls); + CTouchButton(CTouchButton &&Other) noexcept; + CTouchButton(const CTouchButton &Other) = delete; + + CTouchButton &operator=(const CTouchButton &Other) = delete; + CTouchButton &operator=(CTouchButton &&Other) noexcept; + + CTouchControls *m_pTouchControls; + + CUnitRect m_UnitRect; + CUIRect m_ScreenRect; + + EButtonShape m_Shape; + int m_BackgroundCorners; // only used with EButtonShape::RECT + + std::vector m_vVisibilities; + std::unique_ptr m_pBehavior; + + bool m_VisibilityCached; + std::chrono::nanoseconds m_VisibilityStartTime; + + void UpdatePointers(); + void UpdateScreenFromUnitRect(); + void UpdateBackgroundCorners(); + + vec2 ClampTouchPosition(vec2 TouchPosition) const; + bool IsInside(vec2 TouchPosition) const; + void UpdateVisibility(); + bool IsVisible() const; + void Render() const; + void WriteToConfiguration(CJsonWriter *pWriter); + }; + + class CTouchButtonBehavior + { + public: + CTouchButton *m_pTouchButton; + CTouchControls *m_pTouchControls; + + bool m_Active; // variables below must only be used when active + IInput::CTouchFinger m_Finger; + vec2 m_ActivePosition; + vec2 m_AccumulatedDelta; + std::chrono::nanoseconds m_ActivationStartTime; + + virtual ~CTouchButtonBehavior() = default; + virtual void Init(CTouchButton *pTouchButton); + + void Reset(); + void SetActive(const IInput::CTouchFingerState &FingerState); + void SetInactive(); + bool IsActive() const; + bool IsActive(const IInput::CTouchFinger &Finger) const; + + virtual CButtonLabel GetLabel() const = 0; + virtual void OnActivate() {} + virtual void OnDeactivate() {} + virtual void OnUpdate() {} + virtual void WriteToConfiguration(CJsonWriter *pWriter) = 0; + }; + + /** + * Abstract class for predefined behaviors. + * + * Subclasses must implemented the concrete behavior and provide the label. + */ + class CPredefinedTouchButtonBehavior : public CTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_TYPE = "predefined"; + + CPredefinedTouchButtonBehavior(const char *pId) : + m_pId(pId) {} + + /** + * Implements the serialization for predefined behaviors. Subclasses + * may override this, but they should call the parent function first. + */ + void WriteToConfiguration(CJsonWriter *pWriter) override; + + private: + const char *m_pId; + }; + + class CIngameMenuTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "ingame-menu"; + + CIngameMenuTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + }; + + class CExtraMenuTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "extra-menu"; + + CExtraMenuTouchButtonBehavior(int Number); + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + void WriteToConfiguration(CJsonWriter *pWriter) override; + + private: + int m_Number; + char m_aLabel[16]; + }; + + class CEmoticonTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "emoticon"; + + CEmoticonTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + }; + + class CSpectateTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "spectate"; + + CSpectateTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + }; + + class CSwapActionTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "swap-action"; + + CSwapActionTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void OnDeactivate() override; + + private: + int m_ActiveAction = NUM_ACTIONS; + }; + + class CUseActionTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "use-action"; + + CUseActionTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void OnDeactivate() override; + + private: + int m_ActiveAction = NUM_ACTIONS; + }; + + class CJoystickTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + CJoystickTouchButtonBehavior(const char *pId) : + CPredefinedTouchButtonBehavior(pId) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void OnDeactivate() override; + void OnUpdate() override; + int ActiveAction() const { return m_ActiveAction; } + virtual int SelectedAction() const = 0; + + private: + int m_ActiveAction = NUM_ACTIONS; + }; + + class CJoystickActionTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "joystick-action"; + + CJoystickActionTouchButtonBehavior() : + CJoystickTouchButtonBehavior(BEHAVIOR_ID) {} + + void Init(CTouchButton *pTouchButton) override; + int SelectedAction() const override; + }; + + class CJoystickAimTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "joystick-aim"; + + CJoystickAimTouchButtonBehavior() : + CJoystickTouchButtonBehavior(BEHAVIOR_ID) {} + + int SelectedAction() const override; + }; + + class CJoystickFireTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "joystick-fire"; + + CJoystickFireTouchButtonBehavior() : + CJoystickTouchButtonBehavior(BEHAVIOR_ID) {} + + int SelectedAction() const override; + }; + + class CJoystickHookTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_ID = "joystick-hook"; + + CJoystickHookTouchButtonBehavior() : + CJoystickTouchButtonBehavior(BEHAVIOR_ID) {} + + int SelectedAction() const override; + }; + + /** + * Generic behavior implementation that executes a console command like a bind. + */ + class CBindTouchButtonBehavior : public CTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_TYPE = "bind"; + + CBindTouchButtonBehavior(const char *pLabel, CButtonLabel::EType LabelType, const char *pCommand) : + m_Label(pLabel), + m_LabelType(LabelType), + m_Command(pCommand) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void OnDeactivate() override; + void OnUpdate() override; + void WriteToConfiguration(CJsonWriter *pWriter) override; + + private: + std::string m_Label; + CButtonLabel::EType m_LabelType; + std::string m_Command; + + bool m_Repeating = false; + std::chrono::nanoseconds m_LastUpdateTime; + std::chrono::nanoseconds m_AccumulatedRepeatingTime; + }; + + /** + * Generic behavior implementation that switches between executing one of two or more console commands. + */ + class CBindToggleTouchButtonBehavior : public CTouchButtonBehavior + { + public: + static constexpr const char *const BEHAVIOR_TYPE = "bind-toggle"; + + class CCommand + { + public: + std::string m_Label; + CButtonLabel::EType m_LabelType; + std::string m_Command; + + CCommand(const char *pLabel, CButtonLabel::EType LabelType, const char *pCommand) : + m_Label(pLabel), + m_LabelType(LabelType), + m_Command(pCommand) {} + }; + + CBindToggleTouchButtonBehavior(std::vector &&vCommands) : + m_vCommands(std::move(vCommands)) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void WriteToConfiguration(CJsonWriter *pWriter) override; + + private: + std::vector m_vCommands; + size_t m_ActiveCommandIndex = 0; + }; + + /** + * Mode of direct touch input while ingame. + * + * Saved to the touch controls configuration. + */ + EDirectTouchIngameMode m_DirectTouchIngame = EDirectTouchIngameMode::ACTION; + + /** + * Mode of direct touch input while spectating. + * + * Saved to the touch controls configuration. + */ + EDirectTouchSpectateMode m_DirectTouchSpectate = EDirectTouchSpectateMode::AIM; + + /** + * All touch buttons. + * + * Saved to the touch controls configuration. + */ + std::vector m_vTouchButtons; + + /** + * The activation states of the different extra menus which are toggle by the extra menu button behavior. + */ + bool m_aExtraMenuActive[(int)EButtonVisibility::EXTRA_MENU_5 - (int)EButtonVisibility::EXTRA_MENU_1 + 1] = {false}; + + /** + * The currently selected action which is used for direct touch and is changed and used by some button behaviors. + */ + int m_ActionSelected = ACTION_FIRE; + + /** + * The action that was last activated with direct touch input, which will determine the finger that will + * be used to update the mouse position from direct touch input. + */ + int m_DirectTouchLastAction = ACTION_FIRE; + + class CActionState + { + public: + bool m_Active = false; + IInput::CTouchFinger m_Finger; + }; + + /** + * The states of the different actions for direct touch input. + */ + CActionState m_aDirectTouchActionStates[NUM_ACTIONS]; + + /** + * A pointer to the action joystick, if any exists in the current configuration, or `nullptr` if none. + * This is set by @link CJoystickActionTouchButtonBehavior @endlink when it is initialized and always + * cleared before loading a new touch button configuration. + */ + CJoystickActionTouchButtonBehavior *m_pPrimaryJoystickTouchButtonBehavior; + + /** + * Whether editing mode is currently active. + */ + bool m_EditingActive = false; + + /** + * Whether there are changes to the current configuration in editing mode. + */ + bool m_EditingChanges = false; + + void InitVisibilityFunctions(); + int NextActiveAction(int Action) const; + int NextDirectTouchAction() const; + void UpdateButtons(const std::vector &vTouchFingerStates); + void ResetButtons(); + void RenderButtons(); + vec2 CalculateScreenSize() const; + + bool ParseConfiguration(const void *pFileData, unsigned FileLength); + std::optional ParseDirectTouchIngameMode(const json_value *pModeValue); + std::optional ParseDirectTouchSpectateMode(const json_value *pModeValue); + std::optional ParseButton(const json_value *pButtonObject); + std::unique_ptr ParseBehavior(const json_value *pBehaviorObject); + std::unique_ptr ParsePredefinedBehavior(const json_value *pBehaviorObject); + std::unique_ptr ParseExtraMenuBehavior(const json_value *pBehaviorObject); + std::unique_ptr ParseBindBehavior(const json_value *pBehaviorObject); + std::unique_ptr ParseBindToggleBehavior(const json_value *pBehaviorObject); + void WriteConfiguration(CJsonWriter *pWriter); +}; + +#endif diff --git a/src/game/client/gameclient.cpp b/src/game/client/gameclient.cpp index ff733b30db8..bb088c66710 100644 --- a/src/game/client/gameclient.cpp +++ b/src/game/client/gameclient.cpp @@ -146,6 +146,7 @@ void CGameClient::OnConsoleInit() &m_Chat, &m_Broadcast, &m_DebugHud, + &m_TouchControls, &m_Scoreboard, &m_Statboard, &m_Motd, @@ -165,6 +166,7 @@ void CGameClient::OnConsoleInit() &m_Emoticon, &m_Menus, &m_Controls, + &m_TouchControls, &m_Binds}); // add basic console commands diff --git a/src/game/client/gameclient.h b/src/game/client/gameclient.h index 665049e947b..f5a1e2eefab 100644 --- a/src/game/client/gameclient.h +++ b/src/game/client/gameclient.h @@ -58,6 +58,7 @@ #include "components/spectator.h" #include "components/statboard.h" #include "components/tooltips.h" +#include "components/touch_controls.h" #include "components/voting.h" class CGameInfo @@ -146,6 +147,7 @@ class CGameClient : public IGameClient CSounds m_Sounds; CEmoticon m_Emoticon; CDamageInd m_DamageInd; + CTouchControls m_TouchControls; CVoting m_Voting; CSpectator m_Spectator;