Index: binaries/data/mods/public/gui/session/FSM.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/FSM.js @@ -0,0 +1,376 @@ +// Hierarchical finite state machine implementation. +// +// FSMs are specified as a JS data structure; +// see e.g. UnitAI.js for an example of the syntax. +// +// FSMs are implicitly linked with an external object. +// That object stores all FSM-related state. +// (This means we can serialise FSM-based components as +// plain old JS objects, with no need to serialise the complex +// FSM structure itself or to add custom serialisation code.) + +/** + +FSM API: + +Users define the FSM behaviour like: + +var FsmSpec = { + + // Define some default message handlers: + + "MessageName1": function(msg) { + // This function will be called in response to calls to + // Fsm.ProcessMessage(this, { "type": "MessageName1", "data": msg }); + // + // In this function, 'this' is the component object passed into + // ProcessMessage, so you can access 'this.propertyName' + // and 'this.methodName()' etc. + }, + + "MessageName2": function(msg) { + // Another message handler. + }, + + // Define the behaviour for the 'STATENAME' state: + // Names of states may only contain the characters A-Z + "STATENAME": { + + "MessageName1": function(msg) { + // This overrides the previous MessageName1 that was + // defined earlier, and will be called instead of it + // in response to ProcessMessage. + }, + + // We don't override MessageName2, so the default one + // will be called instead. + + // Define the 'STATENAME.SUBSTATENAME' state: + // (we support arbitrarily-nested hierarchies of states) + "SUBSTATENAME": { + + "MessageName2": function(msg) { + // Override the default MessageName2. + // But we don't override MessageName1, so the one from + // STATENAME will be used instead. + }, + + "enter": function() { + // This is a special function called when transitioning + // into this state, or into a substate of this state. + // + // If it returns true, the transition will be aborted: + // do this if you've called SetNextState inside this enter + // handler, because otherwise the new state transition + // will get mixed up with the previous ongoing one. + // In normal cases, you can return false or nothing. + }, + + "leave": function() { + // Called when transitioning out of this state. + }, + }, + + // Define a new state which is an exact copy of another + // state that is defined elsewhere in this FSM: + "OTHERSUBSTATENAME": "STATENAME.SUBSTATENAME", + } + +} + + +Objects can then make themselves act as an instance of the FSM by running + FsmSpec.Init(this, "STATENAME"); +which will define a few properties on 'this' (with names prefixed "fsm"), +and then they can call the FSM functions on the object like + FsmSpec.SetNextState(this, "STATENAME.SUBSTATENAME"); + +These objects must also define a function property that can be called as + this.FsmStateNameChanged(name); + +(This design aims to avoid storing any per-instance state that cannot be +easily serialized - it only stores state-name strings.) + + */ + +function FSM(spec) +{ + // The (relatively) human-readable FSM specification needs to get + // compiled into a more-efficient-to-execute version. + // + // In particular, message handling should require minimal + // property lookups in the common case (even when the FSM has + // a deeply nested hierarchy), and there should never be any + // string manipulation at run-time. + + this.decompose = { "": [] }; + /* 'decompose' will store: + { + "": [], + "A": ["A"], + "A.B": ["A", "A.B"], + "A.B.C": ["A", "A.B", "A.B.C"], + "A.B.D": ["A", "A.B", "A.B.D"], + ... + }; + This is used when switching between states in different branches + of the hierarchy, to determine the list of sub-states to leave/enter + */ + + this.states = { }; + /* 'states' will store: + { + ... + "A": { + "_name": "A", + "_parent": "", + "_refs": { // local -> global name lookups (for SetNextState) + "B": "A.B", + "B.C": "A.B.C", + "B.D": "A.B.D", + }, + }, + "A.B": { + "_name": "A.B", + "_parent": "A", + "_refs": { + "C": "A.B.C", + "D": "A.B.D", + }, + "MessageType": function(msg) { ... }, + }, + "A.B.C": { + "_name": "A.B.C", + "_parent": "A.B", + "_refs": {}, + "enter": function() { ... }, + "MessageType": function(msg) { ... }, + }, + "A.B.D": { + "_name": "A.B.D", + "_parent": "A.B", + "_refs": {}, + "enter": function() { ... }, + "leave": function() { ... }, + "MessageType": function(msg) { ... }, + }, + ... + } + */ + + function process(fsm, node, path, handlers) + { + // Handle string references to nodes defined elsewhere in the FSM spec + if (typeof node === "string") + { + var refpath = node.split("."); + var refd = spec; + for (var p of refpath) + { + refd = refd[p]; + if (!refd) + { + error("FSM node "+path.join(".")+" referred to non-defined node "+node); + return {}; + } + } + node = refd; + } + + var state = {}; + fsm.states[path.join(".")] = state; + + var newhandlers = {}; + for (var e in handlers) + newhandlers[e] = handlers[e]; + + state._name = path.join("."); + state._parent = path.slice(0, -1).join("."); + state._refs = {}; + + for (var key in node) + { + if (key === "enter" || key === "leave") + { + state[key] = node[key]; + } + else if (key.match(/^[A-Z]+$/)) + { + state._refs[key] = (state._name ? state._name + "." : "") + key; + + // (the rest of this will be handled later once we've grabbed + // all the event handlers) + } + else + { + newhandlers[key] = node[key]; + } + } + + for (var e in newhandlers) + state[e] = newhandlers[e]; + + for (var key in node) + { + if (key.match(/^[A-Z]+$/)) + { + var newpath = path.concat([key]); + + var decomposed = [newpath[0]]; + for (var i = 1; i < newpath.length; ++i) + decomposed.push(decomposed[i-1] + "." + newpath[i]); + fsm.decompose[newpath.join(".")] = decomposed; + + var childstate = process(fsm, node[key], newpath, newhandlers); + + for (var r in childstate._refs) + { + var cname = key + "." + r; + state._refs[cname] = childstate._refs[r]; + } + } + } + + return state; + } + + process(this, spec, [], {}); +} + +FSM.prototype.Init = function(obj, initialState) +{ + this.deferFromState = undefined; + + obj.fsmStateName = ""; + obj.fsmNextState = undefined; + this.SwitchToNextState(obj, initialState); +}; + +FSM.prototype.SetNextState = function(obj, state) +{ + obj.fsmNextState = state; +}; + +FSM.prototype.ProcessMessage = function(obj, msg) +{ +// warn("ProcessMessage(obj, "+uneval(msg)+")"); + + var func = this.states[obj.fsmStateName][msg.type]; + if (!func) + { + error("Tried to process unhandled event '" + msg.type + "' in state '" + obj.fsmStateName + "'"); + return undefined; + } + + var ret = func.apply(obj, [msg]); + + // If func called SetNextState then switch into the new state, + // and continue switching if the new state's 'enter' called SetNextState again + while (obj.fsmNextState) + { + var nextStateName = this.LookupState(obj.fsmStateName, obj.fsmNextState); + obj.fsmNextState = undefined; + + this.SwitchToNextState(obj, nextStateName); + } + + return ret; +}; + +FSM.prototype.DeferMessage = function(obj, msg) +{ + // We need to work out which sub-state we were running the message handler from, + // and then try again in its parent state. + var old = this.deferFromState; + var from; + if (old) // if we're recursively deferring and saved the last used state, use that + from = old; + else // if this is the first defer then we must have last processed the message in the current FSM state + from = obj.fsmStateName; + + // Find and save the parent, for use in recursive defers + this.deferFromState = this.states[from]._parent; + + // Run the function from the parent state + var state = this.states[this.deferFromState]; + var func = state[msg.type]; + if (!func) + error("Failed to defer event '" + msg.type + "' from state '" + obj.fsmStateName + "'"); + func.apply(obj, [msg]); + + // Restore the changes we made + this.deferFromState = old; + + // TODO: if an inherited handler defers, it calls exactly the same handler + // on the parent state, which is probably useless and inefficient + + // NOTE: this will break if two units try to execute AI at the same time; + // as long as AI messages are queue and processed asynchronously it should be fine +}; + +FSM.prototype.LookupState = function(currentStateName, stateName) +{ +// print("LookupState("+currentStateName+", "+stateName+")\n"); + for (var s = currentStateName; s; s = this.states[s]._parent) + if (stateName in this.states[s]._refs) + return this.states[s]._refs[stateName]; + return stateName; +}; + +FSM.prototype.GetCurrentState = function(obj) +{ + return obj.fsmStateName; +}; + +FSM.prototype.SwitchToNextState = function(obj, nextStateName) +{ + var fromState = this.decompose[obj.fsmStateName]; + var toState = this.decompose[nextStateName]; + + if (!toState) + error("Tried to change to non-existent state '" + nextStateName + "'"); + + // Find the set of states in the hierarchy tree to leave then enter, + // to traverse from the old state to the new one. + // If any enter/leave function returns true then abort the process + // (this lets them intercept the transition and start a new transition) + + for (var equalPrefix = 0; fromState[equalPrefix] && fromState[equalPrefix] === toState[equalPrefix]; ++equalPrefix) + { + } + + // If the next-state is the same as the current state, leave/enter up one level so cleanup gets triggered. + if (equalPrefix > 0 && equalPrefix === toState.length) + --equalPrefix; + + for (var i = fromState.length-1; i >= equalPrefix; --i) + { + var leave = this.states[fromState[i]].leave; + if (leave) + { + obj.fsmStateName = fromState[i]; + if (leave.apply(obj)) + { + obj.FsmStateNameChanged(obj.fsmStateName); + return; + } + } + } + + for (var i = equalPrefix; i < toState.length; ++i) + { + var enter = this.states[toState[i]].enter; + if (enter) + { + obj.fsmStateName = toState[i]; + if (enter.apply(obj)) + { + obj.FsmStateNameChanged(obj.fsmStateName); + return; + } + } + } + + obj.fsmStateName = nextStateName; + obj.FsmStateNameChanged(obj.fsmStateName); +}; Index: binaries/data/mods/public/gui/session/input.js =================================================================== --- binaries/data/mods/public/gui/session/input.js +++ binaries/data/mods/public/gui/session/input.js @@ -12,33 +12,9 @@ // 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_MASSTRIBUTING = 10; -const INPUT_UNIT_POSITION_START = 11; -const INPUT_UNIT_POSITION = 12; - -var inputState = INPUT_NORMAL; - const INVALID_ENTITY = 0; -var mouseX = 0; -var mouseY = 0; +var g_MousePos = new Vector2D(0, 0); var mouseIsOverObject = false; /** @@ -84,14 +60,22 @@ var doublePressTimer = 0; var prevHotkey = 0; +/** + * FSM that processes gui events before and after the GUI depending of the input state. + */ +var g_InputEvents; + function updateCursorAndTooltip() { var cursorSet = false; var tooltipSet = false; var informationTooltip = Engine.GetGUIObjectByName("informationTooltip"); - if (!mouseIsOverObject && (inputState == INPUT_NORMAL || inputState == INPUT_PRESELECTEDACTION)) + if (!mouseIsOverObject && + (g_InputEvents.hasBaseState("NORMAL") || + g_InputEvents.hasBaseState("PRESELECTEDACTION")) + ) { - let action = determineAction(mouseX, mouseY); + let action = determineAction(g_MousePos); if (action) { if (action.cursor) @@ -207,14 +191,14 @@ /** * Determine the context-sensitive action that should be performed when the mouse is at (x,y) */ -function determineAction(x, y, fromMinimap) +function determineAction(position, fromMinimap) { var selection = g_Selection.toList(); // No action if there's no selection if (!selection.length) { - preSelectedAction = ACTION_NONE; + g_InputEvents.SetNextState("NORMAL"); return undefined; } @@ -235,7 +219,7 @@ var target = undefined; if (!fromMinimap) { - var ent = Engine.PickEntityAtPoint(x, y); + var ent = Engine.PickEntityAtPoint(position.x, position.y); if (ent != INVALID_ENTITY) target = ent; } @@ -247,7 +231,7 @@ actions.sort((a, b) => g_UnitActions[a].specificness - g_UnitActions[b].specificness); var actionInfo = undefined; - if (preSelectedAction != ACTION_NONE) + if (g_InputEvents.isInsideState("PRESELECTEDACTION")) { for (var action of actions) if (g_UnitActions[action].preSelectedActionCheck) @@ -380,9 +364,10 @@ 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; + + let bandboxObject = Engine.GetGUIObjectByName(bandbox) + bandboxObject.size = new GUISize(vMin.x / scale, vMin.y / scale, vMax.x / scale, vMax.y / scale); + bandboxObject.hidden = hidden; return [vMin.x, vMin.y, vMax.x, vMax.y]; } @@ -456,335 +441,23 @@ return preferredEnts; } +/** + * State machine processing: + * This is for states which should override the normal GUI processing. + * The events will be processed here before being passed on, and + * propagation will stop if this function returns true. + */ function handleInputBeforeGui(ev, hoveredObject) { if (GetSimState().cinemaPlaying) return false; - // Capture mouse 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; - } - // Remember whether the mouse is over a GUI object or not 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)) - closeMenu(); - - // 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: - var bandbox = Engine.GetGUIObjectByName("bandbox"); - switch (ev.type) - { - case "mousemotion": - var rect = updateBandbox(bandbox, ev, false); - - var ents = Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer); - var preferredEntities = getPreferredEntities(ents); - g_Selection.setHighlightList(preferredEntities); - - return false; - - case "mousebuttonup": - if (ev.button == SDL_BUTTON_LEFT) - { - var rect = updateBandbox(bandbox, ev, true); - - // Get list of entities limited to preferred entities - var ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(rect[0], rect[1], rect[2], rect[3], g_ViewedPlayer)); - - // Remove the bandbox hover highlighting - g_Selection.setHighlightList([]); - - // Update the list of selected units - 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; - } - else 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 shift is down, let the player continue placing another of the same building - var queued = Engine.HotkeyIsPressed("session.queue"); - if (tryPlaceBuilding(queued)) - { - if (queued) - 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 building 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; - var result = updateBuildingPlacementPreview(); // includes an update of the snap entity candidates - - if (result && result.cost) - { - var 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) - { - var 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; - } - else if (ev.button == SDL_BUTTON_RIGHT) - { - // reset to normal input mode - 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 mouse - placementSupport.angle = placementSupport.position.horizAngleTo(Engine.GetTerrainAtScreenPoint(ev.x, ev.y)); - } - else - { - // If the mouse is near the center, snap back to the default orientation - placementSupport.SetDefaultAngle(); - } - - var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { - "template": placementSupport.template, - "x": placementSupport.position.x, - "z": 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 shift is down, let the player continue placing another of the same building - var queued = Engine.HotkeyIsPressed("session.queue"); - if (tryPlaceBuilding(queued)) - { - if (queued) - 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_MASSTRIBUTING: - if (ev.type == "hotkeyup" && ev.hotkey == "session.masstribute") - { - g_FlushTributing(); - inputState = INPUT_NORMAL; - } - break; - - case INPUT_BATCHTRAINING: - if (ev.type == "hotkeyup" && ev.hotkey == "session.batchtrain") - { - flushTrainingBatch(); - inputState = INPUT_NORMAL; - } - break; - } - - return false; + return !g_InputEvents.isStateAfterGUI() ? + !!g_InputEvents.ProcessMessage({ type: ev.type, ev: ev }) + : false; } function handleInputAfterGui(ev) @@ -817,312 +490,29 @@ updateAdditionalHighlight(); } - if (inputState != INPUT_NORMAL && inputState != INPUT_SELECTING) - clickedEntity = INVALID_ENTITY; + return g_InputEvents.isStateAfterGUI() ? + !!g_InputEvents.ProcessMessage({ type: ev.type, ev: ev }) + : false; +} - // State-machine processing: +function groupSelectionAction(hotkey) +{ + if (!hotkey.startsWith("selection.group.")) + return; - switch (inputState) + let now = Date.now(); + let sptr = hotkey.split("."); + if (now - doublePressTimer < doublePressTime && hotkey == prevHotkey) { - case INPUT_NORMAL: - switch (ev.type) - { - case "mousemotion": - // Highlight the first hovered entity (if any) - var 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) - { - 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) - { - var sptr = ev.hotkey.split("."); - performGroup("snap", sptr[3]); - } - } - else - { - var sptr = ev.hotkey.split("."); - performGroup(sptr[2], sptr[3]); - - doublePressTimer = now; - prevHotkey = ev.hotkey; - } - } - break; - } - break; - - case INPUT_PRESELECTEDACTION: - switch (ev.type) - { - case "mousemotion": - // Highlight the first hovered entity (if any) - var 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) - { - var action = determineAction(ev.x, ev.y); - if (!action) - break; - if (!Engine.HotkeyIsPressed("session.queue")) - { - preSelectedAction = ACTION_NONE; - inputState = INPUT_NORMAL; - } - return doAction(action, ev); - } - else if (ev.button == SDL_BUTTON_RIGHT && preSelectedAction != ACTION_NONE) - { - preSelectedAction = ACTION_NONE; - inputState = INPUT_NORMAL; - break; - } - // else - 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 the mouse moved further than a limit, switch to bandbox mode - if (g_DragStart.distanceTo(ev) >= g_MaxDragDelta) - { - inputState = INPUT_BANDBOXING; - return false; - } - - var 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 camera following and we select different unit, stop - if (Engine.GetFollowedEntity() != clickedEntity) - Engine.CameraFollow(0); - - var ents = []; - if (ev.clicks == 1) - ents = [clickedEntity]; - else - { - // Double click or triple click has occurred - var showOffscreen = Engine.HotkeyIsPressed("selection.offscreen"); - var matchRank = true; - var templateToMatch; - - // Check for double click or triple click - if (ev.clicks == 2) - { - // Select similar units regardless of rank - 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); - } - - // Update the list of selected units - 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 the mouse moved further than a limit, switch to unit position mode - 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 - { - // cancel if not enough resources - if (placementSupport.template && Engine.GuiInterfaceCall("GetNeededResources", { "cost": GetTemplateData(placementSupport.template).cost })) - { - placementSupport.Reset(); - inputState = INPUT_NORMAL; - return true; - } - - var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { - "template": placementSupport.template, - "x": placementSupport.position.x, - "z": 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") - { - var validPlacement = updateBuildingPlacementPreview(); - if (validPlacement !== false) - inputState = INPUT_BUILDING_WALL_CLICK; - } - else - { - placementSupport.position = Engine.GetTerrainAtScreenPoint(ev.x, ev.y); - 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": - - var 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; + if (hotkey.startsWith("selection.group.select.")) + performGroup("snap", sptr[3]); + } + else + { + performGroup(sptr[2], sptr[3]); + doublePressTimer = now; + prevHotkey = hotkey; } - return false; } function doAction(action, ev) @@ -1174,7 +564,7 @@ function positionUnitsFreehandSelectionMouseUp(ev) { - inputState = INPUT_NORMAL; + g_InputEvents.SetNextState("NORMAL") let inputLine = g_FreehandSelection_InputLine; g_FreehandSelection_InputLine = []; if (ev.button != SDL_BUTTON_RIGHT) @@ -1189,7 +579,7 @@ // 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); + let action = determineAction(ev); return !!action && doAction(action, ev); } @@ -1248,11 +638,11 @@ // Partly duplicated from handleInputAfterGui(), but with the input being // world coordinates instead of screen coordinates. - if (inputState != INPUT_NORMAL) + if (!g_InputEvents.hasBaseState("NORMAL")) return false; var fromMinimap = true; - var action = determineAction(undefined, undefined, fromMinimap); + var action = determineAction(new Vector2D(undefined, undefined), fromMinimap); if (!action) return false; @@ -1308,13 +698,11 @@ { 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 && @@ -1324,10 +712,12 @@ // add attack information to display a good tooltip placementSupport.attack = templateData.attack; } + + g_InputEvents.SetNextState(templateData.wallSet ? "PLACEMENT.WALL" : "PLACEMENT.BUILDING"); } // Batch training: -// When the user shift-clicks, we set these variables and switch to INPUT_BATCHTRAINING +// When the user shift-clicks, we set these variables and switch to "BATCHTRAINING" // When the user releases shift, or clicks on a different training button, we create the batched units var g_BatchTrainingEntities; var g_BatchTrainingType; @@ -1403,7 +793,7 @@ // 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) + if (g_InputEvents.hasBaseState("BATCHTRAINING")) { // Check if we are training in the same building(s) as the last batch // NOTE: We just check if the arrays are the same and if the order is the same @@ -1418,7 +808,7 @@ { --g_NumberOfBatches; if (g_NumberOfBatches <= 0) - inputState = INPUT_NORMAL; + g_InputEvents.SetNextState("NORMAL"); } else if (canBeAddedCount == undefined || canBeAddedCount > g_NumberOfBatches * getBatchTrainingSize() * appropriateBuildings.length) @@ -1444,11 +834,11 @@ multiplyEntityCosts(template, getBatchTrainingSize()) })) return; - inputState = INPUT_BATCHTRAINING; g_BatchTrainingEntities = selection; g_BatchTrainingType = trainEntType; g_BatchTrainingEntityAllowedCount = canBeAddedCount; g_NumberOfBatches = 1; + g_InputEvents.SetNextState("BATCHTRAINING"); } else { @@ -1463,6 +853,7 @@ "count": 1, "entities": buildingsForTraining }); + g_InputEvents.SetNextState("NORMAL"); } } @@ -1476,7 +867,7 @@ let nextBatchTrainingCount = 0; let canBeAddedCount; - if (inputState == INPUT_BATCHTRAINING && g_BatchTrainingType == trainEntType) + if (g_InputEvents.BaseState() == "BATCHTRAINING" && g_BatchTrainingType == trainEntType) { nextBatchTrainingCount = g_NumberOfBatches * getBatchTrainingSize(); canBeAddedCount = g_BatchTrainingEntityAllowedCount; @@ -1637,13 +1028,6 @@ function clearSelection() { - if(inputState==INPUT_BUILDING_PLACEMENT || inputState==INPUT_BUILDING_WALL_PATHING) - { - inputState = INPUT_NORMAL; - placementSupport.Reset(); - } - else - g_Selection.reset(); - preSelectedAction = ACTION_NONE; + g_Selection.reset(); + g_InputEvents.SetNextState("NORMAL"); } - Index: binaries/data/mods/public/gui/session/input_events.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/input_events.js @@ -0,0 +1,504 @@ +function InputEvents(initalState) +{ + this.fsm = new FSM(this.events); + this.fsm.Init(this, initalState); + + // Check stateAfterGUI states exist + for (let state of this.stateAfterGUI) + this.warnIfStateNotDefined(state) +}; + +/** + * These states messages will be processed after the GUI + */ +InputEvents.prototype.stateAfterGUI = [ + "NORMAL", + "PRESELECTEDACTION", + "SELECTING.POINT", + "FREEHAND.DRAW", + "MENU" +]; + +InputEvents.prototype.FsmStateNameChanged = function (state) +{ + this.fsmStateName = state; +}; + +InputEvents.prototype.warnIfStateNotDefined = function (state) +{ + if (state in this.fsm.states) + return true + + let err = Error("stateAfterGUI state: " + state + " not defined"); + warn(err.message); + warn(err.stack); + return false; +} + +InputEvents.prototype.GetCurrentState = function () +{ + return this.fsmStateName; +}; + +InputEvents.prototype.BaseState = function () +{ + return this.fsmStateName.split(".")[0]; +}; + +InputEvents.prototype.SetNextState = function (state) +{ + return this.fsm.SetNextState(this, state); +}; + +InputEvents.prototype.SwitchToNextState = function (state) +{ + return this.fsm.SwitchToNextState(this, state); +}; + +InputEvents.prototype.LookupState = function (currentStateName, stateName) +{ + return this.fsm.LookupState(currentStateName, stateName); +}; + +InputEvents.prototype.ProcessMessage = function (msg) +{ + switch (msg.ev.type) + { + case "mousebuttonup": + case "mousebuttondown": + case "mousemotion": + g_MousePos = deepfreeze(new Vector2D(msg.ev.x, msg.ev.y)); + break; + } + return this.fsm.ProcessMessage(this, msg); +}; + +/** + * Check if the current state (including its children) + * is processed after the GUI events. + */ +InputEvents.prototype.isStateAfterGUI = function () +{ + return this.stateAfterGUI.some(state => + this.GetCurrentState().startsWith(state)); +}; + +/** + * @param {String} state + */ +InputEvents.prototype.isInsideState = function (state) +{ + this.warnIfStateNotDefined(state); + return this.GetCurrentState().startsWith(state); +}; + +/** + * @param {String} baseState + */ +InputEvents.prototype.hasBaseState = function (baseState) +{ + this.warnIfStateNotDefined(baseState); + return this.BaseState() == baseState; +}; + +InputEvents.prototype.events = { + "windowevent": function () { }, + "(unknown)": function () { }, + "mousebuttonup": function () { }, + "mousebuttondown": function () { }, + "mousemotion": function () { }, + "keydown": function () { }, + "keyup": function () { }, + "hotkeyup": function () { }, + "hotkeydown": function () { }, + "MENU": { + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + case SDL_BUTTON_RIGHT: + this.SetNextState("NORMAL"); + break; + } + } + }, + "FREEHAND": { + "enter": function () + { + g_DragStart = g_MousePos; + }, + "mousemotion": function (msg) + { + if (g_DragStart.distanceTo(msg.ev) >= g_MaxDragDelta) + this.SetNextState("FREEHAND.DRAW"); + }, + "mousebuttonup": function (msg) + { + this.SetNextState("NORMAL"); + }, + "DRAW": { + "mousemotion": function (msg) + { + return positionUnitsFreehandSelectionMouseMove(msg.ev); + }, + "mousebuttonup": function (msg) + { + return positionUnitsFreehandSelectionMouseUp(msg.ev); + } + } + }, + "MASSTRIBUTING": { + "leave": function () + { + g_FlushTributing(); + }, + "hotkeyup": function (msg) + { + if (msg.ev.hotkey == "session.masstribute") + this.SetNextState("NORMAL"); + } + }, + "BATCHTRAINING": { + "leave": function () + { + flushTrainingBatch(); + }, + "hotkeyup": function (msg) + { + if (msg.ev.hotkey == "session.batchtrain") + this.SetNextState("NORMAL"); + } + }, + "NORMAL": { + "leave": function () + { + clickedEntity = INVALID_ENTITY; + }, + "enter": function () + { + g_Selection.highlightEntityAtPoint(g_MousePos); + }, + "mousemotion": function (msg) + { + g_Selection.highlightEntityAtPoint(g_MousePos); + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + this.SetNextState("SELECTING.POINT"); + break; + case SDL_BUTTON_RIGHT: + this.SetNextState("FREEHAND"); + break; + } + return true; + }, + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_RIGHT: + let action = determineAction(g_MousePos); + if (action) + return doAction(action, msg.ev); + break; + } + }, + "hotkeydown": function (msg) + { + groupSelectionAction(msg.ev.hotkey); + } + }, + "PRESELECTEDACTION": { + "mousemotion": function (msg) + { + if (!g_Selection.toList().length) + { + this.SetNextState("NORMAL"); + return false; + } + g_Selection.highlightEntityAtPoint(g_MousePos); + }, + "mousebuttonup": function (msg) + { + if (!g_Selection.toList().length) + { + this.SetNextState("NORMAL"); + return false; + } + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + let action = determineAction(g_MousePos); + if (!action) + break; + if (!Engine.HotkeyIsPressed("session.queue")) + this.SetNextState("NORMAL"); + return doAction(action, msg.ev); + case SDL_BUTTON_RIGHT: + this.SetNextState("NORMAL"); + break; + } + }, + "GARRISON": {}, + "REPAIR": {}, + "PATROL": {}, + "GUARD": {} + }, + "SELECTING": { + "enter": function () + { + g_DragStart = g_MousePos; + }, + "POINT": { + "leave": function () + { + clickedEntity = INVALID_ENTITY; + }, + "mousemotion": function (msg) + { + if (g_DragStart.distanceTo(g_MousePos) >= g_MaxDragDelta) + this.SetNextState("SELECTING.BOX"); + else + g_Selection.highlightEntityAtPoint(g_MousePos); + }, + "mousebuttonup": function (msg) + { + if (msg.ev.button != SDL_BUTTON_LEFT) + return true; + + if (clickedEntity == INVALID_ENTITY) + clickedEntity = Engine.PickEntityAtPoint(g_MousePos.x, g_MousePos.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)) + { + if (!Engine.HotkeyIsPressed("selection.add") && + !Engine.HotkeyIsPressed("selection.remove")) + { + g_Selection.reset(); + resetIdleUnit(); + } + } + else + { + // If camera following and we select different unit, stop + if (Engine.GetFollowedEntity() != clickedEntity) + Engine.CameraFollow(0); + + g_Selection.processSelection( + this.getSimilarEntities(msg.ev.clicks, clickedEntity)); + } + + this.SetNextState("NORMAL"); + return true; + } + }, + "BOX": { + "leave": function () + { + Engine.GetGUIObjectByName("bandbox").hidden = true; + g_Selection.setHighlightList([]); + }, + "mousemotion": function (msg) + { + g_Selection.highlightEntitiesInsideBox(); + }, + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + g_Selection.selectionBoxProcessSelected(g_MousePos); + this.SetNextState("NORMAL"); + break; + case SDL_BUTTON_RIGHT: + this.SetNextState("NORMAL"); + break; + } + return true; + }, + "hotkeyup": function (msg) + { + if (msg.ev.hotkey == "camera.left" + || msg.ev.hotkey == "camera.right" + || msg.ev.hotkey == "camera.up" + || msg.ev.hotkey == "camera.down") + g_Selection.highlightEntitiesInsideBox(); + }, + "hotkeydown": function (msg) + { + if (msg.ev.hotkey == "camera.left" + || msg.ev.hotkey == "camera.right" + || msg.ev.hotkey == "camera.up" + || msg.ev.hotkey == "camera.down") + g_Selection.highlightEntitiesInsideBox(); + } + } + }, + "PLACEMENT": { + "leave": function () + { + updateBuildingPlacementPreview(); + placementSupport.Reset(); + }, + "mousemotion": function (msg) + { + placementSupport.showBuildingPlacementSnappedToTerrain(g_MousePos); + }, + "BUILDING": { + "enter": function () + { + placementSupport.showBuildingPlacementSnappedToTerrain(g_MousePos); + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + this.SetNextState("PLACEMENT.BUILDING.PLACE"); + break; + } + }, + "hotkeydown": function (msg) + { + let rotation_step = (2 * Math.PI) / 24; + switch (msg.ev.hotkey) + { + case "session.rotate.cw": + placementSupport.angle += rotation_step; + updateBuildingPlacementPreview(); + break; + case "session.rotate.ccw": + placementSupport.angle -= rotation_step; + updateBuildingPlacementPreview(); + break; + } + }, + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_RIGHT: + this.SetNextState("NORMAL"); + break; + } + return true; + }, + "PLACE": + { + "enter": function () + { + g_DragStart = g_MousePos; + placementSupport.position = Engine.GetTerrainAtScreenPoint(g_MousePos.x, g_MousePos.y); + }, + "mousemotion": function (msg) + { + let maxDragDelta = 16; + if (g_DragStart.distanceTo(g_MousePos) >= maxDragDelta) + this.SetNextState("PLACEMENT.BUILDING.PLACE.ROTATE"); + }, + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + let queue = Engine.HotkeyIsPressed("session.queue"); + if (!tryPlaceBuilding(queue) || queue) + this.SetNextState("PLACEMENT.BUILDING"); + else + this.SetNextState("NORMAL"); + break; + case SDL_BUTTON_RIGHT: + this.SetNextState("NORMAL"); + break; + } + return true; + }, + "ROTATE": { + "mousemotion": function (msg) + { + placementSupport.pointBuildingTowardsMousePosition(g_MousePos); + } + } + } + }, + "WALL": { + "enter": function () + { + placementSupport.showBuildingPlacementSnappedToTerrain(g_MousePos); + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + this.SetNextState("PLACEMENT.WALL.PLACE"); + break; + } + return true; + }, + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_RIGHT: + this.SetNextState("NORMAL"); + break; + } + return true; + }, + "PLACE": { + "enter": function () + { + placementSupport.position = Engine.GetTerrainAtScreenPoint(g_MousePos.x, g_MousePos.y); + placementSupport.previewWallPlacing(g_MousePos); + }, + "mousemotion": function (msg) + { + this.SetNextState("PLACEMENT.WALL.EXTEND"); + } + }, + "EXTEND": { + "mousemotion": function (msg) + { + placementSupport.previewWallPlacing(g_MousePos); + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + placementSupport.placeWall(); + break; + } + return true; + } + } + } + } +}; + +/** + * If 1 click: select this one entity + * If 2 clicks: select same units of any rank (same selectionGroupName) + * If 2> clicks or no selectionGroupName: select units of same rank (same template) + */ +InputEvents.prototype.getSimilarEntities = function (numClicks, entity) +{ + if (numClicks == 1) + return [entity]; + + let entityState = GetEntityState(entity); + let sameRank = numClicks > 2 || !entityState.identity.selectionGroupName; + let template = sameRank ? entityState.template : entityState.identity.selectionGroupName; + let showOffscreen = Engine.HotkeyIsPressed("selection.offscreen"); + + // TODO: Should we handle "control all units" here as well? + return Engine.PickSimilarPlayerEntities(template, showOffscreen, sameRank, false); +} Index: binaries/data/mods/public/gui/session/menu.js =================================================================== --- binaries/data/mods/public/gui/session/menu.js +++ binaries/data/mods/public/gui/session/menu.js @@ -54,8 +54,6 @@ */ var g_BarterSell; -var g_IsMenuOpen = false; - var g_IsDiplomacyOpen = false; var g_IsTradeOpen = false; var g_IsObjectivesOpen = false; @@ -86,15 +84,16 @@ function updateMenuPosition(dt) { let menu = Engine.GetGUIObjectByName("menu"); + let isMenuOpen = g_InputEvents.hasBaseState("MENU"); - let maxOffset = g_IsMenuOpen ? + let maxOffset = isMenuOpen ? END_MENU_POSITION - menu.size.bottom : menu.size.top - MENU_TOP; if (maxOffset <= 0) return; - let offset = Math.min(MENU_SPEED * dt, maxOffset) * (g_IsMenuOpen ? +1 : -1); + let offset = Math.min(MENU_SPEED * dt, maxOffset) * (isMenuOpen ? +1 : -1); let size = menu.size; size.top += offset; @@ -102,21 +101,9 @@ menu.size = size; } -// Opens the menu by revealing the screen which contains the menu -function openMenu() -{ - g_IsMenuOpen = true; -} - -// Closes the menu and resets position -function closeMenu() -{ - g_IsMenuOpen = false; -} - function toggleMenu() { - g_IsMenuOpen = !g_IsMenuOpen; + g_InputEvents.SetNextState(g_InputEvents.hasBaseState("MENU") ? "NORMAL" : "MENU"); } function optionsMenuButton() @@ -513,13 +500,13 @@ button.tooltip = formatTributeTooltip(i, resCode, 100); button.onPress = (function(i, resCode, button) { // Shift+click to send 500, shift+click+click to send 1000, etc. - // See INPUT_MASSTRIBUTING in input.js + // See "MASSTRIBUTING" in input.js let multiplier = 1; return function() { let isBatchTrainPressed = Engine.HotkeyIsPressed("session.masstribute"); if (isBatchTrainPressed) { - inputState = INPUT_MASSTRIBUTING; + g_InputEvents.SetNextState("MASSTRIBUTING"); multiplier += multiplier == 1 ? 4 : 5; } @@ -1262,7 +1249,7 @@ function closeOpenDialogs() { - closeMenu(); + g_InputEvents.SetNextState("NORMAL"); closeChat(); closeDiplomacy(); closeTrade(); Index: binaries/data/mods/public/gui/session/placement.js =================================================================== --- binaries/data/mods/public/gui/session/placement.js +++ binaries/data/mods/public/gui/session/placement.js @@ -29,6 +29,20 @@ Engine.GuiInterfaceCall("SetWallPlacementPreview", { "wallSet": null }); }; +PlacementSupport.prototype.SnapData = function(){ + var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + "template": this.template, + "x": this.position.x, + "z": this.position.z + }); + if (snapData) + { + this.angle = snapData.angle; + this.position.x = snapData.x; + this.position.z = snapData.z; + } +} + PlacementSupport.prototype.SetDefaultAngle = function() { this.angle = PlacementSupport.DEFAULT_ANGLE; @@ -39,4 +53,115 @@ this.actorSeed = randIntExclusive(0, Math.pow(2, 16)); }; +/** +* Update the building 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.prototype.previewWallPlacing = function (mousePos) +{ + this.wallEndPosition = Engine.GetTerrainAtScreenPoint(mousePos.x, mousePos.y); + this.wallSnapEntitiesIncludeOffscreen = true; + + // Includes an update of the snap entity candidates + let result = updateBuildingPlacementPreview(); + + if (result && result.cost) + { + let neededResources = Engine.GuiInterfaceCall("GetNeededResources", { "cost": result.cost }); + this.tooltipMessage = [ + getEntityCostTooltip(result), + getNeededResourcesTooltip(neededResources) + ].filter(tip => tip).join("\n"); + } +}; + + +/** + * 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. + */ +PlacementSupport.prototype.placeWall = function () +{ + let queued = Engine.HotkeyIsPressed("session.queue"); + if (tryPlaceWall(queued)) + { + if (queued) + { + // Continue building, just set a new starting position where we left off + this.position = this.wallEndPosition; + this.wallEndPosition = undefined; + g_InputEvents.SetNextState("PLACEMENT.WALL.PLACE"); + } + else + g_InputEvents.SetNextState("NORMAL"); + } + else + this.tooltipMessage = translate("Cannot build wall here!"); + + updateBuildingPlacementPreview(); +}; + +/** + * If the mouse is near the center, snap back to the default orientation otherwise + * rotate in the direction of the mouse + */ +PlacementSupport.prototype.pointBuildingTowardsMousePosition = function (mousePos) +{ + let maxDragDelta = 16; + if (g_DragStart.distanceTo(mousePos) >= maxDragDelta) + { + let coords = Engine.GetTerrainAtScreenPoint(mousePos.x, mousePos.y); + this.angle = this.position.horizAngleTo(coords); + } + else + this.SetDefaultAngle(); + + this.SnapData(); + updateBuildingPlacementPreview(); +}; + +PlacementSupport.prototype.showBuildingPlacementSnappedToTerrain = function (mousePos) +{ + this.position = Engine.GetTerrainAtScreenPoint(mousePos.x, mousePos.y); + + if (this.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). + */ + this.wallSnapEntitiesIncludeOffscreen = false; + } + else + { + // cancel if not enough resources + if (this.template && Engine.GuiInterfaceCall("GetNeededResources", { + "cost": GetTemplateData(this.template).cost + })) + { + this.SetNextState("NORMAL"); + return true; + } + + this.SnapData(); + } + + // Includes an update of the snap entity candidates + updateBuildingPlacementPreview(); +}; + var placementSupport = new PlacementSupport(); Index: binaries/data/mods/public/gui/session/selection.js =================================================================== --- binaries/data/mods/public/gui/session/selection.js +++ binaries/data/mods/public/gui/session/selection.js @@ -384,6 +384,13 @@ return ents; }; +EntitySelection.prototype.highlightEntityAtPoint = function (point) +{ + let ent = Engine.PickEntityAtPoint(point.x,point.y); + g_Selection.setHighlightList(ent == INVALID_ENTITY ? [] : [ent]); + return ent != INVALID_ENTITY; +} + EntitySelection.prototype.setHighlightList = function(ents) { var highlighted = {}; @@ -427,6 +434,40 @@ onSelectionChange(); }; +EntitySelection.prototype.processSelection = function (ents) +{ + // Update the list of selected units + if (Engine.HotkeyIsPressed("selection.add")) + this.addList(ents); + else if (Engine.HotkeyIsPressed("selection.remove")) + this.removeList(ents); + else + { + this.reset(); + this.addList(ents); + } +}; + +EntitySelection.prototype.selectionBoxProcessSelected = function (ev) +{ + let rect = updateBandbox("bandbox", ev, true); + + // Get list of entities limited to preferred entities + let ents = getPreferredEntities(Engine.PickPlayerEntitiesInRect(...rect, g_ViewedPlayer)); + + // Remove the bandbox hover highlighting + this.setHighlightList([]); + this.processSelection(ents); +} + +EntitySelection.prototype.highlightEntitiesInsideBox = function () +{ + this.setHighlightList(getPreferredEntities( + Engine.PickPlayerEntitiesInRect( + ...updateBandbox("bandbox", g_MousePos, false), g_ViewedPlayer) + )); +}; + /** * Cache some quantities which depends only on selection */ Index: binaries/data/mods/public/gui/session/session.js =================================================================== --- binaries/data/mods/public/gui/session/session.js +++ binaries/data/mods/public/gui/session/session.js @@ -287,6 +287,8 @@ sendLobbyPlayerlistUpdate(); onSimulationUpdate(); setTimeout(displayGamestateNotifications, 1000); + + g_InputEvents = new InputEvents("NORMAL"); } function initGUIObjects() Index: binaries/data/mods/public/gui/session/unit_actions.js =================================================================== --- binaries/data/mods/public/gui/session/unit_actions.js +++ binaries/data/mods/public/gui/session/unit_actions.js @@ -277,7 +277,7 @@ }, "preSelectedActionCheck": function(target, selection) { - if (preSelectedAction != ACTION_PATROL || !getActionInfo("patrol", target, selection).possible) + if ( !g_InputEvents.isInsideState("PRESELECTEDACTION.PATROL") || !getActionInfo("patrol", target, selection).possible) return false; return { "type": "patrol", @@ -368,7 +368,7 @@ }, "preSelectedActionCheck": function(target, selection) { - if (preSelectedAction != ACTION_REPAIR) + if (!g_InputEvents.isInsideState("PRESELECTEDACTION.REPAIR")) return false; if (getActionInfo("repair", target, selection).possible) @@ -656,7 +656,7 @@ }, "preSelectedActionCheck": function(target, selection) { - if (preSelectedAction != ACTION_GARRISON) + if (!g_InputEvents.isInsideState("PRESELECTEDACTION.GARRISON")) return false; let actionInfo = getActionInfo("garrison", target, selection); @@ -721,7 +721,7 @@ }, "preSelectedActionCheck": function(target, selection) { - if (preSelectedAction != ACTION_GUARD) + if (!g_InputEvents.isInsideState("PRESELECTEDACTION.GUARD")) return false; if (getActionInfo("guard", target, selection).possible) @@ -1109,9 +1109,8 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_GARRISON; - }, + g_InputEvents.SetNextState("PRESELECTEDACTION.GARRISON"); + } }, "unload": { @@ -1150,8 +1149,8 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_REPAIR; + g_InputEvents.SetNextState("PRESELECTEDACTION.REPAIR"); + }, }, @@ -1223,8 +1222,7 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_GUARD; + g_InputEvents.SetNextState("PRESELECTEDACTION.GUARD"); }, }, @@ -1277,8 +1275,7 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_PATROL; + g_InputEvents.SetNextState("PRESELECTEDACTION.PATROL"); }, },