diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d1ebde9c..61fb569af 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1845,6 +1845,7 @@ set(EXPECTED_DATA themes/winter.png themes/winter_day.map themes/winter_night.map + touch_controls.json wordlist.txt ) @@ -2399,6 +2400,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 000000000..11c90c7fc --- /dev/null +++ b/data/touch_controls.json @@ -0,0 +1,305 @@ +{ + "direct-touch-ingame": true, + "direct-touch-spectate": true, + "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" + } + }, + { + "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" + ], + "behavior": { + "type": "bind", + "label": "Chat", + "label-type": "localized", + "command": "chat all" + } + }, + { + "x": 316667, + "y": 133333, + "w": 83333, + "h": 66667, + "shape": "rect", + "visibilities": [ + "extra-menu" + ], + "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" + ], + "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" + ], + "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": [ + ], + "behavior": { + "type": "predefined", + "id": "joystick-action" + } + } + ] +} diff --git a/src/engine/client/input.cpp b/src/engine/client/input.cpp index f94894eca..29ee33982 100644 --- a/src/engine/client/input.cpp +++ b/src/engine/client/input.cpp @@ -300,6 +300,14 @@ const std::vector &CInput::TouchFingerStates() const return m_vTouchFingerStates; } +void CInput::ClearTouchDeltas() +{ + for(CTouchFingerState &TouchFingerState : m_vTouchFingerStates) + { + TouchFingerState.m_Delta = vec2(0.0f, 0.0f); + } +} + std::string CInput::GetClipboardText() { char *pClipboardText = SDL_GetClipboardText(); @@ -347,10 +355,7 @@ void CInput::Clear() mem_zero(m_aInputState, sizeof(m_aInputState)); mem_zero(m_aInputCount, sizeof(m_aInputCount)); m_vInputEvents.clear(); - for(CTouchFingerState &TouchFingerState : m_vTouchFingerStates) - { - TouchFingerState.m_Delta = vec2(0.0f, 0.0f); - } + ClearTouchDeltas(); } float CInput::GetUpdateTime() const diff --git a/src/engine/client/input.h b/src/engine/client/input.h index 70127785f..4194bd2a3 100644 --- a/src/engine/client/input.h +++ b/src/engine/client/input.h @@ -144,6 +144,7 @@ public: bool NativeMousePressed(int Index) const override; const std::vector &TouchFingerStates() const override; + void ClearTouchDeltas() override; std::string GetClipboardText() override; void SetClipboardText(const char *pText) override; diff --git a/src/engine/input.h b/src/engine/input.h index 9aa873358..edfc14596 100644 --- a/src/engine/input.h +++ b/src/engine/input.h @@ -137,6 +137,12 @@ public: * @return vector of all touch finger states */ virtual const std::vector &TouchFingerStates() const = 0; + /** + * Must be called after the touch finger states have been used during the client update to ensure that + * touch deltas are only accumulated until the next update. If the touch states are only used during + * rendering, i.e. for user interfaces, then this is called automatically by calling @link Clear @endlink. + */ + virtual void ClearTouchDeltas() = 0; // clipboard virtual std::string GetClipboardText() = 0; diff --git a/src/engine/shared/config_variables.h b/src/engine/shared/config_variables.h index 02849f17c..d61afc6e8 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/component.h b/src/game/client/component.h index 639c8b140..9d14615a0 100644 --- a/src/game/client/component.h +++ b/src/game/client/component.h @@ -212,6 +212,14 @@ public: * @param Event The input event. */ virtual bool OnInput(const IInput::CEvent &Event) { return false; } + /** + * Called with all current touch finger states. + * + * @param vTouchFingerStates The touch finger states to be handled. + * + * @return `true` if the component used the touch events, `false` otherwise + */ + virtual bool OnTouchState(const std::vector &vTouchFingerStates) { return false; } }; #endif diff --git a/src/game/client/components/controls.h b/src/game/client/components/controls.h index ce6bc664d..11eecdde0 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 23eb73480..709816ec4 100644 --- a/src/game/client/components/menus.h +++ b/src/game/client/components/menus.h @@ -474,8 +474,12 @@ protected: // found in menus_ingame.cpp STextContainerIndex m_MotdTextContainerIndex; void RenderGame(CUIRect MainView); + void RenderTouchControlsEditor(CUIRect MainView); void PopupConfirmDisconnect(); void PopupConfirmDisconnectDummy(); + void PopupConfirmDiscardTouchControlsChanged(); + void PopupConfirmResetTouchControls(); + void PopupConfirmImportTouchControlsClipboard(); void RenderPlayers(CUIRect MainView); void RenderServerInfo(CUIRect MainView); void RenderServerInfoMotd(CUIRect Motd); @@ -637,7 +641,6 @@ protected: static CUi::EPopupMenuFunctionResult PopupMapPicker(void *pContext, CUIRect View, bool Active); void SetNeedSendInfo(); - void SetActive(bool Active); void UpdateColors(); IGraphics::CTextureHandle m_TextureBlob; @@ -657,6 +660,8 @@ public: 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 e1f3e2b53..ce9b3ccdd 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,147 @@ 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 - 195.0f) / 2.0f, &TouchControlsEditor); + RenderTouchControlsEditor(TouchControlsEditor); + } + } +} + +void CMenus::RenderTouchControlsEditor(CUIRect MainView) +{ + CUIRect Button, Row; + MainView.Draw(ms_ColorTabbarActive, IGraphics::CORNER_ALL, 10.0f); + MainView.Margin(10.0f, &MainView); + + MainView.HSplitTop(25.0f, &Button, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + Ui()->DoLabel(&Button, 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::PopupConfirmDiscardTouchControlsChanged); + } + + 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(5.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, &Button, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + static char s_DirectTouchIngameButton; + if(DoButton_CheckBox(&s_DirectTouchIngameButton, Localize("Direct touch input while ingame"), GameClient()->m_TouchControls.IsDirectTouchIngame(), &Button)) + { + GameClient()->m_TouchControls.SetDirectTouchIngame(!GameClient()->m_TouchControls.IsDirectTouchIngame()); + } + + MainView.HSplitTop(25.0f, &Button, &MainView); + MainView.HSplitTop(5.0f, nullptr, &MainView); + static char s_DirectTouchSpectateButton; + if(DoButton_CheckBox(&s_DirectTouchSpectateButton, Localize("Direct touch input while spectate"), GameClient()->m_TouchControls.IsDirectTouchSpectate(), &Button)) + { + GameClient()->m_TouchControls.SetDirectTouchSpectate(!GameClient()->m_TouchControls.IsDirectTouchSpectate()); + } } void CMenus::PopupConfirmDisconnect() @@ -227,6 +367,57 @@ void CMenus::PopupConfirmDisconnectDummy() SetActive(false); } +void CMenus::PopupConfirmDiscardTouchControlsChanged() +{ + 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 = 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; @@ -1292,6 +1483,9 @@ void CMenus::RenderGhost(CUIRect MainView) void CMenus::RenderIngameHint() { + 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 000000000..003df9fbb --- /dev/null +++ b/src/game/client/components/touch_controls.cpp @@ -0,0 +1,1234 @@ +#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: Support multiple (3 or 5) independent extra menus with separate states, directly supporting nesting of buttons in menus +// TODO: Add "color" property for touch buttons? + +static constexpr const char *ACTION_NAMES[] = {Localizable("Fire"), Localizable("Hook")}; +static constexpr const char *ACTION_SWAP_NAMES[] = {Localizable("Active: Fire"), Localizable("Active: Hook")}; +static constexpr const char *ACTION_COMMANDS[] = {"+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 *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) +{ +} + +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)) +{ + 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); + 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::UpdateUnitFromScreenRect() +{ + const vec2 ScreenSize = m_pTouchControls->CalculateScreenSize(); + m_UnitRect.m_X = m_ScreenRect.x * BUTTON_SIZE_SCALE / ScreenSize.x; + m_UnitRect.m_Y = m_ScreenRect.y * BUTTON_SIZE_SCALE / ScreenSize.y; + m_UnitRect.m_W = m_ScreenRect.w * BUTTON_SIZE_SCALE / ScreenSize.x; + m_UnitRect.m_H = m_ScreenRect.h * BUTTON_SIZE_SCALE / ScreenSize.y; +} + +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; + 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 Radius = minimum(m_ScreenRect.w, m_ScreenRect.h) / 2.0f; + if(distance(TouchPosition, Center) > Radius) + { + TouchPosition = normalize(TouchPosition - Center) * Radius + 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; + } +} + +bool CTouchControls::CTouchButton::IsVisible() const +{ + return 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; + }); +} + +// 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(); + + UpdateUnitFromScreenRect(); + pWriter->WriteAttribute("x"); + pWriter->WriteIntValue(round_to_int(m_UnitRect.m_X)); + pWriter->WriteAttribute("y"); + pWriter->WriteIntValue(round_to_int(m_UnitRect.m_Y)); + pWriter->WriteAttribute("w"); + pWriter->WriteIntValue(round_to_int(m_UnitRect.m_W)); + pWriter->WriteAttribute("h"); + pWriter->WriteIntValue(round_to_int(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"); + m_pBehavior->WriteToConfiguration(pWriter); + + 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(); + } +} + +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->BeginObject(); + + pWriter->WriteAttribute("type"); + pWriter->WriteStrValue(BEHAVIOR_TYPE); + + pWriter->WriteAttribute("id"); + pWriter->WriteStrValue(m_pId); + + pWriter->EndObject(); +} + +// Menu button: +// - Short press: show/hide additional buttons +// - Long press: open ingame menu +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, "\xEF\x83\x89"}; + } +} + +void CTouchControls::CExtraMenuTouchButtonBehavior::OnDeactivate() +{ + if(time_get_nanoseconds() - m_ActivationStartTime >= LONG_TOUCH_DURATION) + { + m_pTouchControls->GameClient()->m_Menus.SetActive(true); + } + else + { + m_pTouchControls->m_ExtraMenuActive = !m_pTouchControls->m_ExtraMenuActive; + } +} + +// 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->m_pPrimaryJoystickTouchButtonBehavior->ActiveAction() + 1) % NUM_ACTIONS]}; + } + 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->m_pPrimaryJoystickTouchButtonBehavior->ActiveAction() + 1) % NUM_ACTIONS; + m_pTouchControls->Console()->ExecuteLineStroked(1, ACTION_COMMANDS[m_ActiveAction]); + } + else + { + m_pTouchControls->m_ActionSelected = (m_pTouchControls->m_ActionSelected + 1) % NUM_ACTIONS; + } +} + +void CTouchControls::CSwapActionTouchButtonBehavior::OnDeactivate() +{ + if(m_ActiveAction != NUM_ACTIONS) + { + m_pTouchControls->Console()->ExecuteLineStroked(0, ACTION_COMMANDS[m_ActiveAction]); + m_ActiveAction = NUM_ACTIONS; + } +} + +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; +} + +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(); + m_pTouchControls->Console()->ExecuteLineStroked(1, ACTION_COMMANDS[m_ActiveAction]); +} + +void CTouchControls::CJoystickTouchButtonBehavior::OnDeactivate() +{ + 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 + { + Controls.m_aMousePos[g_Config.m_ClDummy] = (m_ActivePosition - vec2(0.5f, 0.5f)) * Controls.GetMaxMouseDistance(); + } +} + +CTouchControls::CJoystickActionTouchButtonBehavior::~CJoystickActionTouchButtonBehavior() +{ + if(m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior == this) + { + m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior = nullptr; + } +} + +void CTouchControls::CJoystickActionTouchButtonBehavior::Init(CTouchButton *pTouchButton) +{ + CPredefinedTouchButtonBehavior::Init(pTouchButton); + m_pTouchControls->m_pPrimaryJoystickTouchButtonBehavior = this; +} + +int CTouchControls::CJoystickActionTouchButtonBehavior::SelectedAction() const +{ + return m_pTouchControls->m_ActionSelected; +} + +int CTouchControls::CJoystickFireTouchButtonBehavior::SelectedAction() const +{ + return ACTION_FIRE; +} + +int CTouchControls::CJoystickHookTouchButtonBehavior::SelectedAction() const +{ + return ACTION_HOOK; +} + +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->BeginObject(); + + 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()); + + pWriter->EndObject(); +} + +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::EXTRA_MENU].m_pId = "extra-menu"; + m_aVisibilityFunctions[(int)EButtonVisibility::EXTRA_MENU].m_Function = [&]() { + return m_ExtraMenuActive; + }; + 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(); + }; +} + +void CTouchControls::UpdateButtons(const std::vector &vTouchFingerStates) +{ + const bool DirectTouchEnabled = m_pClient->m_Snap.m_SpecInfo.m_Active ? m_DirectTouchSpectate : m_DirectTouchIngame; + 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_FIRE; Action < NUM_ACTIONS; ++Action) + { + if(!m_aActionStates[Action].m_Active) + { + continue; + } + + const auto ActiveFinger = std::find_if(vRemainingTouchFingerStates.begin(), vRemainingTouchFingerStates.end(), [&](const IInput::CTouchFingerState &TouchFingerState) { + return TouchFingerState.m_Finger == m_aActionStates[Action].m_Finger; + }); + if(ActiveFinger == vRemainingTouchFingerStates.end() || !DirectTouchEnabled) + { + m_aActionStates[Action].m_Active = false; + Console()->ExecuteLineStroked(0, ACTION_COMMANDS[Action]); + } + else + { + if(Action == m_ActionLastActivated) + { + 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. + 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.IsInside(TouchFingerState.m_Position * ScreenSize); + }); + if(FingerInsideButton == vRemainingTouchFingerStates.end()) + { + continue; + } + auto OtherTouchButton = std::find_if(m_vTouchButtons.begin(), m_vTouchButtons.end(), [&](const CTouchButton &Button) { + return Button.m_pBehavior->IsActive(FingerInsideButton->m_Finger); + }); + if(OtherTouchButton != m_vTouchButtons.end()) + { + OtherTouchButton->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; + }); + if(ActiveFinger == vRemainingTouchFingerStates.end()) + { + continue; + } + 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(DirectTouchEnabled && !vRemainingTouchFingerStates.empty() && !m_aActionStates[m_ActionSelected].m_Active) + { + GotDirectFingerState = true; + DirectFingerState = vRemainingTouchFingerStates[0]; + vRemainingTouchFingerStates.erase(vRemainingTouchFingerStates.begin()); + m_aActionStates[m_ActionSelected].m_Active = true; + m_aActionStates[m_ActionSelected].m_Finger = DirectFingerState.m_Finger; + m_ActionLastActivated = m_ActionSelected; + ActivateAction = m_ActionSelected; + } + + // Update mouse position based on the finger responsible for the last active action. + if(GotDirectFingerState) + { + vec2 WorldScreenSize; + RenderTools()->CalcScreenParams(Graphics()->ScreenAspect(), m_pClient->m_Snap.m_SpecInfo.m_Active ? m_pClient->m_Camera.m_Zoom : 1.0f, &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 != NUM_ACTIONS) + { + Console()->ExecuteLineStroked(1, ACTION_COMMANDS[m_ActionSelected]); + } +} + +void CTouchControls::RenderButtons() +{ + for(CTouchButton &TouchButton : m_vTouchButtons) + { + 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); +} + +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()> m_Factory; + }; + static const CBehaviorFactory BEHAVIOR_FACTORIES[] = { + {CExtraMenuTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CEmoticonTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CSpectateTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CSwapActionTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CUseActionTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CJoystickActionTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CJoystickFireTouchButtonBehavior::BEHAVIOR_ID, []() { return std::make_unique(); }}, + {CJoystickHookTouchButtonBehavior::BEHAVIOR_ID, []() { 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(); + } + } + + 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::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::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 + { + log_error("touch_controls", "Failed to parse touch button behavior: attribute 'type' specifies unknown value '%s'", BehaviorType.u.string.ptr); + return nullptr; + } +} + +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; +} + +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: '%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; + } + + const json_value &DirectTouchIngame = (*pConfiguration)["direct-touch-ingame"]; + if(DirectTouchIngame.type != json_boolean) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'direct-touch-ingame' must specify a boolean"); + json_value_free(pConfiguration); + return false; + } + + const json_value &DirectTouchSpectate = (*pConfiguration)["direct-touch-spectate"]; + if(DirectTouchSpectate.type != json_boolean) + { + log_error("touch_controls", "Failed to parse configuration: attribute 'direct-touch-spectate' must specify a boolean"); + 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())); + } + + m_DirectTouchIngame = DirectTouchIngame.u.boolean; + m_DirectTouchSpectate = DirectTouchSpectate.u.boolean; + + m_vTouchButtons = std::move(vParsedTouchButtons); + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.UpdatePointers(); + TouchButton.UpdateScreenFromUnitRect(); + TouchButton.UpdateBackgroundCorners(); + } + + json_value_free(pConfiguration); + + return true; +} + +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()); +} + +void CTouchControls::WriteConfiguration(CJsonWriter *pWriter) +{ + pWriter->BeginObject(); + + pWriter->WriteAttribute("direct-touch-ingame"); + pWriter->WriteBoolValue(m_DirectTouchIngame); + + pWriter->WriteAttribute("direct-touch-spectate"); + pWriter->WriteBoolValue(m_DirectTouchSpectate); + + pWriter->WriteAttribute("touch-buttons"); + pWriter->BeginArray(); + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.WriteToConfiguration(pWriter); + } + pWriter->EndArray(); + + pWriter->EndObject(); +} + +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::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() +{ + for(CTouchButton &TouchButton : m_vTouchButtons) + { + TouchButton.m_pBehavior->Reset(); + } + for(CActionState &ActionState : m_aActionStates) + { + ActionState.m_Active = false; + } +} + +void CTouchControls::OnWindowResize() +{ + OnReset(); + 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()) + { + OnReset(); + 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(); +} diff --git a/src/game/client/components/touch_controls.h b/src/game/client/components/touch_controls.h new file mode 100644 index 000000000..b569b23b7 --- /dev/null +++ b/src/game/client/components/touch_controls.h @@ -0,0 +1,402 @@ +#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 +{ + enum class EButtonShape + { + RECT, + CIRCLE, + NUM_SHAPES + }; + + static constexpr const char *SHAPE_NAMES[(int)EButtonShape::NUM_SHAPES] = {"rect", "circle"}; + + enum class EButtonVisibility + { + INGAME, + EXTRA_MENU, + ZOOM_ALLOWED, + VOTE_ACTIVE, + DUMMY_ALLOWED, + DUMMY_CONNECTED, + RCON_AUTHED, + 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_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 *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; + + void UpdatePointers(); + void UpdateScreenFromUnitRect(); + void UpdateUnitFromScreenRect(); + void UpdateBackgroundCorners(); + + vec2 ClampTouchPosition(vec2 TouchPosition) const; + bool IsInside(vec2 TouchPosition) const; + 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. + * + * This implements the serialization for predefined behaviors. + * + * Subclasses must implemented the actual behavior and provide the label. + */ + class CPredefinedTouchButtonBehavior : public CTouchButtonBehavior + { + const char *m_pId; + + public: + static constexpr const char *BEHAVIOR_TYPE = "predefined"; + + CPredefinedTouchButtonBehavior(const char *pId) : + m_pId(pId) {} + + void WriteToConfiguration(CJsonWriter *pWriter) override; + }; + + class CExtraMenuTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *BEHAVIOR_ID = "extra-menu"; + + CExtraMenuTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + }; + + class CEmoticonTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *BEHAVIOR_ID = "emoticon"; + + CEmoticonTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + }; + + class CSpectateTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + public: + static constexpr const char *BEHAVIOR_ID = "spectate"; + + CSpectateTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnDeactivate() override; + }; + + class CSwapActionTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + int m_ActiveAction = NUM_ACTIONS; + + public: + static constexpr const char *BEHAVIOR_ID = "swap-action"; + + CSwapActionTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void OnDeactivate() override; + }; + + class CUseActionTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + int m_ActiveAction = NUM_ACTIONS; + + public: + static constexpr const char *BEHAVIOR_ID = "use-action"; + + CUseActionTouchButtonBehavior() : + CPredefinedTouchButtonBehavior(BEHAVIOR_ID) {} + + CButtonLabel GetLabel() const override; + void OnActivate() override; + void OnDeactivate() override; + }; + + class CJoystickTouchButtonBehavior : public CPredefinedTouchButtonBehavior + { + int m_ActiveAction = NUM_ACTIONS; + + 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; + }; + + class CJoystickActionTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *BEHAVIOR_ID = "joystick-action"; + + CJoystickActionTouchButtonBehavior() : + CJoystickTouchButtonBehavior(BEHAVIOR_ID) {} + ~CJoystickActionTouchButtonBehavior(); + + void Init(CTouchButton *pTouchButton) override; + int SelectedAction() const override; + }; + + class CJoystickFireTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *BEHAVIOR_ID = "joystick-fire"; + + CJoystickFireTouchButtonBehavior() : + CJoystickTouchButtonBehavior(BEHAVIOR_ID) {} + + int SelectedAction() const override; + }; + + class CJoystickHookTouchButtonBehavior : public CJoystickTouchButtonBehavior + { + public: + static constexpr const char *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 + { + 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; + + public: + static constexpr const char *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; + }; + + bool m_DirectTouchIngame = true; + bool m_DirectTouchSpectate = true; + + std::vector m_vTouchButtons; + + bool m_ExtraMenuActive = false; + + class CActionState + { + public: + bool m_Active = false; + IInput::CTouchFinger m_Finger; + }; + + int m_ActionSelected = ACTION_FIRE; + int m_ActionLastActivated = ACTION_FIRE; + CActionState m_aActionStates[NUM_ACTIONS]; + CJoystickActionTouchButtonBehavior *m_pPrimaryJoystickTouchButtonBehavior; + + bool m_EditingActive = false; + bool m_EditingChanges = false; + + void InitVisibilityFunctions(); + void UpdateButtons(const std::vector &vTouchFingerStates); + void RenderButtons(); + vec2 CalculateScreenSize() const; + + std::unique_ptr ParsePredefinedBehavior(const json_value *pBehaviorObject); + std::unique_ptr ParseBindBehavior(const json_value *pBehaviorObject); + std::unique_ptr ParseBehavior(const json_value *pBehaviorObject); + std::optional ParseButton(const json_value *pButtonObject); + bool ParseConfiguration(const void *pFileData, unsigned FileLength); + void WriteConfiguration(CJsonWriter *pWriter); + +public: + 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(); + + bool IsDirectTouchIngame() const { return m_DirectTouchIngame; } + void SetDirectTouchIngame(bool DirectTouchIngame) + { + m_DirectTouchIngame = DirectTouchIngame; + m_EditingChanges = true; + } + bool IsDirectTouchSpectate() const { return m_DirectTouchSpectate; } + void SetDirectTouchSpectate(bool 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; } +}; + +#endif diff --git a/src/game/client/gameclient.cpp b/src/game/client/gameclient.cpp index 5ac98249a..673315035 100644 --- a/src/game/client/gameclient.cpp +++ b/src/game/client/gameclient.cpp @@ -145,6 +145,7 @@ void CGameClient::OnConsoleInit() &m_Chat, &m_Broadcast, &m_DebugHud, + &m_TouchControls, &m_Scoreboard, &m_Statboard, &m_Motd, @@ -164,6 +165,7 @@ void CGameClient::OnConsoleInit() &m_Emoticon, &m_Menus, &m_Controls, + &m_TouchControls, &m_Binds}); // add basic console commands @@ -443,6 +445,23 @@ void CGameClient::OnUpdate() } } + // handle touch events + const std::vector &vTouchFingerStates = Input()->TouchFingerStates(); + bool TouchHandled = false; + for(auto &pComponent : m_vpInput) + { + if(TouchHandled) + { + // Also update inactive components so they can handle touch fingers being released. + pComponent->OnTouchState({}); + } + else if(pComponent->OnTouchState(vTouchFingerStates)) + { + Input()->ClearTouchDeltas(); + TouchHandled = true; + } + } + // handle key presses Input()->ConsumeEvents([&](const IInput::CEvent &Event) { for(auto &pComponent : m_vpInput) diff --git a/src/game/client/gameclient.h b/src/game/client/gameclient.h index 175c0d414..cf918813e 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 @@ public: CSounds m_Sounds; CEmoticon m_Emoticon; CDamageInd m_DamageInd; + CTouchControls m_TouchControls; CVoting m_Voting; CSpectator m_Spectator;