Index: ps/trunk/binaries/data/config/default.cfg =================================================================== --- ps/trunk/binaries/data/config/default.cfg (revision 24751) +++ ps/trunk/binaries/data/config/default.cfg (revision 24752) @@ -1,538 +1,538 @@ ; Global Configuration Settings ; ; ************************************************************** ; * DO NOT EDIT THIS FILE if you want personal customisations: * ; * create a text file called "local.cfg" instead, and copy * ; * the lines from this file that you want to change. * ; * * ; * If a setting is part of a section (for instance [hotkey]) * ; * you need to append the section name at the beginning of * ; * your custom line (for instance you need to write * ; * "hotkey.pause = Space" if you want to change the pausing * ; * hotkey to the spacebar). * ; * * ; * On Linux, create: * ; * $XDG_CONFIG_HOME/0ad/config/local.cfg * ; * (Note: $XDG_CONFIG_HOME defaults to ~/.config) * ; * * ; * On OS X, create: * ; * ~/Library/Application\ Support/0ad/config/local.cfg * ; * * ; * On Windows, create: * ; * %appdata%\0ad\config\local.cfg * ; * * ; ************************************************************** ; Enable/disable windowed mode by default. (Use Alt+Enter to toggle in the game.) windowed = false ; Show detailed tooltips (Unit stats) showdetailedtooltips = false ; Pause the game on window focus loss (Only applicable to single player mode) pauseonfocusloss = true ; Persist settings after leaving the game setup screen persistmatchsettings = true ; Default player name to use in multiplayer ; playername = "anonymous" ; Default server name or IP to use in multiplayer multiplayerserver = "127.0.0.1" ; Force a particular resolution. (If these are 0, the default is ; to keep the current desktop resolution in fullscreen mode or to ; use 1024x768 in windowed mode.) xres = 0 yres = 0 ; Force a non-standard bit depth (if 0 then use the current desktop bit depth) bpp = 0 ; Preferred display (for multidisplay setups, only works with SDL 2.0) display = 0 ; Emulate right-click with Ctrl+Click on Mac mice macmouse = false ; System settings: ; if false, actors won't be rendered but anything entity will be. renderactors = true watereffects=true ; When disabled, force usage of the fixed pipeline water. This is faster, but really, really ugly. waterfancyeffects = false waterrealdepth = true waterrefraction = true waterreflection = true shadows = true shadowquality = 0 ; Shadow map resolution. (-2 - Very Low, -1 - Low, 0 - Medium, 1 - High, 2 - Very High) ; High values can crash the game when using a graphics card with low memory! shadowpcf = true shadowsfixed = false ; When enabled shadows are rendered only on the shadowsfixeddistance = 300.0 ; fixed distance and without swimming effect. vsync = false particles = true fog = true silhouettes = true showsky = true novbo = false ; Disable hardware cursors nohwcursor = false ; Specify the render path. This can be one of: ; default Automatically select one of the below, depending on system capabilities ; fixed Only use OpenGL fixed function pipeline ; shader Use vertex/fragment shaders for transform and lighting where possible ; Using 'fixed' instead of 'default' may work around some graphics-related problems, ; but will reduce performance and features when a modern graphics card is available. renderpath = default ;;;;; EXPERIMENTAL ;;;;; ; Prefer GLSL shaders over ARB shaders. Allows fancier graphical effects. preferglsl = false ; Experimental probably-non-working GPU skinning support; requires preferglsl; use at own risk gpuskinning = false ; Use smooth LOS interpolation smoothlos = false ; Use screen-space postprocessing filters (HDR, bloom, DOF, etc). Incompatible with fixed renderpath. postproc = false ; Use anti-aliasing techniques. antialiasing = "disabled" ; Use sharpening techniques. sharpening = "disabled" sharpness = 0.3 ; Quality level of shader effects (set to 10 to display all effects) materialmgr.quality = 2.0 ; Maximum distance to display parallax effect. Set to 0 to disable parallax. materialmgr.PARALLAX_DIST.max = 150 ; Maximum distance to display high quality parallax effect. materialmgr.PARALLAX_HQ_DIST.max = 75 ; Maximum distance to display very high quality parallax effect. Set to 30 to enable. materialmgr.PARALLAX_VHQ_DIST.max = 0 ;;;;;;;;;;;;;;;;;;;;;;;; ; Replace alpha-blending with alpha-testing, for performance experiments forcealphatest = false ; Color of the sky (in "r g b" format) skycolor = "0 0 0" [adaptivefps] session = 60 ; Throttle FPS in running games (prevents 100% CPU workload). menu = 60 ; Throttle FPS in menus only. [profiler2] server = "127.0.0.1" server.port = "8000" ; Use a free port on your machine. server.threads = "6" ; Enough for the browser's parallel connection limit [hotkey] ; Each one of the specified keys will trigger the action on the left ; for multiple-key combinations, separate keys with '+'. ; See keys.txt for the list of key names. ; > SYSTEM SETTINGS exit = "Ctrl+Break", "Super+Q", "Alt+F4" ; Exit to desktop cancel = Escape ; Close or cancel the current dialog box/popup confirm = Return ; Confirm the current command pause = Pause, "Shift+Space" ; Pause/unpause game screenshot = F2 ; Take PNG screenshot bigscreenshot = "Shift+F2" ; Take large BMP screenshot togglefullscreen = "Alt+Return" ; Toggle fullscreen/windowed mode screenshot.watermark = "Alt+K" ; Toggle product/company watermark for official screenshots wireframe = "Alt+Shift+W" ; Toggle wireframe mode silhouettes = "Alt+Shift+S" ; Toggle unit silhouettes ; > DIALOG HOTKEYS summary = "Ctrl+Tab" ; Toggle in-game summary lobby = "Alt+L" ; Show the multiplayer lobby in a dialog window. structree = "Alt+Shift+T" ; Show structure tree civinfo = "Alt+Shift+H" ; Show civilization info ; > CLIPBOARD CONTROLS copy = "Ctrl+C" ; Copy to clipboard paste = "Ctrl+V" ; Paste from clipboard cut = "Ctrl+X" ; Cut selected text and copy to the clipboard ; > CONSOLE SETTINGS console.toggle = BackQuote, F9 ; Open/close console ; > OVERLAY KEYS fps.toggle = "Alt+F" ; Toggle frame counter realtime.toggle = "Alt+T" ; Toggle current display of computer time timeelapsedcounter.toggle = "F12" ; Toggle time elapsed counter ceasefirecounter.toggle = "" ; Toggle ceasefire counter ; > HOTKEYS ONLY chat = Return ; Toggle chat window teamchat = "T" ; Toggle chat window in team chat mode privatechat = "L" ; Toggle chat window and select the previous private chat partner ; > QUICKSAVE quicksave = "Shift+F5" quickload = "Shift+F8" [hotkey.camera] reset = "R" ; Reset camera rotation to default. follow = "F" ; Follow the first unit in the selection rallypointfocus = "" ; Focus the camera on the rally point of the selected building zoom.in = Plus, NumPlus ; Zoom camera in (continuous control) zoom.out = Minus, NumMinus ; Zoom camera out (continuous control) zoom.wheel.in = WheelUp ; Zoom camera in (stepped control) zoom.wheel.out = WheelDown ; Zoom camera out (stepped control) rotate.up = "Ctrl+UpArrow", "Ctrl+W" ; Rotate camera to look upwards rotate.down = "Ctrl+DownArrow", "Ctrl+S" ; Rotate camera to look downwards rotate.cw = "Ctrl+LeftArrow", "Ctrl+A", Q ; Rotate camera clockwise around terrain rotate.ccw = "Ctrl+RightArrow", "Ctrl+D", E ; Rotate camera anticlockwise around terrain rotate.wheel.cw = "Shift+WheelUp", MouseX1 ; Rotate camera clockwise around terrain (stepped control) rotate.wheel.ccw = "Shift+WheelDown", MouseX2 ; Rotate camera anticlockwise around terrain (stepped control) pan = MouseMiddle ; Enable scrolling by moving mouse left = A, LeftArrow ; Scroll or rotate left right = D, RightArrow ; Scroll or rotate right up = W, UpArrow ; Scroll or rotate up/forwards down = S, DownArrow ; Scroll or rotate down/backwards scroll.speed.increase = "Ctrl+Shift+S" ; Increase scroll speed scroll.speed.decrease = "Ctrl+Alt+S" ; Decrease scroll speed rotate.speed.increase = "Ctrl+Shift+R" ; Increase rotation speed rotate.speed.decrease = "Ctrl+Alt+R" ; Decrease rotation speed zoom.speed.increase = "Ctrl+Shift+Z" ; Increase zoom speed zoom.speed.decrease = "Ctrl+Alt+Z" ; Decrease zoom speed [hotkey.camera.jump] 1 = F5 ; Jump to position N 2 = F6 3 = F7 4 = F8 ;5 = ;6 = ;7 = ;8 = ;9 = ;10 = [hotkey.camera.jump.set] 1 = "Ctrl+F5" ; Set jump position N 2 = "Ctrl+F6" 3 = "Ctrl+F7" 4 = "Ctrl+F8" ;5 = ;6 = ;7 = ;8 = ;9 = ;10 = [hotkey.profile] toggle = "F11" ; Enable/disable real-time profiler save = "Shift+F11" ; Save current profiler data to logs/profile.txt [hotkey.profile2] toggle = "Ctrl+F11" ; Enable/disable HTTP/GPU modes for new profiler [hotkey.selection] cancel = Esc ; Un-select all units and cancel building placement add = Shift ; Add units to selection militaryonly = Alt ; Add only military units to the selection nonmilitaryonly = "Alt+Y" ; Add only non-military units to the selection idleonly = "I" ; Select only idle units woundedonly = "O" ; Select only wounded units remove = Ctrl ; Remove units from selection idleworker = Period, NumDecimal ; Select next idle worker idlewarrior = Slash, NumDivide ; Select next idle warrior idleunit = BackSlash ; Select next idle unit offscreen = Alt ; Include offscreen units in selection [hotkey.selection.group.add] 0 = "Shift+0", "Shift+Num0" 1 = "Shift+1", "Shift+Num1" 2 = "Shift+2", "Shift+Num2" 3 = "Shift+3", "Shift+Num3" 4 = "Shift+4", "Shift+Num4" 5 = "Shift+5", "Shift+Num5" 6 = "Shift+6", "Shift+Num6" 7 = "Shift+7", "Shift+Num7" 8 = "Shift+8", "Shift+Num8" 9 = "Shift+9", "Shift+Num9" [hotkey.selection.group.save] 0 = "Ctrl+0", "Ctrl+Num0" 1 = "Ctrl+1", "Ctrl+Num1" 2 = "Ctrl+2", "Ctrl+Num2" 3 = "Ctrl+3", "Ctrl+Num3" 4 = "Ctrl+4", "Ctrl+Num4" 5 = "Ctrl+5", "Ctrl+Num5" 6 = "Ctrl+6", "Ctrl+Num6" 7 = "Ctrl+7", "Ctrl+Num7" 8 = "Ctrl+8", "Ctrl+Num8" 9 = "Ctrl+9", "Ctrl+Num9" [hotkey.selection.group.select] 0 = 0, Num0 1 = 1, Num1 2 = 2, Num2 3 = 3, Num3 4 = 4, Num4 5 = 5, Num5 6 = 6, Num6 7 = 7, Num7 8 = 8, Num8 9 = 9, Num9 [hotkey.gamesetup] mapbrowser.open = "M" [hotkey.session] kill = Delete, Backspace ; Destroy selected units stop = "H" ; Stop the current action backtowork = "Y" ; The unit will go back to work unload = "U" ; Unload garrisoned units when a building/mechanical unit is selected move = "" ; Modifier to move to a point instead of another action (e.g. gather) attack = Ctrl ; Modifier to attack instead of another action (e.g. capture) attackmove = Ctrl ; Modifier to attackmove when clicking on a point -attackmoveUnit = "Ctrl+Q" ; Modifier to attackmove targeting only units when clicking on a point (should contain the attackmove keys) +attackmoveUnit = "Ctrl+Q" ; Modifier to attackmove targeting only units when clicking on a point garrison = Ctrl ; Modifier to garrison when clicking on building autorallypoint = Ctrl ; Modifier to set the rally point on the building itself guard = "G" ; Modifier to escort/guard when clicking on unit/building patrol = "P" ; Modifier to patrol a unit repair = "J" ; Modifier to repair when clicking on building/mechanical unit queue = Shift ; Modifier to queue unit orders instead of replacing orderone = Alt ; Modifier to order only one entity in selection. batchtrain = Shift ; Modifier to train units in batches massbarter = Shift ; Modifier to barter bunch of resources masstribute = Shift ; Modifier to tribute bunch of resources noconfirmation = Shift ; Do not ask confirmation when deleting a building/unit fulltradeswap = Shift ; Modifier to put the desired trade resource to 100% unloadtype = Shift ; Modifier to unload all units of type deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting rotate.cw = RightBracket ; Rotate building placement preview clockwise rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise snaptoedges = Ctrl ; Modifier to align new structures with nearby existing structure toggledefaultformation = "" ; Switch between null default formation and the last default formation used (defaults to "box") ; Overlays showstatusbars = Tab ; Toggle display of status bars devcommands.toggle = "Alt+D" ; Toggle developer commands panel highlightguarding = PageDown ; Toggle highlight of guarding units highlightguarded = PageUp ; Toggle highlight of guarded units diplomacycolors = "Alt+X" ; Toggle diplomacy colors toggleattackrange = "Alt+C" ; Toggle display of attack range overlays of selected defensive structures toggleaurasrange = "Alt+V" ; Toggle display of aura range overlays of selected units and structures togglehealrange = "Alt+B" ; Toggle display of heal range overlays of selected units [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI menu.toggle = "F10" ; Toggle in-game menu diplomacy.toggle = "Ctrl+H" ; Toggle in-game diplomacy page barter.toggle = "Ctrl+B" ; Toggle in-game barter/trade page objectives.toggle = "Ctrl+O" ; Toggle in-game objectives page tutorial.toggle = "Ctrl+P" ; Toggle in-game tutorial panel [hotkey.session.savedgames] delete = Delete, Backspace ; Delete the selected saved game asking confirmation noconfirmation = Shift ; Do not ask confirmation when deleting a game [hotkey.session.queueunit] ; > UNIT TRAINING 1 = "Z" ; add first unit type to queue 2 = "X" ; add second unit type to queue 3 = "C" ; add third unit type to queue 4 = "V" ; add fourth unit type to queue 5 = "B" ; add fivth unit type to queue 6 = "N" ; add sixth unit type to queue 7 = "M" ; add seventh unit type to queue 8 = Comma ; add eighth unit type to queue [hotkey.session.timewarp] fastforward = Space ; If timewarp mode enabled, speed up the game rewind = "Shift+Backspace" ; If timewarp mode enabled, go back to earlier point in the game [hotkey.tab] next = "Tab", "Alt+S" ; Show the next tab prev = "Shift+Tab", "Alt+W" ; Show the previous tab [hotkey.text] ; > GUI TEXTBOX HOTKEYS delete.left = "Ctrl+Backspace" ; Delete word to the left of cursor delete.right = "Ctrl+Del" ; Delete word to the right of cursor move.left = "Ctrl+LeftArrow" ; Move cursor to start of word to the left of cursor move.right = "Ctrl+RightArrow" ; Move cursor to start of word to the right of cursor [gui] cursorblinkrate = 0.5 ; Cursor blink rate in seconds (0.0 to disable blinking) scale = 1.0 ; GUI scaling factor, for improved compatibility with 4K displays [gui.gamesetup] enabletips = true ; Enable/Disable tips during gamesetup (for newcomers) assignplayers = everyone ; Whether to assign joining clients to free playerslots. Possible values: everyone, buddies, disabled. aidifficulty = 3 ; Difficulty level, from 0 (easiest) to 5 (hardest) aibehavior = "random" ; Default behavior of the AI (random, balanced, aggressive or defensive) settingsslide = true ; Enable/Disable settings panel slide [gui.loadingscreen] progressdescription = false ; Whether to display the progress percent or a textual description [gui.session] camerajump.threshold = 40 ; How close do we have to be to the actual location in order to jump back to the previous one? timeelapsedcounter = false ; Show the game duration in the top right corner ceasefirecounter = false ; Show the remaining ceasefire time in the top right corner batchtrainingsize = 5 ; Number of units to be trained per batch by default (when pressing the hotkey) scrollbatchratio = 1 ; Number of times you have to scroll to increase/decrease the batchsize by 1 woundedunithotkeythreshold = 33 ; The wounded unit hotkey considers the selected units as wounded if their health percentage falls below this number attackrange = true ; Display attack range overlays of selected defensive structures aurasrange = true ; Display aura range overlays of selected units and structures healrange = true ; Display heal range overlays of selected units rankabovestatusbar = true ; Show rank icons above status bars experiencestatusbar = true ; Show an experience status bar above each selected unit respoptooltipsort = 0 ; Sorting players in the resources and population tooltip by value (0 - no sort, -1 - ascending, 1 - descending) snaptoedges = "disabled" ; Possible values: disabled, enabled. snaptoedgesdistancethreshold = 15 ; On which distance we don't snap to edges disjointcontrolgroups = "true" ; Whether control groups are disjoint sets or entities can be in multiple control groups at the same time. defaultformation = "special/formations/box" ; For walking orders, automatically put units into this formation if they don't have one already. formationwalkonly = "true" ; Formations are disabled when giving gather/attack/... orders. [gui.session.minimap] blinkduration = 1.7 ; The blink duration while pinging pingduration = 50.0 ; The duration for which an entity will be pinged after an attack notification [gui.session.notifications] attack = true ; Show a chat notification if you are attacked by another player tribute = true ; Show a chat notification if an ally tributes resources to another team member if teams are locked, and all tributes in observer mode barter = true ; Show a chat notification to observers when a player bartered resources phase = completed ; Show a chat notification if you or an ally have started, aborted or completed a new phase, and phases of all players in observer mode. Possible values: none, completed, all. [gui.splashscreen] enable = true ; Enable/disable the splashscreen version = 0 ; Splashscreen version (date of last modification). By default, 0 to force splashscreen to appear at first launch [gui.session.diplomacycolors] self = "21 55 149" ; Color of your units when diplomacy colors are enabled ally = "86 180 31" ; Color of allies when diplomacy colors are enabled neutral = "231 200 5" ; Color of neutral players when diplomacy colors are enabled enemy = "150 20 20" ; Color of enemies when diplomacy colors are enabled [joystick] ; EXPERIMENTAL: joystick/gamepad settings enable = false deadzone = 8192 [joystick.camera] pan.x = 0 pan.y = 1 rotate.x = 3 rotate.y = 2 zoom.in = 5 zoom.out = 4 [chat] timestamp = true ; Show at which time chat messages have been sent [chat.session] extended = true ; Whether to display the chat history [lobby] history = 0 ; Number of past messages to display on join room = "arena24" ; Default MUC room to join server = "lobby.wildfiregames.com" ; Address of lobby server tls = true ; Whether to use TLS encryption when connecting to the server. verify_certificate = false ; Whether to reject connecting to the lobby if the TLS certificate is invalid (TODO: wait for Gloox GnuTLS trust implementation to be fixed) terms_url = "https://trac.wildfiregames.com/browser/ps/trunk/binaries/data/mods/public/gui/prelobby/common/terms/"; Allows the user to save the text and print the terms terms_of_service = "0" ; Version (hash) of the Terms of Service that the user has accepted terms_of_use = "0" ; Version (hash) of the Terms of Use that the user has accepted privacy_policy = "0" ; Version (hash) of the Privacy Policy that the user has accepted xpartamupp = "wfgbot24" ; Name of the server-side XMPP-account that manage games echelon = "echelon24" ; Name of the server-side XMPP-account that manages ratings buddies = "," ; Comma separated list of playernames that the current user has marked as buddies rememberpassword = true ; Whether to store the encrypted password in the user config [lobby.columns] gamerating = false ; Show the average rating of the participating players in a column of the gamelist [lobby.stun] enabled = true ; The STUN protocol allows hosting games without configuring the firewall and router. ; If STUN is disabled, the game relies on direct connection, UPnP and port forwarding. server = "lobby.wildfiregames.com" ; Address of the STUN server. port = 3478 ; Port of the STUN server. delay = 200 ; Duration in milliseconds that is waited between STUN messages. ; Smaller numbers speed up joins but also become less stable. [mod] enabledmods = "mod public" [modio] public_key = "RWTsHxQMrRq4xwHisyBa2rNQfAedcINzbTT83jeX4/ZcfVxqLfWB4y8w" ; Public key corresponding to the private key valid mods are signed with disclaimer = "0" ; Version (hash) of the Disclaimer that the user has accepted [modio.v1] baseurl = "https://api.mod.io/v1" api_key = "23df258a71711ea6e4b50893acc1ba55" name_id = "0ad" [network] duplicateplayernames = false ; Rename joining player to "User (2)" if "User" is already connected, otherwise prohibit join. lateobservers = everyone ; Allow observers to join the game after it started. Possible values: everyone, buddies, disabled. observerlimit = 8 ; Prevent further observer joins in running games if this limit is reached [overlay] fps = "false" ; Show frames per second in top right corner realtime = "false" ; Show current system time in top right corner netwarnings = "true" ; Show warnings if the network connection is bad [profiler2] autoenable = false ; Enable HTTP server output at startup (default off for security/performance) gpu.arb.enable = true ; Allow GL_ARB_timer_query timing mode when available gpu.ext.enable = true ; Allow GL_EXT_timer_query timing mode when available gpu.intel.enable = true ; Allow GL_INTEL_performance_queries timing mode when available [rlinterface] address = "127.0.0.1:6000" [sound] mastergain = 0.9 musicgain = 0.2 ambientgain = 0.6 actiongain = 0.7 uigain = 0.7 [sound.notify] nick = true ; Play a sound when someone mentions your name in the lobby or game gamesetup.join = false ; Play a sound when a new client joins the game setup [tinygettext] debug = false ; Print error messages each time a translation for an English string is not found. [userreport] ; Opt-in online user reporting system url_upload = "https://feedback.wildfiregames.com/report/upload/v1/" ; URL where UserReports are uploaded to url_publication = "https://feedback.wildfiregames.com/" ; URL where UserReports were analyzed and published url_terms = "https://trac.wildfiregames.com/browser/ps/trunk/binaries/data/mods/public/gui/userreport/Terms_and_Conditions.txt"; Allows the user to save the text and print the terms terms = "0" ; Version (hash) of the UserReporter Terms that the user has accepted [view] ; Camera control settings scroll.speed = 120.0 scroll.speed.modifier = 1.05 ; Multiplier for changing scroll speed rotate.x.speed = 1.2 rotate.x.min = 28.0 rotate.x.max = 60.0 rotate.x.default = 35.0 rotate.y.speed = 2.0 rotate.y.speed.wheel = 0.45 rotate.y.default = 0.0 rotate.speed.modifier = 1.05 ; Multiplier for changing rotation speed drag.speed = 0.5 zoom.speed = 256.0 zoom.speed.wheel = 32.0 zoom.min = 50.0 zoom.max = 200.0 zoom.default = 120.0 zoom.speed.modifier = 1.05 ; Multiplier for changing zoom speed pos.smoothness = 0.1 zoom.smoothness = 0.4 rotate.x.smoothness = 0.5 rotate.y.smoothness = 0.3 near = 2.0 ; Near plane distance far = 4096.0 ; Far plane distance fov = 45.0 ; Field of view (degrees), lower is narrow, higher is wide height.smoothness = 0.5 height.min = 16 Index: ps/trunk/binaries/data/mods/public/gui/session/input.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/input.js (revision 24751) +++ ps/trunk/binaries/data/mods/public/gui/session/input.js (revision 24752) @@ -1,1643 +1,1649 @@ const SDL_BUTTON_LEFT = 1; const SDL_BUTTON_MIDDLE = 2; const SDL_BUTTON_RIGHT = 3; const SDLK_LEFTBRACKET = 91; const SDLK_RIGHTBRACKET = 93; const SDLK_RSHIFT = 303; const SDLK_LSHIFT = 304; const SDLK_RCTRL = 305; const SDLK_LCTRL = 306; const SDLK_RALT = 307; const SDLK_LALT = 308; // TODO: these constants should be defined somewhere else instead, in // case any other code wants to use them too. const ACTION_NONE = 0; const ACTION_GARRISON = 1; const ACTION_REPAIR = 2; const ACTION_GUARD = 3; const ACTION_PATROL = 4; var preSelectedAction = ACTION_NONE; const INPUT_NORMAL = 0; const INPUT_SELECTING = 1; const INPUT_BANDBOXING = 2; const INPUT_BUILDING_PLACEMENT = 3; const INPUT_BUILDING_CLICK = 4; const INPUT_BUILDING_DRAG = 5; const INPUT_BATCHTRAINING = 6; const INPUT_PRESELECTEDACTION = 7; const INPUT_BUILDING_WALL_CLICK = 8; const INPUT_BUILDING_WALL_PATHING = 9; const INPUT_UNIT_POSITION_START = 10; const INPUT_UNIT_POSITION = 11; var inputState = INPUT_NORMAL; const INVALID_ENTITY = 0; var mouseX = 0; var mouseY = 0; var mouseIsOverObject = false; /** * Containing the ingame position which span the line. */ var g_FreehandSelection_InputLine = []; /** * Minimum squared distance when a mouse move is called a drag. */ const g_FreehandSelection_ResolutionInputLineSquared = 1; /** * Minimum length a dragged line should have to use the freehand selection. */ const g_FreehandSelection_MinLengthOfLine = 8; /** * To start the freehandSelection function you need a minimum number of units. * Minimum must be 2, for better performance you could set it higher. */ const g_FreehandSelection_MinNumberOfUnits = 2; /** * Number of pixels the mouse can move before the action is considered a drag. */ const g_MaxDragDelta = 4; /** * Used for remembering mouse coordinates at start of drag operations. */ var g_DragStart; /** * Store the clicked entity on mousedown or mouseup for single/double/triple clicks to select entities. * If any mousedown or mouseup of a sequence of clicks lands on a unit, * that unit will be selected, which makes it easier to click on moving units. */ var clickedEntity = INVALID_ENTITY; // Same double-click behaviour for hotkey presses. const doublePressTime = 500; var doublePressTimer = 0; var prevHotkey = 0; function updateCursorAndTooltip() { let cursorSet = false; let tooltipSet = false; let informationTooltip = Engine.GetGUIObjectByName("informationTooltip"); if (!mouseIsOverObject && (inputState == INPUT_NORMAL || inputState == INPUT_PRESELECTEDACTION) || g_MiniMapPanel.isMouseOverMiniMap()) { let action = determineAction(mouseX, mouseY, g_MiniMapPanel.isMouseOverMiniMap()); if (action) { if (action.cursor) { Engine.SetCursor(action.cursor); cursorSet = true; } if (action.tooltip) { tooltipSet = true; informationTooltip.caption = action.tooltip; informationTooltip.hidden = false; } } } if (!cursorSet) Engine.ResetCursor(); if (!tooltipSet) informationTooltip.hidden = true; let placementTooltip = Engine.GetGUIObjectByName("placementTooltip"); if (placementSupport.tooltipMessage) placementTooltip.sprite = placementSupport.tooltipError ? "BackgroundErrorTooltip" : "BackgroundInformationTooltip"; placementTooltip.caption = placementSupport.tooltipMessage || ""; placementTooltip.hidden = !placementSupport.tooltipMessage; } function updateBuildingPlacementPreview() { // The preview should be recomputed every turn, so that it responds to obstructions/fog/etc moving underneath it, or // in the case of the wall previews, in response to new tower foundations getting constructed for it to snap to. // See onSimulationUpdate in session.js. if (placementSupport.mode === "building") { if (placementSupport.template && placementSupport.position) { let result = Engine.GuiInterfaceCall("SetBuildingPlacementPreview", { "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, "angle": placementSupport.angle, "actorSeed": placementSupport.actorSeed }); placementSupport.tooltipError = !result.success; placementSupport.tooltipMessage = ""; if (!result.success) { if (result.message && result.parameters) { let message = result.message; if (result.translateMessage) if (result.pluralMessage) message = translatePlural(result.message, result.pluralMessage, result.pluralCount); else message = translate(message); let parameters = result.parameters; if (result.translateParameters) translateObjectKeys(parameters, result.translateParameters); placementSupport.tooltipMessage = sprintf(message, parameters); } return false; } if (placementSupport.attack && placementSupport.attack.Ranged) { let cmd = { "x": placementSupport.position.x, "z": placementSupport.position.z, "range": placementSupport.attack.Ranged.maxRange, "elevationBonus": placementSupport.attack.Ranged.elevationBonus }; let averageRange = Math.round(Engine.GuiInterfaceCall("GetAverageRangeForBuildings", cmd) - cmd.range); let range = Math.round(cmd.range); placementSupport.tooltipMessage = sprintf(translatePlural("Basic range: %(range)s meter", "Basic range: %(range)s meters", range), { "range": range }) + "\n" + sprintf(translatePlural("Average bonus range: %(range)s meter", "Average bonus range: %(range)s meters", averageRange), { "range": averageRange }); } return true; } } else if (placementSupport.mode === "wall" && placementSupport.wallSet && placementSupport.position) { placementSupport.wallSnapEntities = Engine.PickSimilarPlayerEntities( placementSupport.wallSet.templates.tower, placementSupport.wallSnapEntitiesIncludeOffscreen, true, // require exact template match true // include foundations ); return Engine.GuiInterfaceCall("SetWallPlacementPreview", { "wallSet": placementSupport.wallSet, "start": placementSupport.position, "end": placementSupport.wallEndPosition, "snapEntities": placementSupport.wallSnapEntities // snapping entities (towers) for starting a wall segment }); } return false; } /** * Determine the context-sensitive action that should be performed when the mouse is at (x,y) */ function determineAction(x, y, fromMiniMap) { let selection = g_Selection.toList(); if (!selection.length) { preSelectedAction = ACTION_NONE; return undefined; } let entState = GetEntityState(selection[0]); if (!entState) return undefined; if (!selection.every(ownsEntity) && !(g_SimState.players[g_ViewedPlayer] && g_SimState.players[g_ViewedPlayer].controlsAll)) return undefined; let target; if (!fromMiniMap) { let ent = Engine.PickEntityAtPoint(x, y); if (ent != INVALID_ENTITY) target = ent; } // Decide between the following ordered actions, // if two actions are possible, the first one is taken // thus the most specific should appear first. if (preSelectedAction != ACTION_NONE) { for (let action of g_UnitActionsSortedKeys) if (g_UnitActions[action].preSelectedActionCheck) { let r = g_UnitActions[action].preSelectedActionCheck(target, selection); if (r) return r; } return { "type": "none", "cursor": "", "target": target }; } for (let action of g_UnitActionsSortedKeys) if (g_UnitActions[action].hotkeyActionCheck) { let r = g_UnitActions[action].hotkeyActionCheck(target, selection); if (r) return r; } for (let action of g_UnitActionsSortedKeys) if (g_UnitActions[action].actionCheck) { let r = g_UnitActions[action].actionCheck(target, selection); if (r) return r; } return { "type": "none", "cursor": "", "target": target }; } function ownsEntity(ent) { let entState = GetEntityState(ent); return entState && entState.player == g_ViewedPlayer; } +function isAttackMovePressed() +{ + return Engine.HotkeyIsPressed("session.attackmove") || + Engine.HotkeyIsPressed("session.attackmoveUnit"); +} + function isSnapToEdgesEnabled() { let config = Engine.ConfigDB_GetValue("user", "gui.session.snaptoedges"); let hotkeyPressed = Engine.HotkeyIsPressed("session.snaptoedges"); return hotkeyPressed == (config == "disabled"); } function tryPlaceBuilding(queued) { if (placementSupport.mode !== "building") { error("tryPlaceBuilding expected 'building', got '" + placementSupport.mode + "'"); return false; } if (!updateBuildingPlacementPreview()) { Engine.GuiInterfaceCall("PlaySound", { "name": "invalid_building_placement", "entity": g_Selection.toList()[0] }); return false; } let selection = Engine.HotkeyIsPressed("session.orderone") && popOneFromSelection({ "type": "construct", "target": placementSupport }) || g_Selection.toList(); Engine.PostNetworkCommand({ "type": "construct", "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, "angle": placementSupport.angle, "actorSeed": placementSupport.actorSeed, "entities": selection, "autorepair": true, "autocontinue": true, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_build", "entity": selection[0] }); if (!queued || !g_Selection.toList().length) placementSupport.Reset(); else placementSupport.RandomizeActorSeed(); return true; } function tryPlaceWall(queued) { if (placementSupport.mode !== "wall") { error("tryPlaceWall expected 'wall', got '" + placementSupport.mode + "'"); return false; } let wallPlacementInfo = updateBuildingPlacementPreview(); // entities making up the wall (wall segments, towers, ...) if (!(wallPlacementInfo === false || typeof wallPlacementInfo === "object")) { error("Invalid updateBuildingPlacementPreview return value: " + uneval(wallPlacementInfo)); return false; } if (!wallPlacementInfo) return false; let selection = Engine.HotkeyIsPressed("session.orderone") && popOneFromSelection({ "type": "construct", "target": placementSupport }) || g_Selection.toList(); let cmd = { "type": "construct-wall", "autorepair": true, "autocontinue": true, "queued": queued, "entities": selection, "wallSet": placementSupport.wallSet, "pieces": wallPlacementInfo.pieces, "startSnappedEntity": wallPlacementInfo.startSnappedEnt, "endSnappedEntity": wallPlacementInfo.endSnappedEnt, "formation": g_AutoFormation.getNull() }; // Make sure that there's at least one non-tower entity getting built, to prevent silly edge cases where the start and end // point are too close together for the algorithm to place a wall segment inbetween, and only the towers are being previewed // (this is somewhat non-ideal and hardcode-ish). let hasWallSegment = false; for (let piece of cmd.pieces) { if (piece.template != cmd.wallSet.templates.tower) // TODO: hardcode-ish :( { hasWallSegment = true; break; } } if (hasWallSegment) { Engine.PostNetworkCommand(cmd); Engine.GuiInterfaceCall("PlaySound", { "name": "order_build", "entity": selection[0] }); } return true; } /** * Updates the bandbox object with new positions and visibility. * @returns {array} The coordinates of the vertices of the bandbox. */ function updateBandbox(bandbox, ev, hidden) { let scale = +Engine.ConfigDB_GetValue("user", "gui.scale"); let vMin = Vector2D.min(g_DragStart, ev); let vMax = Vector2D.max(g_DragStart, ev); bandbox.size = new GUISize(vMin.x / scale, vMin.y / scale, vMax.x / scale, vMax.y / scale); bandbox.hidden = hidden; return [vMin.x, vMin.y, vMax.x, vMax.y]; } // Define some useful unit filters for getPreferredEntities. var unitFilters = { "isUnit": entity => { let entState = GetEntityState(entity); return entState && hasClass(entState, "Unit"); }, "isDefensive": entity => { let entState = GetEntityState(entity); return entState && hasClass(entState, "Defensive"); }, "isMilitary": entity => { let entState = GetEntityState(entity); return entState && g_MilitaryTypes.some(c => hasClass(entState, c)); }, "isNonMilitary": entity => { let entState = GetEntityState(entity); return entState && hasClass(entState, "Unit") && !g_MilitaryTypes.some(c => hasClass(entState, c)); }, "isIdle": entity => { let entState = GetEntityState(entity); return entState && hasClass(entState, "Unit") && entState.unitAI && entState.unitAI.isIdle && !hasClass(entState, "Domestic"); }, "isWounded": entity => { let entState = GetEntityState(entity); return entState && hasClass(entState, "Unit") && entState.maxHitpoints && 100 * entState.hitpoints <= entState.maxHitpoints * Engine.ConfigDB_GetValue("user", "gui.session.woundedunithotkeythreshold"); }, "isAnything": entity => { return true; } }; // Choose, inside a list of entities, which ones will be selected. // We may use several entity filters, until one returns at least one element. function getPreferredEntities(ents) { let filters = [unitFilters.isUnit, unitFilters.isDefensive, unitFilters.isAnything]; if (Engine.HotkeyIsPressed("selection.militaryonly")) filters = [unitFilters.isMilitary]; if (Engine.HotkeyIsPressed("selection.nonmilitaryonly")) filters = [unitFilters.isNonMilitary]; if (Engine.HotkeyIsPressed("selection.idleonly")) filters = [unitFilters.isIdle]; if (Engine.HotkeyIsPressed("selection.woundedonly")) filters = [unitFilters.isWounded]; let preferredEnts = []; for (let i = 0; i < filters.length; ++i) { preferredEnts = ents.filter(filters[i]); if (preferredEnts.length) break; } return preferredEnts; } function handleInputBeforeGui(ev, hoveredObject) { if (GetSimState().cinemaPlaying) return false; // Capture cursor position so we can use it for displaying cursors, // and key states. switch (ev.type) { case "mousebuttonup": case "mousebuttondown": case "mousemotion": mouseX = ev.x; mouseY = ev.y; break; } mouseIsOverObject = (hoveredObject != null); // Close the menu when interacting with the game world. if (!mouseIsOverObject && (ev.type =="mousebuttonup" || ev.type == "mousebuttondown") && (ev.button == SDL_BUTTON_LEFT || ev.button == SDL_BUTTON_RIGHT)) g_Menu.close(); // State-machine processing: // // (This is for states which should override the normal GUI processing - events will // be processed here before being passed on, and propagation will stop if this function // returns true) // // TODO: it'd probably be nice to have a better state-machine system, with guaranteed // entry/exit functions, since this is a bit broken now switch (inputState) { case INPUT_BANDBOXING: let bandbox = Engine.GetGUIObjectByName("bandbox"); switch (ev.type) { case "mousemotion": { let rect = updateBandbox(bandbox, ev, false); let ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer); let preferredEntities = getPreferredEntities(ents); g_Selection.setHighlightList(preferredEntities); return false; } case "mousebuttonup": if (ev.button == SDL_BUTTON_LEFT) { let rect = updateBandbox(bandbox, ev, true); let ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer)); g_Selection.setHighlightList([]); if (Engine.HotkeyIsPressed("selection.add")) g_Selection.addList(ents); else if (Engine.HotkeyIsPressed("selection.remove")) g_Selection.removeList(ents); else { g_Selection.reset(); g_Selection.addList(ents); } inputState = INPUT_NORMAL; return true; } if (ev.button == SDL_BUTTON_RIGHT) { // Cancel selection. bandbox.hidden = true; g_Selection.setHighlightList([]); inputState = INPUT_NORMAL; return true; } break; } break; case INPUT_UNIT_POSITION: switch (ev.type) { case "mousemotion": return positionUnitsFreehandSelectionMouseMove(ev); case "mousebuttonup": return positionUnitsFreehandSelectionMouseUp(ev); } break; case INPUT_BUILDING_CLICK: switch (ev.type) { case "mousemotion": // If the mouse moved far enough from the original click location, // then switch to drag-orientation mode. let maxDragDelta = 16; if (g_DragStart.distanceTo(ev) >= maxDragDelta) { inputState = INPUT_BUILDING_DRAG; return false; } break; case "mousebuttonup": if (ev.button == SDL_BUTTON_LEFT) { // If queued, let the player continue placing another of the same building. let queued = Engine.HotkeyIsPressed("session.queue"); if (tryPlaceBuilding(queued)) { if (queued && g_Selection.toList().length) inputState = INPUT_BUILDING_PLACEMENT; else inputState = INPUT_NORMAL; } else inputState = INPUT_BUILDING_PLACEMENT; return true; } break; case "mousebuttondown": if (ev.button == SDL_BUTTON_RIGHT) { // Cancel building. placementSupport.Reset(); inputState = INPUT_NORMAL; return true; } break; } break; case INPUT_BUILDING_WALL_CLICK: // User is mid-click in choosing a starting point for building a wall. The build process can still be cancelled at this point // by right-clicking; releasing the left mouse button will 'register' the starting point and commence endpoint choosing mode. switch (ev.type) { case "mousebuttonup": if (ev.button === SDL_BUTTON_LEFT) { inputState = INPUT_BUILDING_WALL_PATHING; return true; } break; case "mousebuttondown": if (ev.button == SDL_BUTTON_RIGHT) { // Cancel building. placementSupport.Reset(); updateBuildingPlacementPreview(); inputState = INPUT_NORMAL; return true; } break; } break; case INPUT_BUILDING_WALL_PATHING: // User has chosen a starting point for constructing the wall, and is now looking to set the endpoint. // Right-clicking cancels wall building mode, left-clicking sets the endpoint and builds the wall and returns to // normal input mode. Optionally, shift + left-clicking does not return to normal input, and instead allows the // user to continue building walls. switch (ev.type) { case "mousemotion": placementSupport.wallEndPosition = Engine.GetTerrainAtScreenPoint(ev.x, ev.y); // Update the structure placement preview, and by extension, the list of snapping candidate entities for both (!) // the ending point and the starting point to snap to. // // TODO: Note that here, we need to fetch all similar entities, including any offscreen ones, to support the case // where the snap entity for the starting point has moved offscreen, or has been deleted/destroyed, or was a // foundation and has been replaced with a completed entity since the user first chose it. Fetching all towers on // the entire map instead of only the current screen might get expensive fast since walls all have a ton of towers // in them. Might be useful to query only for entities within a certain range around the starting point and ending // points. placementSupport.wallSnapEntitiesIncludeOffscreen = true; let result = updateBuildingPlacementPreview(); // includes an update of the snap entity candidates if (result && result.cost) { let neededResources = Engine.GuiInterfaceCall("GetNeededResources", { "cost": result.cost }); placementSupport.tooltipMessage = [ getEntityCostTooltip(result), getNeededResourcesTooltip(neededResources) ].filter(tip => tip).join("\n"); } break; case "mousebuttondown": if (ev.button == SDL_BUTTON_LEFT) { let queued = Engine.HotkeyIsPressed("session.queue"); if (tryPlaceWall(queued)) { if (queued) { // Continue building, just set a new starting position where we left off. placementSupport.position = placementSupport.wallEndPosition; placementSupport.wallEndPosition = undefined; inputState = INPUT_BUILDING_WALL_CLICK; } else { placementSupport.Reset(); inputState = INPUT_NORMAL; } } else placementSupport.tooltipMessage = translate("Cannot build wall here!"); updateBuildingPlacementPreview(); return true; } if (ev.button == SDL_BUTTON_RIGHT) { placementSupport.Reset(); updateBuildingPlacementPreview(); inputState = INPUT_NORMAL; return true; } break; } break; case INPUT_BUILDING_DRAG: switch (ev.type) { case "mousemotion": let maxDragDelta = 16; if (g_DragStart.distanceTo(ev) >= maxDragDelta) // Rotate in the direction of the cursor. placementSupport.angle = placementSupport.position.horizAngleTo(Engine.GetTerrainAtScreenPoint(ev.x, ev.y)); else // If the cursor is near the center, snap back to the default orientation. placementSupport.SetDefaultAngle(); let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, "angle": placementSupport.angle, "snapToEdges": isSnapToEdgesEnabled() && Engine.GetEdgesOfStaticObstructionsOnScreenNearTo( placementSupport.position.x, placementSupport.position.z) }); if (snapData) { placementSupport.angle = snapData.angle; placementSupport.position.x = snapData.x; placementSupport.position.z = snapData.z; } updateBuildingPlacementPreview(); break; case "mousebuttonup": if (ev.button == SDL_BUTTON_LEFT) { // If queued, let the player continue placing another of the same structure. let queued = Engine.HotkeyIsPressed("session.queue"); if (tryPlaceBuilding(queued)) { if (queued && g_Selection.toList().length) inputState = INPUT_BUILDING_PLACEMENT; else inputState = INPUT_NORMAL; } else inputState = INPUT_BUILDING_PLACEMENT; return true; } break; case "mousebuttondown": if (ev.button == SDL_BUTTON_RIGHT) { // Cancel building. placementSupport.Reset(); inputState = INPUT_NORMAL; return true; } break; } break; case INPUT_BATCHTRAINING: if (ev.type == "hotkeyup" && ev.hotkey == "session.batchtrain") { flushTrainingBatch(); inputState = INPUT_NORMAL; } break; } return false; } function handleInputAfterGui(ev) { if (GetSimState().cinemaPlaying) return false; if (ev.hotkey === undefined) ev.hotkey = null; if (ev.hotkey == "session.highlightguarding") { g_ShowGuarding = (ev.type == "hotkeydown"); updateAdditionalHighlight(); } else if (ev.hotkey == "session.highlightguarded") { g_ShowGuarded = (ev.type == "hotkeydown"); updateAdditionalHighlight(); } if (inputState != INPUT_NORMAL && inputState != INPUT_SELECTING) clickedEntity = INVALID_ENTITY; // State-machine processing: switch (inputState) { case INPUT_NORMAL: switch (ev.type) { case "mousemotion": let ent = Engine.PickEntityAtPoint(ev.x, ev.y); if (ent != INVALID_ENTITY) g_Selection.setHighlightList([ent]); else g_Selection.setHighlightList([]); return false; case "mousebuttondown": if (ev.button == SDL_BUTTON_LEFT) { g_DragStart = new Vector2D(ev.x, ev.y); inputState = INPUT_SELECTING; // If a single click occured, reset the clickedEntity. // Also set it if we're double/triple clicking and missed the unit earlier. if (ev.clicks == 1 || clickedEntity == INVALID_ENTITY) clickedEntity = Engine.PickEntityAtPoint(ev.x, ev.y); return true; } else if (ev.button == SDL_BUTTON_RIGHT) { if (!controlsPlayer(g_ViewedPlayer)) break; g_DragStart = new Vector2D(ev.x, ev.y); inputState = INPUT_UNIT_POSITION_START; } break; case "hotkeydown": if (ev.hotkey.indexOf("selection.group.") == 0) { let now = Date.now(); if (now - doublePressTimer < doublePressTime && ev.hotkey == prevHotkey) { if (ev.hotkey.indexOf("selection.group.select.") == 0) { let sptr = ev.hotkey.split("."); performGroup("snap", sptr[3]); } } else { let sptr = ev.hotkey.split("."); performGroup(sptr[2], sptr[3]); doublePressTimer = now; prevHotkey = ev.hotkey; } } break; } break; case INPUT_PRESELECTEDACTION: switch (ev.type) { case "mousemotion": let ent = Engine.PickEntityAtPoint(ev.x, ev.y); if (ent != INVALID_ENTITY) g_Selection.setHighlightList([ent]); else g_Selection.setHighlightList([]); return false; case "mousebuttondown": if (ev.button == SDL_BUTTON_LEFT && preSelectedAction != ACTION_NONE) { let action = determineAction(ev.x, ev.y); if (!action) break; if (!Engine.HotkeyIsPressed("session.queue") && !Engine.HotkeyIsPressed("session.orderone")) { preSelectedAction = ACTION_NONE; inputState = INPUT_NORMAL; } return doAction(action, ev); } if (ev.button == SDL_BUTTON_RIGHT && preSelectedAction != ACTION_NONE) { preSelectedAction = ACTION_NONE; inputState = INPUT_NORMAL; break; } default: // Slight hack: If selection is empty, reset the input state. if (g_Selection.toList().length == 0) { preSelectedAction = ACTION_NONE; inputState = INPUT_NORMAL; break; } } break; case INPUT_SELECTING: switch (ev.type) { case "mousemotion": if (g_DragStart.distanceTo(ev) >= g_MaxDragDelta) { inputState = INPUT_BANDBOXING; return false; } let ent = Engine.PickEntityAtPoint(ev.x, ev.y); if (ent != INVALID_ENTITY) g_Selection.setHighlightList([ent]); else g_Selection.setHighlightList([]); return false; case "mousebuttonup": if (ev.button == SDL_BUTTON_LEFT) { if (clickedEntity == INVALID_ENTITY) clickedEntity = Engine.PickEntityAtPoint(ev.x, ev.y); // Abort if we didn't click on an entity or if the entity was removed before the mousebuttonup event. if (clickedEntity == INVALID_ENTITY || !GetEntityState(clickedEntity)) { clickedEntity = INVALID_ENTITY; if (!Engine.HotkeyIsPressed("selection.add") && !Engine.HotkeyIsPressed("selection.remove")) { g_Selection.reset(); resetIdleUnit(); } inputState = INPUT_NORMAL; return true; } if (Engine.GetFollowedEntity() != clickedEntity) Engine.CameraFollow(0); let ents = []; if (ev.clicks == 1) ents = [clickedEntity]; else { let showOffscreen = Engine.HotkeyIsPressed("selection.offscreen"); let matchRank = true; let templateToMatch; if (ev.clicks == 2) { templateToMatch = GetEntityState(clickedEntity).identity.selectionGroupName; if (templateToMatch) matchRank = false; else // No selection group name defined, so fall back to exact match. templateToMatch = GetEntityState(clickedEntity).template; } else // Triple click // Select units matching exact template name (same rank). templateToMatch = GetEntityState(clickedEntity).template; // TODO: Should we handle "control all units" here as well? ents = Engine.PickSimilarPlayerEntities(templateToMatch, showOffscreen, matchRank, false); } if (Engine.HotkeyIsPressed("selection.add")) g_Selection.addList(ents); else if (Engine.HotkeyIsPressed("selection.remove")) g_Selection.removeList(ents); else { g_Selection.reset(); g_Selection.addList(ents); } inputState = INPUT_NORMAL; return true; } break; } break; case INPUT_UNIT_POSITION_START: switch (ev.type) { case "mousemotion": if (g_DragStart.distanceToSquared(ev) >= Math.square(g_MaxDragDelta)) { inputState = INPUT_UNIT_POSITION; return false; } break; case "mousebuttonup": inputState = INPUT_NORMAL; if (ev.button == SDL_BUTTON_RIGHT) { let action = determineAction(ev.x, ev.y); if (action) return doAction(action, ev); } break; } break; case INPUT_BUILDING_PLACEMENT: switch (ev.type) { case "mousemotion": placementSupport.position = Engine.GetTerrainAtScreenPoint(ev.x, ev.y); if (placementSupport.mode === "wall") { // Including only the on-screen towers in the next snap candidate list is sufficient here, since the user is // still selecting a starting point (which must necessarily be on-screen). (The update of the snap entities // itself happens in the call to updateBuildingPlacementPreview below.) placementSupport.wallSnapEntitiesIncludeOffscreen = false; } else { if (placementSupport.template && Engine.GuiInterfaceCall("GetNeededResources", { "cost": GetTemplateData(placementSupport.template).cost })) { placementSupport.Reset(); inputState = INPUT_NORMAL; return true; } if (isSnapToEdgesEnabled()) { // We need to reset the angle before the snapping to edges, // because we want to get the angle near to the default one. placementSupport.SetDefaultAngle(); } let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, "angle": placementSupport.angle, "snapToEdges": isSnapToEdgesEnabled() && Engine.GetEdgesOfStaticObstructionsOnScreenNearTo( placementSupport.position.x, placementSupport.position.z) }); if (snapData) { placementSupport.angle = snapData.angle; placementSupport.position.x = snapData.x; placementSupport.position.z = snapData.z; } } updateBuildingPlacementPreview(); // includes an update of the snap entity candidates return false; // continue processing mouse motion case "mousebuttondown": if (ev.button == SDL_BUTTON_LEFT) { if (placementSupport.mode === "wall") { let validPlacement = updateBuildingPlacementPreview(); if (validPlacement !== false) inputState = INPUT_BUILDING_WALL_CLICK; } else { placementSupport.position = Engine.GetTerrainAtScreenPoint(ev.x, ev.y); if (isSnapToEdgesEnabled()) { let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, "angle": placementSupport.angle, "snapToEdges": Engine.GetEdgesOfStaticObstructionsOnScreenNearTo( placementSupport.position.x, placementSupport.position.z) }); if (snapData) { placementSupport.angle = snapData.angle; placementSupport.position.x = snapData.x; placementSupport.position.z = snapData.z; } } g_DragStart = new Vector2D(ev.x, ev.y); inputState = INPUT_BUILDING_CLICK; } return true; } else if (ev.button == SDL_BUTTON_RIGHT) { // Cancel building. placementSupport.Reset(); inputState = INPUT_NORMAL; return true; } break; case "hotkeydown": let rotation_step = Math.PI / 12; // 24 clicks make a full rotation switch (ev.hotkey) { case "session.rotate.cw": placementSupport.angle += rotation_step; updateBuildingPlacementPreview(); break; case "session.rotate.ccw": placementSupport.angle -= rotation_step; updateBuildingPlacementPreview(); break; } break; } break; } return false; } function doAction(action, ev) { if (!controlsPlayer(g_ViewedPlayer)) return false; return handleUnitAction(Engine.GetTerrainAtScreenPoint(ev.x, ev.y), action); } function popOneFromSelection(action) { // Pick the first unit that can do this order. let unit = action.firstAbleEntity || g_Selection.find(entity => ["preSelectedActionCheck", "hotkeyActionCheck", "actionCheck"].some(method => g_UnitActions[action.type][method] && g_UnitActions[action.type][method](action.target || undefined, [entity]) )); if (unit) { g_Selection.removeList([unit]); return [unit]; } return null; } function positionUnitsFreehandSelectionMouseMove(ev) { // Converting the input line into a List of points. // For better performance the points must have a minimum distance to each other. let target = Vector2D.from3D(Engine.GetTerrainAtScreenPoint(ev.x, ev.y)); if (!g_FreehandSelection_InputLine.length || target.distanceToSquared(g_FreehandSelection_InputLine[g_FreehandSelection_InputLine.length - 1]) >= g_FreehandSelection_ResolutionInputLineSquared) g_FreehandSelection_InputLine.push(target); return false; } function positionUnitsFreehandSelectionMouseUp(ev) { inputState = INPUT_NORMAL; let inputLine = g_FreehandSelection_InputLine; g_FreehandSelection_InputLine = []; if (ev.button != SDL_BUTTON_RIGHT) return true; let lengthOfLine = 0; for (let i = 1; i < inputLine.length; ++i) lengthOfLine += inputLine[i].distanceTo(inputLine[i - 1]); let selection = g_Selection.toList().filter(ent => !!GetEntityState(ent).unitAI).sort((a, b) => a - b); // Checking the line for a minimum length to save performance. if (lengthOfLine < g_FreehandSelection_MinLengthOfLine || selection.length < g_FreehandSelection_MinNumberOfUnits) { let action = determineAction(ev.x, ev.y); return !!action && doAction(action, ev); } // Even distribution of the units on the line. let p0 = inputLine[0]; let entityDistribution = [p0]; let distanceBetweenEnts = lengthOfLine / (selection.length - 1); let freeDist = -distanceBetweenEnts; for (let i = 1; i < inputLine.length; ++i) { let p1 = inputLine[i]; freeDist += inputLine[i - 1].distanceTo(p1); while (freeDist >= 0) { p0 = Vector2D.sub(p0, p1).normalize().mult(freeDist).add(p1); entityDistribution.push(p0); freeDist -= distanceBetweenEnts; } } // Rounding errors can lead to missing or too many points. entityDistribution = entityDistribution.slice(0, selection.length); entityDistribution = entityDistribution.concat(new Array(selection.length - entityDistribution.length).fill(inputLine[inputLine.length - 1])); if (Vector2D.from3D(GetEntityState(selection[0]).position).distanceTo(entityDistribution[0]) + Vector2D.from3D(GetEntityState(selection[selection.length - 1]).position).distanceTo(entityDistribution[selection.length - 1]) > Vector2D.from3D(GetEntityState(selection[0]).position).distanceTo(entityDistribution[selection.length - 1]) + Vector2D.from3D(GetEntityState(selection[selection.length - 1]).position).distanceTo(entityDistribution[0])) entityDistribution.reverse(); Engine.PostNetworkCommand({ - "type": Engine.HotkeyIsPressed("session.attackmove") ? "attack-walk-custom" : "walk-custom", + "type": isAttackMovePressed() ? "attack-walk-custom" : "walk-custom", "entities": selection, "targetPositions": entityDistribution.map(pos => pos.toFixed(2)), "targetClasses": Engine.HotkeyIsPressed("session.attackmoveUnit") ? { "attack": ["Unit"] } : { "attack": ["Unit", "Structure"] }, "queued": Engine.HotkeyIsPressed("session.queue"), "formation": NULL_FORMATION, }); // Add target markers with a minimum distance of 5 to each other. let entitiesBetweenMarker = Math.ceil(5 / distanceBetweenEnts); for (let i = 0; i < entityDistribution.length; i += entitiesBetweenMarker) DrawTargetMarker({ "x": entityDistribution[i].x, "z": entityDistribution[i].y }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_walk", "entity": selection[0] }); return true; } function handleUnitAction(target, action) { if (!g_UnitActions[action.type] || !g_UnitActions[action.type].execute) { error("Invalid action.type " + action.type); return false; } let selection = Engine.HotkeyIsPressed("session.orderone") && popOneFromSelection(action) || g_Selection.toList(); // If the session.queue hotkey is down, add the order to the unit's order queue instead // of running it immediately. return g_UnitActions[action.type].execute(target, action, selection, Engine.HotkeyIsPressed("session.queue")); } function getEntityLimitAndCount(playerState, entType) { let ret = { "entLimit": undefined, "entCount": undefined, "entLimitChangers": undefined, "canBeAddedCount": undefined, "matchLimit": undefined, "matchCount": undefined, "type": undefined }; if (!playerState.entityLimits) return ret; let template = GetTemplateData(entType); let entCategory; let matchLimit; if (template.trainingRestrictions) { entCategory = template.trainingRestrictions.category; matchLimit = template.trainingRestrictions.matchLimit; ret.type = "training"; } else if (template.buildRestrictions) { entCategory = template.buildRestrictions.category; matchLimit = template.buildRestrictions.matchLimit; ret.type = "build"; } if (entCategory && playerState.entityLimits[entCategory] !== undefined) { ret.entLimit = playerState.entityLimits[entCategory] || 0; ret.entCount = playerState.entityCounts[entCategory] || 0; ret.entLimitChangers = playerState.entityLimitChangers[entCategory]; ret.canBeAddedCount = Math.max(ret.entLimit - ret.entCount, 0); } if (matchLimit) { ret.matchLimit = matchLimit; ret.matchCount = playerState.matchEntityCounts[entType] || 0; ret.canBeAddedCount = Math.min(Math.max(ret.entLimit - ret.entCount, 0), Math.max(ret.matchLimit - ret.matchCount, 0)); } return ret; } /** * Called by GUI when user clicks construction button. * @param {string} buildTemplate - Template name of the entity the user wants to build. */ function startBuildingPlacement(buildTemplate, playerState) { if (getEntityLimitAndCount(playerState, buildTemplate).canBeAddedCount == 0) return; // TODO: we should clear any highlight selection rings here. If the cursor was over an entity before going onto the GUI // to start building a structure, then the highlight selection rings are kept during the construction of the structure. // Gives the impression that somehow the hovered-over entity has something to do with the structure you're building. placementSupport.Reset(); let templateData = GetTemplateData(buildTemplate); if (templateData.wallSet) { placementSupport.mode = "wall"; placementSupport.wallSet = templateData.wallSet; inputState = INPUT_BUILDING_PLACEMENT; } else { placementSupport.mode = "building"; placementSupport.template = buildTemplate; inputState = INPUT_BUILDING_PLACEMENT; } if (templateData.attack && templateData.attack.Ranged && templateData.attack.Ranged.maxRange) placementSupport.attack = templateData.attack; } // Batch training: // When the user shift-clicks, we set these variables and switch to INPUT_BATCHTRAINING // When the user releases shift, or clicks on a different training button, we create the batched units var g_BatchTrainingEntities; var g_BatchTrainingType; var g_NumberOfBatches; var g_BatchTrainingEntityAllowedCount; var g_BatchSize = getDefaultBatchTrainingSize(); function OnTrainMouseWheel(dir) { if (!Engine.HotkeyIsPressed("session.batchtrain")) return; g_BatchSize += dir / Engine.ConfigDB_GetValue("user", "gui.session.scrollbatchratio"); if (g_BatchSize < 1 || !Number.isFinite(g_BatchSize)) g_BatchSize = 1; updateSelectionDetails(); } function getBuildingsWhichCanTrainEntity(entitiesToCheck, trainEntType) { return entitiesToCheck.filter(entity => { let state = GetEntityState(entity); return state && state.production && state.production.entities.length && state.production.entities.indexOf(trainEntType) != -1 && (!state.upgrade || !state.upgrade.isUpgrading); }); } function initBatchTrain() { registerConfigChangeHandler(changes => { if (changes.has("gui.session.batchtrainingsize")) updateDefaultBatchSize(); }); } function getDefaultBatchTrainingSize() { let num = +Engine.ConfigDB_GetValue("user", "gui.session.batchtrainingsize"); return Number.isInteger(num) && num > 0 ? num : 5; } function getBatchTrainingSize() { return Math.max(Math.round(g_BatchSize), 1); } function updateDefaultBatchSize() { g_BatchSize = getDefaultBatchTrainingSize(); } /** * Add the unit shown at position to the training queue for all entities in the selection. * @param {number} position - The position of the template to train. */ function addTrainingByPosition(position) { let playerState = GetSimState().players[Engine.GetPlayerID()]; let selection = g_Selection.toList(); if (!playerState || !selection.length) return; let trainableEnts = getAllTrainableEntitiesFromSelection(); let entToTrain = trainableEnts[position]; if (!entToTrain) return; addTrainingToQueue(selection, entToTrain, playerState); } // Called by GUI when user clicks training button function addTrainingToQueue(selection, trainEntType, playerState) { let appropriateBuildings = getBuildingsWhichCanTrainEntity(selection, trainEntType); let canBeAddedCount = getEntityLimitAndCount(playerState, trainEntType).canBeAddedCount; let decrement = Engine.HotkeyIsPressed("selection.remove"); let template; if (!decrement) template = GetTemplateData(trainEntType); // Batch training only possible if we can train at least 2 units. if (Engine.HotkeyIsPressed("session.batchtrain") && (canBeAddedCount == undefined || canBeAddedCount > 1)) { if (inputState == INPUT_BATCHTRAINING) { // Check if we are training in the same structure(s) as the last batch. // NOTE: We just check if the arrays are the same and if the order is the same. // If the order changed, we have a new selection and we should create a new batch. // If we're already creating a batch of this unit (in the same structure(s)), then just extend it // (if training limits allow). if (g_BatchTrainingEntities.length == selection.length && g_BatchTrainingEntities.every((ent, i) => ent == selection[i]) && g_BatchTrainingType == trainEntType) { if (decrement) { --g_NumberOfBatches; if (g_NumberOfBatches <= 0) inputState = INPUT_NORMAL; } else if (canBeAddedCount == undefined || canBeAddedCount > g_NumberOfBatches * getBatchTrainingSize() * appropriateBuildings.length) { if (Engine.GuiInterfaceCall("GetNeededResources", { "cost": multiplyEntityCosts(template, (g_NumberOfBatches + 1) * getBatchTrainingSize()) })) return; ++g_NumberOfBatches; } g_BatchTrainingEntityAllowedCount = canBeAddedCount; return; } else if (!decrement) flushTrainingBatch(); } if (decrement || Engine.GuiInterfaceCall("GetNeededResources", { "cost": multiplyEntityCosts(template, getBatchTrainingSize()) })) return; inputState = INPUT_BATCHTRAINING; g_BatchTrainingEntities = selection; g_BatchTrainingType = trainEntType; g_BatchTrainingEntityAllowedCount = canBeAddedCount; g_NumberOfBatches = 1; } else { let buildingsForTraining = appropriateBuildings; if (canBeAddedCount !== undefined) buildingsForTraining = buildingsForTraining.slice(0, canBeAddedCount); Engine.PostNetworkCommand({ "type": "train", "template": trainEntType, "count": 1, "entities": buildingsForTraining }); } } /** * Returns the number of units that will be present in a batch if the user clicks * the training button depending on the batch training modifier hotkey. */ function getTrainingStatus(selection, trainEntType, playerState) { let appropriateBuildings = getBuildingsWhichCanTrainEntity(selection, trainEntType); let nextBatchTrainingCount = 0; let canBeAddedCount; if (inputState == INPUT_BATCHTRAINING && g_BatchTrainingType == trainEntType) { nextBatchTrainingCount = g_NumberOfBatches * getBatchTrainingSize(); canBeAddedCount = g_BatchTrainingEntityAllowedCount; } else canBeAddedCount = getEntityLimitAndCount(playerState, trainEntType).canBeAddedCount; // We need to calculate count after the next increment if possible. if ((canBeAddedCount == undefined || canBeAddedCount > nextBatchTrainingCount * appropriateBuildings.length) && Engine.HotkeyIsPressed("session.batchtrain")) nextBatchTrainingCount += getBatchTrainingSize(); nextBatchTrainingCount = Math.max(nextBatchTrainingCount, 1); // If training limits don't allow us to train batchedSize in each appropriate structure, // train as many full batches as we can and the remainder in one more structure. let buildingsCountToTrainFullBatch = appropriateBuildings.length; let remainderToTrain = 0; if (canBeAddedCount !== undefined && canBeAddedCount < nextBatchTrainingCount * appropriateBuildings.length) { buildingsCountToTrainFullBatch = Math.floor(canBeAddedCount / nextBatchTrainingCount); remainderToTrain = canBeAddedCount % nextBatchTrainingCount; } return [buildingsCountToTrainFullBatch, nextBatchTrainingCount, remainderToTrain]; } function flushTrainingBatch() { let batchedSize = g_NumberOfBatches * getBatchTrainingSize(); let appropriateBuildings = getBuildingsWhichCanTrainEntity(g_BatchTrainingEntities, g_BatchTrainingType); // If training limits don't allow us to train batchedSize in each appropriate structure. if (g_BatchTrainingEntityAllowedCount !== undefined && g_BatchTrainingEntityAllowedCount < batchedSize * appropriateBuildings.length) { // Train as many full batches as we can. let buildingsCountToTrainFullBatch = Math.floor(g_BatchTrainingEntityAllowedCount / batchedSize); Engine.PostNetworkCommand({ "type": "train", "entities": appropriateBuildings.slice(0, buildingsCountToTrainFullBatch), "template": g_BatchTrainingType, "count": batchedSize }); // Train remainer in one more structure. let remainer = g_BatchTrainingEntityAllowedCount % batchedSize; if (remainer) Engine.PostNetworkCommand({ "type": "train", "entities": [appropriateBuildings[buildingsCountToTrainFullBatch]], "template": g_BatchTrainingType, "count": remainer }); } else Engine.PostNetworkCommand({ "type": "train", "entities": appropriateBuildings, "template": g_BatchTrainingType, "count": batchedSize }); } function performGroup(action, groupId) { switch (action) { case "snap": case "select": case "add": let toSelect = []; g_Groups.update(); for (let ent in g_Groups.groups[groupId].ents) toSelect.push(+ent); if (action != "add") g_Selection.reset(); g_Selection.addList(toSelect); if (action == "snap" && toSelect.length) { let entState = GetEntityState(toSelect[0]); let position = entState.position; if (position && entState.visibility != "hidden") Engine.CameraMoveTo(position.x, position.z); } break; case "save": case "breakUp": g_Groups.groups[groupId].reset(); if (action == "save") g_Groups.addEntities(groupId, g_Selection.toList()); updateGroups(); break; } } var lastIdleUnit = 0; var currIdleClassIndex = 0; var lastIdleClasses = []; function resetIdleUnit() { lastIdleUnit = 0; currIdleClassIndex = 0; lastIdleClasses = []; } function findIdleUnit(classes) { let append = Engine.HotkeyIsPressed("selection.add"); let selectall = Engine.HotkeyIsPressed("selection.offscreen"); // Reset the last idle unit, etc., if the selection type has changed. if (selectall || classes.length != lastIdleClasses.length || !classes.every((v, i) => v === lastIdleClasses[i])) resetIdleUnit(); lastIdleClasses = classes; let data = { "viewedPlayer": g_ViewedPlayer, "excludeUnits": append ? g_Selection.toList() : [], // If the current idle class index is not 0, put the class at that index first. "idleClasses": classes.slice(currIdleClassIndex, classes.length).concat(classes.slice(0, currIdleClassIndex)) }; if (!selectall) { data.limit = 1; data.prevUnit = lastIdleUnit; } let idleUnits = Engine.GuiInterfaceCall("FindIdleUnits", data); if (!idleUnits.length) { // TODO: display a message or play a sound to indicate no more idle units, or something // Reset for next cycle resetIdleUnit(); return; } if (!append) g_Selection.reset(); g_Selection.addList(idleUnits); if (selectall) return; lastIdleUnit = idleUnits[0]; let entityState = GetEntityState(lastIdleUnit); if (entityState.position) Engine.CameraMoveTo(entityState.position.x, entityState.position.z); // Move the idle class index to the first class an idle unit was found for. let indexChange = data.idleClasses.findIndex(elem => MatchesClassList(entityState.identity.classes, elem)); currIdleClassIndex = (currIdleClassIndex + indexChange) % classes.length; } function clearSelection() { if (inputState==INPUT_BUILDING_PLACEMENT || inputState==INPUT_BUILDING_WALL_PATHING) { inputState = INPUT_NORMAL; placementSupport.Reset(); } else g_Selection.reset(); preSelectedAction = ACTION_NONE; } Index: ps/trunk/binaries/data/mods/public/gui/session/unit_actions.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/unit_actions.js (revision 24751) +++ ps/trunk/binaries/data/mods/public/gui/session/unit_actions.js (revision 24752) @@ -1,1629 +1,1629 @@ /** * Specifies which template should indicate the target location of a player command, * given a command type. */ var g_TargetMarker = { "move": "special/target_marker" }; /** * Which enemy entity types will be attacked on sight when patroling. */ var g_PatrolTargets = ["Unit"]; const g_DisabledTags = { "color": "255 140 0" }; /** * List of different actions units can execute, * this is mostly used to determine which actions can be executed * * "execute" is meant to send the command to the engine * * The next functions will always return false * in case you have to continue to seek * (i.e. look at the next entity for getActionInfo, the next * possible action for the actionCheck ...) * They will return an object when the searching is finished * * "getActionInfo" is used to determine if the action is possible, * and also give visual feedback to the user (tooltips, cursors, ...) * * "preSelectedActionCheck" is used to select actions when the gui buttons * were used to set them, but still require a target (like the guard button) * * "hotkeyActionCheck" is used to check the possibility of actions when * a hotkey is pressed * * "actionCheck" is used to check the possibilty of actions without specific * command. For that, the specificness variable is used * * "specificness" is used to determine how specific an action is, * The lower the number, the more specific an action is, and the bigger * the chance of selecting that action when multiple actions are possible */ var g_UnitActions = { "move": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "walk", "entities": selection, "x": target.x, "z": target.z, "queued": queued, "formation": g_AutoFormation.getDefault() }); DrawTargetMarker(target); Engine.GuiInterfaceCall("PlaySound", { "name": "order_walk", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.unitAI) return false; return { "possible": true }; }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.move") && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("move", target, selection); return actionInfo.possible && { "type": "move", "firstAbleEntity": actionInfo.entity }; }, "specificness": 12, }, "attack-move": { "execute": function(target, action, selection, queued) { let targetClasses; if (Engine.HotkeyIsPressed("session.attackmoveUnit")) targetClasses = { "attack": ["Unit"] }; else targetClasses = { "attack": ["Unit", "Structure"] }; Engine.PostNetworkCommand({ "type": "attack-walk", "entities": selection, "x": target.x, "z": target.z, "targetClasses": targetClasses, "queued": queued, "formation": g_AutoFormation.getNull() }); DrawTargetMarker(target); Engine.GuiInterfaceCall("PlaySound", { "name": "order_walk", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.unitAI) return false; return { "possible": true }; }, "hotkeyActionCheck": function(target, selection) { - return Engine.HotkeyIsPressed("session.attackmove") && + return isAttackMovePressed() && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("attack-move", target, selection); return actionInfo.possible && { "type": "attack-move", "cursor": "action-attack-move", "firstAbleEntity": actionInfo.entity }; }, "specificness": 30, }, "capture": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "attack", "entities": selection, "target": action.target, "allowCapture": true, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_attack", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.attack || !targetState || !targetState.capturePoints) return false; return { "possible": Engine.GuiInterfaceCall("CanAttack", { "entity": entState.id, "target": targetState.id, "types": ["Capture"] }) }; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("capture", target, selection); return actionInfo.possible && { "type": "capture", "cursor": "action-capture", "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 9, }, "attack": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "attack", "entities": selection, "target": action.target, "queued": queued, "allowCapture": false, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_attack", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.attack || !targetState || !targetState.hitpoints) return false; return { "possible": Engine.GuiInterfaceCall("CanAttack", { "entity": entState.id, "target": targetState.id, "types": ["!Capture"] }) }; }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.attack") && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("attack", target, selection); return actionInfo.possible && { "type": "attack", "cursor": "action-attack", "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 10, }, "patrol": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "patrol", "entities": selection, "x": target.x, "z": target.z, "target": action.target, "targetClasses": { "attack": g_PatrolTargets }, "queued": queued, "allowCapture": false, "formation": g_AutoFormation.getDefault() }); DrawTargetMarker(target); Engine.GuiInterfaceCall("PlaySound", { "name": "order_patrol", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.unitAI || !entState.unitAI.canPatrol) return false; return { "possible": true }; }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.patrol") && this.actionCheck(target, selection); }, "preSelectedActionCheck": function(target, selection) { return preSelectedAction == ACTION_PATROL && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("patrol", target, selection); return actionInfo.possible && { "type": "patrol", "cursor": "action-patrol", "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 37, }, "heal": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "heal", "entities": selection, "target": action.target, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_heal", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.heal || !targetState || !hasClass(targetState, "Unit") || !targetState.needsHeal || !playerCheck(entState, targetState, ["Player", "Ally"]) || entState.id == targetState.id) // Healers can't heal themselves. return false; let unhealableClasses = entState.heal.unhealableClasses; if (MatchesClassList(targetState.identity.classes, unhealableClasses)) return false; let healableClasses = entState.heal.healableClasses; if (!MatchesClassList(targetState.identity.classes, healableClasses)) return false; return { "possible": true }; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("heal", target, selection); return actionInfo.possible && { "type": "heal", "cursor": "action-heal", "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 7, }, // "Fake" action to check if an entity can be ordered to "construct" // which is handled differently from repair as the target does not exist. "construct": { "preSelectedActionCheck": function(target, selection) { let state = GetEntityState(selection[0]); if (state && state.builder && target && target.constructor && target.constructor.name == "PlacementSupport") return { "type": "construct" }; return false; }, "specificness": 0, }, "repair": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "repair", "entities": selection, "target": action.target, "autocontinue": true, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": action.foundation ? "order_build" : "order_repair", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.builder || !targetState || !targetState.needsRepair && !targetState.foundation || !playerCheck(entState, targetState, ["Player", "Ally"])) return false; return { "possible": true, "foundation": targetState.foundation }; }, "preSelectedActionCheck": function(target, selection) { return preSelectedAction == ACTION_REPAIR && (this.actionCheck(target, selection) || { "type": "none", "cursor": "action-repair-disabled", "target": null }); }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.repair") && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("repair", target, selection); return actionInfo.possible && { "type": "repair", "cursor": "action-repair", "target": target, "foundation": actionInfo.foundation, "firstAbleEntity": actionInfo.entity }; }, "specificness": 11, }, "gather": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "gather", "entities": selection, "target": action.target, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_gather", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.resourceGatherRates || !targetState || !targetState.resourceSupply) return false; let resource; if (entState.resourceGatherRates[targetState.resourceSupply.type.generic + "." + targetState.resourceSupply.type.specific]) resource = targetState.resourceSupply.type.specific; else if (entState.resourceGatherRates[targetState.resourceSupply.type.generic]) resource = targetState.resourceSupply.type.generic; if (!resource) return false; return { "possible": true, "cursor": "action-gather-" + resource }; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("gather", target, selection); return actionInfo.possible && { "type": "gather", "cursor": actionInfo.cursor, "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 1, }, "returnresource": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "returnresource", "entities": selection, "target": action.target, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_gather", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!targetState || !targetState.resourceDropsite) return false; let playerState = GetSimState().players[entState.player]; if (playerState.hasSharedDropsites && targetState.resourceDropsite.shared) { if (!playerCheck(entState, targetState, ["Player", "MutualAlly"])) return false; } else if (!playerCheck(entState, targetState, ["Player"])) return false; if (!entState.resourceCarrying || !entState.resourceCarrying.length) return false; let carriedType = entState.resourceCarrying[0].type; if (targetState.resourceDropsite.types.indexOf(carriedType) == -1) return false; return { "possible": true, "cursor": "action-return-" + carriedType }; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("returnresource", target, selection); return actionInfo.possible && { "type": "returnresource", "cursor": actionInfo.cursor, "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 2, }, "cancel-setup-trade-route": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "cancel-setup-trade-route", "entities": selection, "target": action.target, "queued": queued }); return true; }, "getActionInfo": function(entState, targetState) { if (!targetState || targetState.foundation || !entState.trader || !targetState.market || playerCheck(entState, targetState, ["Enemy"]) || !(targetState.market.land && hasClass(entState, "Organic") || targetState.market.naval && hasClass(entState, "Ship"))) return false; let tradingDetails = Engine.GuiInterfaceCall("GetTradingDetails", { "trader": entState.id, "target": targetState.id }); if (!tradingDetails || !tradingDetails.type) return false; if (tradingDetails.type == "is first" && !tradingDetails.hasBothMarkets) return { "possible": true, "tooltip": translate("This is the origin trade market.\nRight-click to cancel trade route.") }; return false; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("cancel-setup-trade-route", target, selection); return actionInfo.possible && { "type": "cancel-setup-trade-route", "cursor": "action-cancel-setup-trade-route", "tooltip": actionInfo.tooltip, "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 2, }, "setup-trade-route": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "setup-trade-route", "entities": selection, "target": action.target, "source": null, "route": null, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_trade", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!targetState || targetState.foundation || !entState.trader || !targetState.market || playerCheck(entState, targetState, ["Enemy"]) || !(targetState.market.land && hasClass(entState, "Organic") || targetState.market.naval && hasClass(entState, "Ship"))) return false; let tradingDetails = Engine.GuiInterfaceCall("GetTradingDetails", { "trader": entState.id, "target": targetState.id }); if (!tradingDetails) return false; let tooltip; switch (tradingDetails.type) { case "is first": tooltip = translate("Origin trade market.") + "\n"; if (tradingDetails.hasBothMarkets) tooltip += sprintf(translate("Gain: %(gain)s"), { "gain": getTradingTooltip(tradingDetails.gain) }); else return false; break; case "is second": tooltip = translate("Destination trade market.") + "\n" + sprintf(translate("Gain: %(gain)s"), { "gain": getTradingTooltip(tradingDetails.gain) }); break; case "set first": tooltip = translate("Right-click to set as origin trade market"); break; case "set second": if (tradingDetails.gain.traderGain == 0) return { "possible": true, "tooltip": setStringTags(translate("This market is too close to the origin market."), g_DisabledTags), "disabled": true }; tooltip = translate("Right-click to set as destination trade market.") + "\n" + sprintf(translate("Gain: %(gain)s"), { "gain": getTradingTooltip(tradingDetails.gain) }); break; } return { "possible": true, "tooltip": tooltip }; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("setup-trade-route", target, selection); if (actionInfo.disabled) return { "type": "none", "cursor": "action-setup-trade-route-disabled", "target": null, "tooltip": actionInfo.tooltip }; return actionInfo.possible && { "type": "setup-trade-route", "cursor": "action-setup-trade-route", "tooltip": actionInfo.tooltip, "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 0, }, "garrison": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "garrison", "entities": selection, "target": action.target, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_garrison", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.garrisonable || !targetState || !targetState.garrisonHolder || !playerCheck(entState, targetState, ["Player", "MutualAlly"])) return false; let tooltip = sprintf(translate("Current garrison: %(garrisoned)s/%(capacity)s"), { "garrisoned": targetState.garrisonHolder.garrisonedEntitiesCount, "capacity": targetState.garrisonHolder.capacity }); let extraCount = 0; if (entState.garrisonHolder) extraCount += entState.garrisonHolder.garrisonedEntitiesCount; if (targetState.garrisonHolder.garrisonedEntitiesCount + extraCount >= targetState.garrisonHolder.capacity) tooltip = coloredText(tooltip, "orange"); if (!MatchesClassList(entState.identity.classes, targetState.garrisonHolder.allowedClasses)) return false; return { "possible": true, "tooltip": tooltip }; }, "preSelectedActionCheck": function(target, selection) { return preSelectedAction == ACTION_GARRISON && (this.actionCheck(target, selection) || { "type": "none", "cursor": "action-garrison-disabled", "target": null }); }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.garrison") && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("garrison", target, selection); return actionInfo.possible && { "type": "garrison", "cursor": "action-garrison", "tooltip": actionInfo.tooltip, "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 20, }, "guard": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "guard", "entities": selection, "target": action.target, "queued": queued, "formation": g_AutoFormation.getNull() }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_guard", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!targetState || !targetState.guard || entState.id == targetState.id || !playerCheck(entState, targetState, ["Player", "Ally"]) || !entState.unitAI || !entState.unitAI.canGuard) return false; return { "possible": true }; }, "preSelectedActionCheck": function(target, selection) { return preSelectedAction == ACTION_GUARD && (this.actionCheck(target, selection) || { "type": "none", "cursor": "action-guard-disabled", "target": null }); }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.guard") && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("guard", target, selection); return actionInfo.possible && { "type": "guard", "cursor": "action-guard", "target": target, "firstAbleEntity": actionInfo.entity }; }, "specificness": 40, }, "remove-guard": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "remove-guard", "entities": selection, "target": action.target, "queued": queued }); Engine.GuiInterfaceCall("PlaySound", { "name": "order_guard", "entity": action.firstAbleEntity }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.unitAI || !entState.unitAI.isGuarding) return false; return { "possible": true }; }, "hotkeyActionCheck": function(target, selection) { return Engine.HotkeyIsPressed("session.guard") && this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("remove-guard", target, selection); return actionInfo.possible && { "type": "remove-guard", "cursor": "action-remove-guard", "firstAbleEntity": actionInfo.entity }; }, "specificness": 41, }, "set-rallypoint": { "execute": function(target, action, selection, queued) { // if there is a position set in the action then use this so that when setting a // rally point on an entity it is centered on that entity if (action.position) target = action.position; Engine.PostNetworkCommand({ "type": "set-rallypoint", "entities": selection, "x": target.x, "z": target.z, "data": action.data, "queued": queued }); // Display rally point at the new coordinates, to avoid display lag Engine.GuiInterfaceCall("DisplayRallyPoint", { "entities": selection, "x": target.x, "z": target.z, "queued": queued }); return true; }, "getActionInfo": function(entState, targetState) { if (!entState.rallyPoint) return false; // Don't allow the rally point to be set on any of the currently selected entities (used for unset) // except if the autorallypoint hotkey is pressed and the target can produce entities. if (targetState && (!Engine.HotkeyIsPressed("session.autorallypoint") || !targetState.production || !targetState.production.entities.length)) for (let ent in g_Selection.selected) if (targetState.id == +ent) return false; let tooltip; let disabled = false; // default to walking there (or attack-walking if hotkey pressed) let data = { "command": "walk" }; let cursor = ""; - if (Engine.HotkeyIsPressed("session.attackmove")) + if (isAttackMovePressed()) { let targetClasses; if (Engine.HotkeyIsPressed("session.attackmoveUnit")) targetClasses = { "attack": ["Unit"] }; else targetClasses = { "attack": ["Unit", "Structure"] }; data.command = "attack-walk"; data.targetClasses = targetClasses; cursor = "action-attack-move"; } if (Engine.HotkeyIsPressed("session.repair") && targetState && (targetState.needsRepair || targetState.foundation) && playerCheck(entState, targetState, ["Player", "Ally"])) { data.command = "repair"; data.target = targetState.id; cursor = "action-repair"; } else if (targetState && targetState.garrisonHolder && playerCheck(entState, targetState, ["Player", "MutualAlly"])) { data.command = "garrison"; data.target = targetState.id; cursor = "action-garrison"; tooltip = sprintf(translate("Current garrison: %(garrisoned)s/%(capacity)s"), { "garrisoned": targetState.garrisonHolder.garrisonedEntitiesCount, "capacity": targetState.garrisonHolder.capacity }); if (targetState.garrisonHolder.garrisonedEntitiesCount >= targetState.garrisonHolder.capacity) tooltip = coloredText(tooltip, "orange"); } else if (targetState && targetState.resourceSupply) { let resourceType = targetState.resourceSupply.type; if (resourceType.generic == "treasure") cursor = "action-gather-" + resourceType.generic; else cursor = "action-gather-" + resourceType.specific; data.command = "gather-near-position"; data.resourceType = resourceType; data.resourceTemplate = targetState.template; if (!targetState.speed) { data.command = "gather"; data.target = targetState.id; } } else if (entState.market && targetState && targetState.market && entState.id != targetState.id && (!entState.market.naval || targetState.market.naval) && !playerCheck(entState, targetState, ["Enemy"])) { // Find a trader (if any) that this structure can train. let trader; if (entState.production && entState.production.entities.length) for (let i = 0; i < entState.production.entities.length; ++i) if ((trader = GetTemplateData(entState.production.entities[i]).trader)) break; let traderData = { "firstMarket": entState.id, "secondMarket": targetState.id, "template": trader }; let gain = Engine.GuiInterfaceCall("GetTradingRouteGain", traderData); if (gain) { data.command = "trade"; data.target = traderData.secondMarket; data.source = traderData.firstMarket; cursor = "action-setup-trade-route"; if (gain.traderGain) tooltip = translate("Right-click to establish a default route for new traders.") + "\n" + sprintf( trader ? translate("Gain: %(gain)s") : translate("Expected gain: %(gain)s"), { "gain": getTradingTooltip(gain) }); else { disabled = true; tooltip = setStringTags(translate("This market is too close to the origin market."), g_DisabledTags); cursor = "action-setup-trade-route-disabled"; } } } else if (targetState && (targetState.needsRepair || targetState.foundation) && playerCheck(entState, targetState, ["Ally"])) { data.command = "repair"; data.target = targetState.id; cursor = "action-repair"; } else if (targetState && playerCheck(entState, targetState, ["Enemy"])) { data.target = targetState.id; data.command = "attack"; cursor = "action-attack"; } return { "possible": true, "data": data, "position": targetState && targetState.position, "cursor": cursor, "disabled": disabled, "tooltip": tooltip }; }, "hotkeyActionCheck": function(target, selection) { // Hotkeys are checked in the actionInfo. return this.actionCheck(target, selection); }, "actionCheck": function(target, selection) { // We want commands to units take precedence. if (selection.some(ent => { let entState = GetEntityState(ent); return entState && !!entState.unitAI; })) return false; let actionInfo = getActionInfo("set-rallypoint", target, selection); if (actionInfo.disabled) return { "type": "none", "cursor": actionInfo.cursor, "target": null, "tooltip": actionInfo.tooltip }; return actionInfo.possible && { "type": "set-rallypoint", "cursor": actionInfo.cursor, "data": actionInfo.data, "tooltip": actionInfo.tooltip, "position": actionInfo.position, "firstAbleEntity": actionInfo.entity }; }, "specificness": 6, }, "unset-rallypoint": { "execute": function(target, action, selection, queued) { Engine.PostNetworkCommand({ "type": "unset-rallypoint", "entities": selection }); // Remove displayed rally point Engine.GuiInterfaceCall("DisplayRallyPoint", { "entities": [] }); return true; }, "getActionInfo": function(entState, targetState) { if (!targetState || entState.id != targetState.id || entState.unitAI || !entState.rallyPoint || !entState.rallyPoint.position) return false; return { "possible": true }; }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("unset-rallypoint", target, selection); return actionInfo.possible && { "type": "unset-rallypoint", "cursor": "action-unset-rally", "firstAbleEntity": actionInfo.entity }; }, "specificness": 11, }, // This is a "fake" action to show a failure cursor // when only uncontrollable entities are selected. "uncontrollable": { "execute": function(target, action, selection, queued) { return true; }, "actionCheck": function(target, selection) { // Only show this action if all entities are marked uncontrollable. let playerState = g_SimState.players[g_ViewedPlayer]; if (playerState && playerState.controlsAll || selection.some(ent => { let entState = GetEntityState(ent); return entState && entState.identity && entState.identity.controllable; })) return false; return { "type": "none", "cursor": "cursor-no", "tooltip": translatePlural("This entity cannot be controlled.", "These entities cannot be controlled.", selection.length) }; }, "specificness": 100, }, "none": { "execute": function(target, action, selection, queued) { return true; }, "specificness": 100, }, }; var g_UnitActionsSortedKeys = Object.keys(g_UnitActions).sort((a, b) => g_UnitActions[a].specificness - g_UnitActions[b].specificness); /** * Info and actions for the entity commands * Currently displayed in the bottom of the central panel */ var g_EntityCommands = { "unload-all": { "getInfo": function(entStates) { let count = 0; for (let entState of entStates) { if (!entState.garrisonHolder) continue; if (allowedPlayersCheck([entState], ["Player"])) count += entState.garrisonHolder.entities.length; else for (let entity of entState.garrisonHolder.entities) if (allowedPlayersCheck([GetEntityState(entity)], ["Player"])) ++count; } if (!count) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.unload") + translate("Unload All."), "icon": "garrison-out.png", "count": count, "enabled": true }; }, "execute": function() { unloadAll(); }, "allowedPlayers": ["Player", "Ally"] }, "delete": { "getInfo": function(entStates) { return entStates.some(entState => !isUndeletable(entState)) ? { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.kill") + translate("Destroy the selected units or structures.") + "\n" + colorizeHotkey( translate("Use %(hotkey)s to avoid the confirmation dialog."), "session.noconfirmation" ), "icon": "kill_small.png", "enabled": true } : { // Get all delete reasons and remove duplications "tooltip": entStates.map(entState => isUndeletable(entState)) .filter((reason, pos, self) => self.indexOf(reason) == pos && reason ).join("\n"), "icon": "kill_small_disabled.png", "enabled": false }; }, "execute": function(entStates) { let entityIDs = entStates.reduce( (ids, entState) => { if (!isUndeletable(entState)) ids.push(entState.id); return ids; }, []); if (!entityIDs.length) return; let deleteSelection = () => Engine.PostNetworkCommand({ "type": "delete-entities", "entities": entityIDs }); if (Engine.HotkeyIsPressed("session.noconfirmation")) deleteSelection(); else (new DeleteSelectionConfirmation(deleteSelection)).display(); }, "allowedPlayers": ["Player"] }, "stop": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.unitAI)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.stop") + translate("Abort the current order."), "icon": "stop.png", "enabled": true }; }, "execute": function(entStates) { if (entStates.length) stopUnits(entStates.map(entState => entState.id)); }, "allowedPlayers": ["Player"] }, "garrison": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.unitAI || entState.turretParent || false)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.garrison") + translate("Order the selected units to garrison in a structure or unit."), "icon": "garrison.png", "enabled": true }; }, "execute": function() { inputState = INPUT_PRESELECTEDACTION; preSelectedAction = ACTION_GARRISON; }, "allowedPlayers": ["Player"] }, "unload": { "getInfo": function(entStates) { if (entStates.every(entState => { if (!entState.unitAI || !entState.turretParent) return true; let parent = GetEntityState(entState.turretParent); return !parent || !parent.garrisonHolder || parent.garrisonHolder.entities.indexOf(entState.id) == -1; })) return false; return { "tooltip": translate("Unload"), "icon": "garrison-out.png", "enabled": true }; }, "execute": function() { unloadSelection(); }, "allowedPlayers": ["Player"] }, "repair": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.builder)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.repair") + translate("Order the selected units to repair a structure, ship, or siege engine."), "icon": "repair.png", "enabled": true }; }, "execute": function() { inputState = INPUT_PRESELECTEDACTION; preSelectedAction = ACTION_REPAIR; }, "allowedPlayers": ["Player"] }, "focus-rally": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.rallyPoint)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "camera.rallypointfocus") + translate("Focus on Rally Point."), "icon": "focus-rally.png", "enabled": true }; }, "execute": function(entStates) { // TODO: Would be nicer to cycle between the rallypoints of multiple entities instead of just using the first let focusTarget; for (let entState of entStates) if (entState.rallyPoint && entState.rallyPoint.position) { focusTarget = entState.rallyPoint.position; break; } if (!focusTarget) for (let entState of entStates) if (entState.position) { focusTarget = entState.position; break; } if (focusTarget) Engine.CameraMoveTo(focusTarget.x, focusTarget.z); }, "allowedPlayers": ["Player", "Observer"] }, "back-to-work": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.unitAI || !entState.unitAI.hasWorkOrders)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.backtowork") + translate("Back to Work"), "icon": "back-to-work.png", "enabled": true }; }, "execute": function() { backToWork(); }, "allowedPlayers": ["Player"] }, "add-guard": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.unitAI || !entState.unitAI.canGuard || entState.unitAI.isGuarding)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.guard") + translate("Order the selected units to guard a structure or unit."), "icon": "add-guard.png", "enabled": true }; }, "execute": function() { inputState = INPUT_PRESELECTEDACTION; preSelectedAction = ACTION_GUARD; }, "allowedPlayers": ["Player"] }, "remove-guard": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.unitAI || !entState.unitAI.isGuarding)) return false; return { "tooltip": translate("Remove guard"), "icon": "remove-guard.png", "enabled": true }; }, "execute": function() { removeGuard(); }, "allowedPlayers": ["Player"] }, "select-trading-goods": { "getInfo": function(entStates) { if (entStates.every(entState => !entState.market)) return false; return { "tooltip": translate("Barter & Trade"), "icon": "economics.png", "enabled": true }; }, "execute": function() { g_TradeDialog.toggle(); }, "allowedPlayers": ["Player"] }, "patrol": { "getInfo": function(entStates) { if (!entStates.some(entState => entState.unitAI && entState.unitAI.canPatrol)) return false; return { "tooltip": colorizeHotkey("%(hotkey)s" + " ", "session.patrol") + translate("Patrol") + "\n" + translate("Attack all encountered enemy units while avoiding structures."), "icon": "patrol.png", "enabled": true }; }, "execute": function() { inputState = INPUT_PRESELECTEDACTION; preSelectedAction = ACTION_PATROL; }, "allowedPlayers": ["Player"] }, "share-dropsite": { "getInfo": function(entStates) { let sharableEntities = entStates.filter( entState => entState.resourceDropsite && entState.resourceDropsite.sharable); if (!sharableEntities.length) return false; // Returns if none of the entities belong to a player with a mutual ally. if (entStates.every(entState => !GetSimState().players[entState.player].isMutualAlly.some( (isAlly, playerId) => isAlly && playerId != entState.player))) return false; return sharableEntities.some(entState => !entState.resourceDropsite.shared) ? { "tooltip": translate("Press to allow allies to use this dropsite"), "icon": "locked_small.png", "enabled": true } : { "tooltip": translate("Press to prevent allies from using this dropsite"), "icon": "unlocked_small.png", "enabled": true }; }, "execute": function(entStates) { let sharableEntities = entStates.filter( entState => entState.resourceDropsite && entState.resourceDropsite.sharable); if (sharableEntities) Engine.PostNetworkCommand({ "type": "set-dropsite-sharing", "entities": sharableEntities.map(entState => entState.id), "shared": sharableEntities.some(entState => !entState.resourceDropsite.shared) }); }, "allowedPlayers": ["Player"] }, "is-dropsite-shared": { "getInfo": function(entStates) { let shareableEntities = entStates.filter( entState => entState.resourceDropsite && entState.resourceDropsite.sharable); if (!shareableEntities.length) return false; let player = Engine.GetPlayerID(); let simState = GetSimState(); if (!g_IsObserver && !simState.players[player].hasSharedDropsites || shareableEntities.every(entState => controlsPlayer(entState.player))) return false; if (!shareableEntities.every(entState => entState.resourceDropsite.shared)) return { "tooltip": translate("The use of this dropsite is prohibited"), "icon": "locked_small.png", "enabled": false }; return { "tooltip": g_IsObserver ? translate("Allies are allowed to use this dropsite.") : translate("You are allowed to use this dropsite"), "icon": "unlocked_small.png", "enabled": false }; }, "execute": function(entState) { // This command button is always disabled. }, "allowedPlayers": ["Ally", "Observer"] } }; function playerCheck(entState, targetState, validPlayers) { let playerState = GetSimState().players[entState.player]; for (let player of validPlayers) if (player == "Gaia" && targetState.player == 0 || player == "Player" && targetState.player == entState.player || playerState["is" + player] && playerState["is" + player][targetState.player]) return true; return false; } /** * Checks whether the entities have the right diplomatic status * with respect to the currently active player. * Also "Observer" can be used. * * @param {Object[]} entStates - An array containing the entity states to check. * @param {string[]} validPlayers - An array containing the diplomatic statuses. * * @return {boolean} - Whether the currently active player is allowed. */ function allowedPlayersCheck(entStates, validPlayers) { // Assume we can only select entities from one player, // or it does not matter (e.g. observer). let targetState = entStates[0]; let playerState = GetSimState().players[Engine.GetPlayerID()]; return validPlayers.some(player => player == "Observer" && g_IsObserver || player == "Player" && controlsPlayer(targetState.player) || playerState && playerState["is" + player] && playerState["is" + player][targetState.player]); } function hasClass(entState, className) { // note: use the functions in globalscripts/Templates.js for more versatile matching return entState.identity && entState.identity.classes.indexOf(className) != -1; } /** * Keep in sync with Commands.js. */ function isUndeletable(entState) { let playerState = g_SimState.players[entState.player]; if (playerState && playerState.controlsAll) return false; if (entState.resourceSupply && entState.resourceSupply.killBeforeGather) return translate("The entity has to be killed before it can be gathered from"); if (entState.capturePoints && entState.capturePoints[entState.player] < entState.maxCapturePoints / 2) return translate("You cannot destroy this entity as you own less than half the capture points"); if (!entState.identity.canDelete) return translate("This entity is undeletable"); return false; } function DrawTargetMarker(target) { Engine.GuiInterfaceCall("AddTargetMarker", { "template": g_TargetMarker.move, "x": target.x, "z": target.z }); } function getCommandInfo(command, entStates) { return entStates && g_EntityCommands[command] && allowedPlayersCheck(entStates, g_EntityCommands[command].allowedPlayers) && g_EntityCommands[command].getInfo(entStates); } function getActionInfo(action, target, selection) { if (!selection || !selection.length || !GetEntityState(selection[0])) return { "possible": false }; // Look at the first targeted entity // (TODO: maybe we eventually want to look at more, and be more context-sensitive? // e.g. prefer to attack an enemy unit, even if some friendly units are closer to the mouse) let targetState = GetEntityState(target); let simState = GetSimState(); let playerState = g_SimState.players[g_ViewedPlayer]; // Check if any entities in the selection can do some of the available actions. for (let entityID of selection) { let entState = GetEntityState(entityID); if (!entState) continue; if (playerState && !playerState.controlsAll && !entState.identity.controllable) continue; if (g_UnitActions[action] && g_UnitActions[action].getActionInfo) { let r = g_UnitActions[action].getActionInfo(entState, targetState, simState); if (r && r.possible) { r.entity = entityID; return r; } } } return { "possible": false }; } Index: ps/trunk/source/ps/Hotkey.cpp =================================================================== --- ps/trunk/source/ps/Hotkey.cpp (revision 24751) +++ ps/trunk/source/ps/Hotkey.cpp (revision 24752) @@ -1,377 +1,380 @@ /* Copyright (C) 2021 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * 0 A.D. is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with 0 A.D. If not, see . */ #include "precompiled.h" #include "Hotkey.h" #include #include "lib/external_libraries/libsdl.h" #include "ps/CConsole.h" #include "ps/CLogger.h" #include "ps/CStr.h" #include "ps/ConfigDB.h" #include "ps/Globals.h" #include "ps/KeyName.h" static bool unified[UNIFIED_LAST - UNIFIED_SHIFT]; std::unordered_map g_HotkeyMap; std::unordered_map g_HotkeyStatus; namespace { // List of currently pressed hotkeys. This is used to quickly reset hotkeys. // NB: this points to one of g_HotkeyMap's mappings. It works because that map is stable once constructed. std::vector pressedHotkeys; } static_assert(std::is_integral::type>::value, "SDL_Scancode is not an integral enum."); static_assert(SDL_USEREVENT_ == SDL_USEREVENT, "SDL_USEREVENT_ is not the same type as the real SDL_USEREVENT"); static_assert(UNUSED_HOTKEY_CODE == SDL_SCANCODE_UNKNOWN); // Look up each key binding in the config file and set the mappings for // all key combinations that trigger it. static void LoadConfigBindings(CConfigDB& configDB) { for (const std::pair& configPair : configDB.GetValuesWithPrefix(CFG_COMMAND, "hotkey.")) { std::string hotkeyName = configPair.first.substr(7); // strip the "hotkey." prefix // "unused" is kept or the A23->24 migration, this can likely be removed in A25. if (configPair.second.empty() || (configPair.second.size() == 1 && configPair.second.front() == "unused")) { // Unused hotkeys must still be registered in the map to appear in the hotkey editor. SHotkeyMapping unusedCode; unusedCode.name = hotkeyName; unusedCode.primary = SKey{ UNUSED_HOTKEY_CODE }; g_HotkeyMap[UNUSED_HOTKEY_CODE].push_back(unusedCode); continue; } for (const CStr& hotkey : configPair.second) { std::vector keyCombination; // Iterate through multiple-key bindings (e.g. Ctrl+I) boost::char_separator sep("+"); typedef boost::tokenizer > tokenizer; tokenizer tok(hotkey, sep); for (tokenizer::iterator it = tok.begin(); it != tok.end(); ++it) { // Attempt decode as key name SDL_Scancode scancode = FindScancode(it->c_str()); if (!scancode) { LOGWARNING("Hotkey mapping used invalid key '%s'", hotkey.c_str()); continue; } SKey key = { scancode }; keyCombination.push_back(key); } std::vector::iterator itKey, itKey2; for (itKey = keyCombination.begin(); itKey != keyCombination.end(); ++itKey) { SHotkeyMapping bindCode; bindCode.name = hotkeyName; bindCode.primary = SKey{ itKey->code }; for (itKey2 = keyCombination.begin(); itKey2 != keyCombination.end(); ++itKey2) if (itKey != itKey2) // Push any auxiliary keys bindCode.requires.push_back(*itKey2); g_HotkeyMap[itKey->code].push_back(bindCode); } } } } void LoadHotkeys(CConfigDB& configDB) { pressedHotkeys.clear(); LoadConfigBindings(configDB); } void UnloadHotkeys() { pressedHotkeys.clear(); g_HotkeyMap.clear(); g_HotkeyStatus.clear(); } bool isPressed(const SKey& key) { // Normal keycodes are below EXTRA_KEYS_BASE if ((int)key.code < EXTRA_KEYS_BASE) return g_scancodes[key.code]; // Mouse 'keycodes' are after the modifier keys else if ((int)key.code < MOUSE_LAST && (int)key.code > MOUSE_BASE) return g_mouse_buttons[key.code - MOUSE_BASE]; // Modifier keycodes are between the normal keys and the mouse 'keys' else if ((int)key.code < UNIFIED_LAST && (int)key.code > SDL_NUM_SCANCODES) return unified[key.code - UNIFIED_SHIFT]; // This codepath shouldn't be taken, but not having it triggers warnings. else return false; } InReaction HotkeyStateChange(const SDL_Event_* ev) { if (ev->ev.type == SDL_HOTKEYPRESS) g_HotkeyStatus[static_cast(ev->ev.user.data1)] = true; else if (ev->ev.type == SDL_HOTKEYUP) g_HotkeyStatus[static_cast(ev->ev.user.data1)] = false; return IN_PASS; } InReaction HotkeyInputHandler(const SDL_Event_* ev) { int scancode = SDL_SCANCODE_UNKNOWN; switch(ev->ev.type) { case SDL_KEYDOWN: case SDL_KEYUP: scancode = ev->ev.key.keysym.scancode; break; case SDL_MOUSEBUTTONDOWN: case SDL_MOUSEBUTTONUP: // Mousewheel events are no longer buttons, but we want to maintain the order // expected by g_mouse_buttons for compatibility if (ev->ev.button.button >= SDL_BUTTON_X1) scancode = MOUSE_BASE + (int)ev->ev.button.button + 2; else scancode = MOUSE_BASE + (int)ev->ev.button.button; break; case SDL_MOUSEWHEEL: if (ev->ev.wheel.y > 0) { scancode = MOUSE_WHEELUP; break; } else if (ev->ev.wheel.y < 0) { scancode = MOUSE_WHEELDOWN; break; } else if (ev->ev.wheel.x > 0) { scancode = MOUSE_X2; break; } else if (ev->ev.wheel.x < 0) { scancode = MOUSE_X1; break; } return IN_PASS; default: return IN_PASS; } // Somewhat hackish: // Create phantom 'unified-modifier' events when left- or right- modifier keys are pressed // Just send them to this handler; don't let the imaginary event codes leak back to real SDL. SDL_Event_ phantom; phantom.ev.type = ((ev->ev.type == SDL_KEYDOWN) || (ev->ev.type == SDL_MOUSEBUTTONDOWN)) ? SDL_KEYDOWN : SDL_KEYUP; if (phantom.ev.type == SDL_KEYDOWN) phantom.ev.key.repeat = ev->ev.type == SDL_KEYDOWN ? ev->ev.key.repeat : 0; if (scancode == SDL_SCANCODE_LSHIFT || scancode == SDL_SCANCODE_RSHIFT) { phantom.ev.key.keysym.scancode = static_cast(UNIFIED_SHIFT); unified[0] = (phantom.ev.type == SDL_KEYDOWN); HotkeyInputHandler(&phantom); } else if (scancode == SDL_SCANCODE_LCTRL || scancode == SDL_SCANCODE_RCTRL) { phantom.ev.key.keysym.scancode = static_cast(UNIFIED_CTRL); unified[1] = (phantom.ev.type == SDL_KEYDOWN); HotkeyInputHandler(&phantom); } else if (scancode == SDL_SCANCODE_LALT || scancode == SDL_SCANCODE_RALT) { phantom.ev.key.keysym.scancode = static_cast(UNIFIED_ALT); unified[2] = (phantom.ev.type == SDL_KEYDOWN); HotkeyInputHandler(&phantom); } else if (scancode == SDL_SCANCODE_LGUI || scancode == SDL_SCANCODE_RGUI) { phantom.ev.key.keysym.scancode = static_cast(UNIFIED_SUPER); unified[3] = (phantom.ev.type == SDL_KEYDOWN); HotkeyInputHandler(&phantom); } // Check whether we have any hotkeys registered for this particular keycode if (g_HotkeyMap.find(scancode) == g_HotkeyMap.end()) return (IN_PASS); // Inhibit the dispatch of hotkey events caused by real keys (not fake mouse button // events) while the console is up. bool consoleCapture = false; if (g_Console && g_Console->IsActive() && scancode < SDL_NUM_SCANCODES) consoleCapture = true; // Here's an interesting bit: // If you have an event bound to, say, 'F', and another to, say, 'Ctrl+F', pressing // 'F' while control is down would normally fire off both. // To avoid this, set the modifier keys for /all/ events this key would trigger // (Ctrl, for example, is both group-save and bookmark-save) // but only send a HotkeyPress/HotkeyDown event for the event with bindings most precisely // matching the conditions (i.e. the event with the highest number of auxiliary // keys, providing they're all down) // Furthermore, we need to support non-conflicting hotkeys triggering at the same time. // This is much more complex code than you might expect. A refactoring could be used. std::vector newPressedHotkeys; std::vector releasedHotkeys; size_t closestMapMatch = 0; bool release = (ev->ev.type == SDL_KEYUP) || (ev->ev.type == SDL_MOUSEBUTTONUP); SKey retrigger = { UNUSED_HOTKEY_CODE }; for (const SHotkeyMapping& hotkey : g_HotkeyMap[scancode]) { // If the key is being released, any active hotkey is released. if (release) { if (g_HotkeyStatus[hotkey.name]) { releasedHotkeys.push_back(hotkey.name.c_str()); // If we are releasing a key, we possibly need to retrigger less precise hotkeys // (e.g. 'Ctrl + D', if releasing D, we need to retrigger Ctrl hotkeys). // To do this simply, we'll just re-trigger any of the additional required key. if (!hotkey.requires.empty() && retrigger.code == UNUSED_HOTKEY_CODE) for (const SKey& k : hotkey.requires) if (isPressed(k)) { retrigger.code = hotkey.requires.front().code; break; } } continue; } // Check for no unpermitted keys bool accept = true; for (const SKey& k : hotkey.requires) { accept = isPressed(k); if (!accept) break; } if (accept && !(consoleCapture && hotkey.name != "console.toggle")) { // Check if this is an equally precise or more precise match if (hotkey.requires.size() + 1 >= closestMapMatch) { // Check if more precise if (hotkey.requires.size() + 1 > closestMapMatch) { // Throw away the old less-precise matches newPressedHotkeys.clear(); closestMapMatch = hotkey.requires.size() + 1; } newPressedHotkeys.push_back(&hotkey); } } } // If this is a new key, check if we need to unset any previous hotkey. // NB: this uses unsorted vectors because there are usually very few elements to go through // (and thus it is presumably faster than std::set). if ((ev->ev.type == SDL_KEYDOWN) || (ev->ev.type == SDL_MOUSEBUTTONDOWN)) for (const SHotkeyMapping* hotkey : pressedHotkeys) { - if (hotkey->requires.size() + 1 < closestMapMatch) + if (std::find_if(newPressedHotkeys.begin(), newPressedHotkeys.end(), + [&hotkey](const SHotkeyMapping* v){ return v->name == hotkey->name; }) != newPressedHotkeys.end()) + continue; + else if (hotkey->requires.size() + 1 < closestMapMatch) releasedHotkeys.push_back(hotkey->name.c_str()); - else if (std::find(newPressedHotkeys.begin(), newPressedHotkeys.end(), hotkey) == newPressedHotkeys.end()) + else { // We need to check that all 'keys' are still pressed (because of mouse buttons). if (!isPressed(hotkey->primary)) continue; for (const SKey& key : hotkey->requires) if (!isPressed(key)) continue; newPressedHotkeys.push_back(hotkey); } } pressedHotkeys.swap(newPressedHotkeys); // Mouse wheel events are released instantly. if (ev->ev.type == SDL_MOUSEWHEEL) for (const SHotkeyMapping* hotkey : pressedHotkeys) releasedHotkeys.push_back(hotkey->name.c_str()); for (const SHotkeyMapping* hotkey : pressedHotkeys) { // Send a KeyPress event when a hotkey is pressed initially and on mouseButton and mouseWheel events. if (ev->ev.type != SDL_KEYDOWN || ev->ev.key.repeat == 0) { SDL_Event_ hotkeyPressNotification; hotkeyPressNotification.ev.type = SDL_HOTKEYPRESS; hotkeyPressNotification.ev.user.data1 = const_cast(hotkey->name.c_str()); in_push_priority_event(&hotkeyPressNotification); } // Send a HotkeyDown event on every key, mouseButton and mouseWheel event. // For keys the event is repeated depending on hardware and OS configured interval. // On linux, modifier keys (shift, alt, ctrl) are not repeated, see https://github.com/SFML/SFML/issues/122. SDL_Event_ hotkeyDownNotification; hotkeyDownNotification.ev.type = SDL_HOTKEYDOWN; hotkeyDownNotification.ev.user.data1 = const_cast(hotkey->name.c_str()); in_push_priority_event(&hotkeyDownNotification); } for (const char* hotkeyName : releasedHotkeys) { SDL_Event_ hotkeyNotification; hotkeyNotification.ev.type = SDL_HOTKEYUP; hotkeyNotification.ev.user.data1 = const_cast(hotkeyName); in_push_priority_event(&hotkeyNotification); } if (retrigger.code != UNUSED_HOTKEY_CODE) { SDL_Event_ phantomKey; phantomKey.ev.type = SDL_KEYDOWN; phantomKey.ev.key.repeat = 0; phantomKey.ev.key.keysym.scancode = static_cast(retrigger.code); HotkeyInputHandler(&phantomKey); } return IN_PASS; } bool HotkeyIsPressed(const CStr& keyname) { return g_HotkeyStatus[keyname]; } Index: ps/trunk/source/ps/tests/test_Hotkeys.h =================================================================== --- ps/trunk/source/ps/tests/test_Hotkeys.h (revision 24751) +++ ps/trunk/source/ps/tests/test_Hotkeys.h (revision 24752) @@ -1,181 +1,214 @@ /* Copyright (C) 2021 Wildfire Games. * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ #include "lib/self_test.h" #include "lib/external_libraries/libsdl.h" #include "ps/Hotkey.h" #include "ps/ConfigDB.h" #include "ps/Globals.h" #include "ps/Filesystem.h" class TestHotkey : public CxxTest::TestSuite { CConfigDB* configDB; private: void fakeInput(const char* key, bool keyDown) { SDL_Event_ ev; ev.ev.type = keyDown ? SDL_KEYDOWN : SDL_KEYUP; ev.ev.key.repeat = 0; ev.ev.key.keysym.scancode = SDL_GetScancodeFromName(key); GlobalsInputHandler(&ev); HotkeyInputHandler(&ev); while(in_poll_priority_event(&ev)) HotkeyStateChange(&ev); } public: void setUp() { g_VFS = CreateVfs(); TS_ASSERT_OK(g_VFS->Mount(L"config", DataDir()/"_testconfig")); TS_ASSERT_OK(g_VFS->Mount(L"cache", DataDir()/"_testcache")); configDB = new CConfigDB; + + g_scancodes = {}; } void tearDown() { delete configDB; g_VFS.reset(); DeleteDirectory(DataDir()/"_testcache"); DeleteDirectory(DataDir()/"_testconfig"); } void test_Hotkeys() { configDB->SetValueString(CFG_SYSTEM, "hotkey.A", "A"); configDB->SetValueString(CFG_SYSTEM, "hotkey.AB", "A+B"); configDB->SetValueString(CFG_SYSTEM, "hotkey.ABC", "A+B+C"); - configDB->SetValueString(CFG_SYSTEM, "hotkey.D", "D"); + configDB->SetValueList(CFG_SYSTEM, "hotkey.D", { "D", "D+E" }); configDB->WriteFile(CFG_SYSTEM, "config/conf.cfg"); configDB->Reload(CFG_SYSTEM); UnloadHotkeys(); LoadHotkeys(*configDB); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); /** * Simple check. */ fakeInput("A", true); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); fakeInput("A", false); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); /** * Hotkey combinations: * - The most precise match only is selected * - Order does not matter. */ fakeInput("A", true); fakeInput("B", true); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); fakeInput("A", false); fakeInput("B", false); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); fakeInput("B", true); fakeInput("A", true); + // Activating the more precise hotkey AB untriggers "A" TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); fakeInput("A", false); fakeInput("B", false); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); fakeInput("A", true); fakeInput("B", true); fakeInput("B", false); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + fakeInput("A", false); + fakeInput("D", true); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); + + fakeInput("E", true); + // Changing from one hotkey to another more specific combination of the same hotkey keeps it active + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); + fakeInput("E", false); + // Likewise going the other way. + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); + } + + void test_quirk() + { + configDB->SetValueString(CFG_SYSTEM, "hotkey.A", "A"); + configDB->SetValueString(CFG_SYSTEM, "hotkey.AB", "A+B"); + configDB->SetValueString(CFG_SYSTEM, "hotkey.ABC", "A+B+C"); + configDB->SetValueList(CFG_SYSTEM, "hotkey.D", { "D", "D+E" }); + configDB->WriteFile(CFG_SYSTEM, "config/conf.cfg"); + configDB->Reload(CFG_SYSTEM); + + UnloadHotkeys(); + LoadHotkeys(*configDB); + /** * Quirk of the implementation: hotkeys are allowed to fire with too many keys. * Further, hotkeys of the same specificity (i.e. same # of required keys) * are allowed to fire at the same time if they don't conflict. * This is required so that e.g. up+left scrolls both up and left at the same time. */ fakeInput("A", true); fakeInput("D", true); // A+D isn't a hotkey; both A and D are active. TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); fakeInput("C", true); // A+D+C likewise. TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); fakeInput("B", true); // Here D is inactivated because it's lower-specificity than A+B+C (with D being ignored). TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); fakeInput("A", false); fakeInput("B", false); fakeInput("C", false); fakeInput("D", false); fakeInput("B", true); fakeInput("D", true); fakeInput("A", true); TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), true); TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); UnloadHotkeys(); } };