Index: binaries/data/mods/public/gui/session/FSM.js =================================================================== --- /dev/null +++ binaries/data/mods/public/gui/session/FSM.js @@ -0,0 +1,386 @@ +// 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 and _ (undercore) + "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(" + this.GetCurrentState(obj) + ", " + JSON.stringify(msg) + ")"); + + var func = this.states[obj.fsmStateName][msg.type]; + if (!func) + { + let defaultFunc = this.states[obj.fsmStateName].default + if (!defaultFunc) + { + warn("Tried to process unhandled event '" + msg.type + "' in state '" + obj.fsmStateName + "'"); + return undefined; + } + func = defaultFunc; + } + + 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) + { + let defaultFunc = this.state.default + if (!defaultFunc) + warn("Failed to defer event '" + msg.type + "' from state '" + obj.fsmStateName + "'"); + func = defaultFunc; + } + 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; /** @@ -86,12 +62,12 @@ function updateCursorAndTooltip() { - var cursorSet = false; - var tooltipSet = false; - var informationTooltip = Engine.GetGUIObjectByName("informationTooltip"); - if (!mouseIsOverObject && (inputState == INPUT_NORMAL || inputState == INPUT_PRESELECTEDACTION)) + let cursorSet = false; + let tooltipSet = false; + let informationTooltip = Engine.GetGUIObjectByName("informationTooltip"); + if (!mouseIsOverObject && g_InputEvents.hasBaseState(["NORMAL", "PRESELECTEDACTION"])) { - let action = determineAction(mouseX, mouseY); + let action = determineAction(g_MousePos); if (action) { if (action.cursor) @@ -114,7 +90,7 @@ if (!tooltipSet) informationTooltip.hidden = true; - var placementTooltip = Engine.GetGUIObjectByName("placementTooltip"); + let placementTooltip = Engine.GetGUIObjectByName("placementTooltip"); if (placementSupport.tooltipMessage) placementTooltip.sprite = placementSupport.tooltipError ? "BackgroundErrorTooltip" : "BackgroundInformationTooltip"; @@ -132,7 +108,7 @@ { if (placementSupport.template && placementSupport.position) { - var result = Engine.GuiInterfaceCall("SetBuildingPlacementPreview", { + let result = Engine.GuiInterfaceCall("SetBuildingPlacementPreview", { "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, @@ -148,13 +124,13 @@ { if (result.message && result.parameters) { - var message = result.message; + let message = result.message; if (result.translateMessage) if (result.pluralMessage) message = translatePlural(result.message, result.pluralMessage, result.pluralCount); else message = translate(message); - var parameters = result.parameters; + let parameters = result.parameters; if (result.translateParameters) translateObjectKeys(parameters, result.translateParameters); placementSupport.tooltipMessage = sprintf(message, parameters); @@ -166,14 +142,14 @@ { // building can be placed here, and has an attack // show the range advantage in the tooltip - var cmd = { + let cmd = { "x": placementSupport.position.x, "z": placementSupport.position.z, "range": placementSupport.attack.Ranged.maxRange, "elevationBonus": placementSupport.attack.Ranged.elevationBonus, }; - var averageRange = Math.round(Engine.GuiInterfaceCall("GetAverageRangeForBuildings", cmd) - cmd.range); - var range = Math.round(cmd.range); + 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 }); } @@ -207,35 +183,35 @@ /** * Determine the context-sensitive action that should be performed when the mouse is at (x,y) */ -function determineAction(x, y, fromMinimap) +function determineAction(pos, fromMinimap) { - var selection = g_Selection.toList(); + let selection = g_Selection.toList(); // No action if there's no selection if (!selection.length) { - preSelectedAction = ACTION_NONE; + g_InputEvents.SwitchToNextState("NORMAL"); return undefined; } // If the selection doesn't exist, no action - var entState = GetEntityState(selection[0]); + let entState = GetEntityState(selection[0]); if (!entState) return undefined; // If the selection isn't friendly units, no action - var allOwnedByPlayer = selection.every(ent => { - var entState = GetEntityState(ent); + let allOwnedByPlayer = selection.every(ent => { + let entState = GetEntityState(ent); return entState && entState.player == g_ViewedPlayer; }); if (!g_DevSettings.controlAll && !allOwnedByPlayer) return undefined; - var target = undefined; + let target = undefined; if (!fromMinimap) { - var ent = Engine.PickEntityAtPoint(x, y); + let ent = Engine.PickEntityAtPoint(pos.x, pos.y); if (ent != INVALID_ENTITY) target = ent; } @@ -243,16 +219,16 @@ // decide between the following ordered actions // if two actions are possible, the first one is taken // so the most specific should appear first - var actions = Object.keys(g_UnitActions).slice(); + let actions = Object.keys(g_UnitActions).slice(); actions.sort((a, b) => g_UnitActions[a].specificness - g_UnitActions[b].specificness); - var actionInfo = undefined; - if (preSelectedAction != ACTION_NONE) + let actionInfo = undefined; + if (g_InputEvents.isInsideState("PRESELECTEDACTION")) { - for (var action of actions) + for (let action of actions) if (g_UnitActions[action].preSelectedActionCheck) { - var r = g_UnitActions[action].preSelectedActionCheck(target, selection); + let r = g_UnitActions[action].preSelectedActionCheck(target, selection); if (r) return r; } @@ -260,18 +236,18 @@ return { "type": "none", "cursor": "", "target": target }; } - for (var action of actions) + for (let action of actions) if (g_UnitActions[action].hotkeyActionCheck) { - var r = g_UnitActions[action].hotkeyActionCheck(target, selection); + let r = g_UnitActions[action].hotkeyActionCheck(target, selection); if (r) return r; } - for (var action of actions) + for (let action of actions) if (g_UnitActions[action].actionCheck) { - var r = g_UnitActions[action].actionCheck(target, selection); + let r = g_UnitActions[action].actionCheck(target, selection); if (r) return r; } @@ -294,7 +270,7 @@ return false; } - var selection = g_Selection.toList(); + let selection = g_Selection.toList(); Engine.PostNetworkCommand({ "type": "construct", @@ -326,8 +302,8 @@ return false; } - var wallPlacementInfo = updateBuildingPlacementPreview(); // entities making up the wall (wall segments, towers, ...) - if (!(wallPlacementInfo === false || typeof(wallPlacementInfo) === "object")) + 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; @@ -336,8 +312,8 @@ if (!wallPlacementInfo) return false; - var selection = g_Selection.toList(); - var cmd = { + let selection = g_Selection.toList(); + let cmd = { "type": "construct-wall", "autorepair": true, "autocontinue": true, @@ -352,7 +328,7 @@ // 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) - var hasWallSegment = false; + let hasWallSegment = false; for (let piece of cmd.pieces) { if (piece.template != cmd.wallSet.templates.tower) // TODO: hardcode-ish :( @@ -377,12 +353,13 @@ */ function updateBandbox(bandbox, ev, hidden) { + let bandboxObject = Engine.GetGUIObjectByName(bandbox) 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; + 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 +433,24 @@ 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; - } + mouseIsOverObject = hoveredObject != null; - 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; - } + if (g_InputEvents.isStateAfterGUI()) + return false; - return false; + return !!g_InputEvents.ProcessMessage({ type: ev.type, ev: ev }) } function handleInputAfterGui(ev) @@ -817,312 +483,10 @@ updateAdditionalHighlight(); } - if (inputState != INPUT_NORMAL && inputState != INPUT_SELECTING) - clickedEntity = INVALID_ENTITY; - - // State-machine processing: - - switch (inputState) - { - 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; + if (!g_InputEvents.isStateAfterGUI()) + return false; - } - break; - } - return false; + return !!g_InputEvents.ProcessMessage({ type: ev.type, ev: ev }) } function doAction(action, ev) @@ -1174,7 +538,7 @@ function positionUnitsFreehandSelectionMouseUp(ev) { - inputState = INPUT_NORMAL; + g_InputEvents.SwitchToNextState("NORMAL") let inputLine = g_FreehandSelection_InputLine; g_FreehandSelection_InputLine = []; if (ev.button != SDL_BUTTON_RIGHT) @@ -1189,7 +553,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,17 +612,17 @@ // 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); + let fromMinimap = true; + let action = determineAction(new Vector2D(undefined, undefined), fromMinimap); if (!action) return false; - var selection = g_Selection.toList(); + let selection = g_Selection.toList(); - var queued = Engine.HotkeyIsPressed("session.queue"); + let queued = Engine.HotkeyIsPressed("session.queue"); if (g_UnitActions[action.type] && g_UnitActions[action.type].execute) return g_UnitActions[action.type].execute(target, action, selection, queued); error("Invalid action.type " + action.type); @@ -1293,7 +657,7 @@ // @param buildTemplate Template name of the entity the user wants to build function startBuildingPlacement(buildTemplate, playerState) { - if(getEntityLimitAndCount(playerState, buildTemplate).canBeAddedCount == 0) + if (getEntityLimitAndCount(playerState, buildTemplate).canBeAddedCount == 0) return; // TODO: we should clear any highlight selection rings here. If the mouse was over an entity before going onto the GUI @@ -1303,18 +667,16 @@ placementSupport.Reset(); // find out if we're building a wall, and change the entity appropriately if so - var templateData = GetTemplateData(buildTemplate); + 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 && @@ -1324,10 +686,12 @@ // add attack information to display a good tooltip placementSupport.attack = templateData.attack; } + + g_InputEvents.SwitchToNextState("PLACEMENT." + (templateData.wallSet ? "WALL" : "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 +767,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 +782,7 @@ { --g_NumberOfBatches; if (g_NumberOfBatches <= 0) - inputState = INPUT_NORMAL; + g_InputEvents.SwitchToNextState("NORMAL"); } else if (canBeAddedCount == undefined || canBeAddedCount > g_NumberOfBatches * getBatchTrainingSize() * appropriateBuildings.length) @@ -1444,11 +808,11 @@ multiplyEntityCosts(template, getBatchTrainingSize()) })) return; - inputState = INPUT_BATCHTRAINING; g_BatchTrainingEntities = selection; g_BatchTrainingType = trainEntType; g_BatchTrainingEntityAllowedCount = canBeAddedCount; g_NumberOfBatches = 1; + g_InputEvents.SwitchToNextState("BATCHTRAINING"); } else { @@ -1463,6 +827,7 @@ "count": 1, "entities": buildingsForTraining }); + g_InputEvents.SwitchToNextState("NORMAL"); } } @@ -1476,7 +841,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; @@ -1546,9 +911,9 @@ case "snap": case "select": case "add": - var toSelect = []; + let toSelect = []; g_Groups.update(); - for (var ent in g_Groups.groups[groupId].ents) + for (let ent in g_Groups.groups[groupId].ents) toSelect.push(+ent); if (action != "add") @@ -1576,6 +941,26 @@ } } +function groupSelectionAction(hotkey) +{ + if (!hotkey.startsWith("selection.group.")) + return; + + let now = Date.now(); + let sptr = hotkey.split("."); + if (now - doublePressTimer < doublePressTime && hotkey == prevHotkey) + { + if (hotkey.startsWith("selection.group.select.")) + performGroup("snap", sptr[3]); + } + else + { + performGroup(sptr[2], sptr[3]); + doublePressTimer = now; + prevHotkey = hotkey; + } +} + var lastIdleUnit = 0; var currIdleClassIndex = 0; var lastIdleClasses = []; @@ -1589,11 +974,11 @@ function findIdleUnit(classes) { - var append = Engine.HotkeyIsPressed("selection.add"); - var selectall = Engine.HotkeyIsPressed("selection.offscreen"); + 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])) + if (selectall || classes.length != lastIdleClasses.length || !classes.every((v, i) => v === lastIdleClasses[i])) resetIdleUnit(); lastIdleClasses = classes; @@ -1637,13 +1022,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.SwitchToNextState("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,458 @@ +function InputEvents(initState) +{ + this.FsmStateNameChanged(initState); +}; + +/** + * These states messages will be processed after the GUI + */ +InputEvents.prototype.stateAfterGUI = [ + "NORMAL", + "PRESELECTEDACTION", + "SELECTING.POINT", + "FREEHAND.START", + "PLACEMENT", + "MENU" +]; + +InputEvents.prototype.Init = function () +{ + this.fsm = new FSM(this.events); + this.fsm.Init(this, this.GetCurrentState()); +}; + +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); +}; + +InputEvents.prototype.FsmStateNameChanged = function (state) +{ + this.fsmStateName = state; +}; + +/** + * If a state is after GUI then all his substates are too. + */ +InputEvents.prototype.isStateAfterGUI = function () +{ + return this.stateAfterGUI.some(state => this.GetCurrentState().startsWith(state)); +}; + +/** + * @param {String | String[]} state + */ +InputEvents.prototype.isInsideState = function (state) +{ + if (typeof state == 'string' || state instanceof String) + return this.GetCurrentState().startsWith(state); + else // array + return state.findIndex(thisState => this.GetCurrentState().startsWith(thisState)) != -1; +}; + +/** + * @param {String | String[]} baseState + */ +InputEvents.prototype.hasBaseState = function (baseState) +{ + if (typeof baseState == 'string' || baseState instanceof String) + return this.BaseState() == baseState; + else // array + return baseState.indexOf(this.BaseState()) != -1; +}; + +InputEvents.prototype.events = { + "default": function () { }, + "MENU": { + "mousebuttonup": function (msg) + { + if (msg.ev.button == SDL_BUTTON_LEFT || msg.ev.button == SDL_BUTTON_RIGHT) + this.SwitchToNextState("NORMAL"); + }, + "mousebuttondown": function (msg) + { + if (msg.ev.button == SDL_BUTTON_LEFT || msg.ev.button == SDL_BUTTON_RIGHT) + this.SwitchToNextState("NORMAL"); + } + }, + "FREEHAND": { + "enter": function () + { + g_DragStart = g_MousePos; + }, + "mousemotion": function (msg) + { + if (g_DragStart.distanceTo(msg.ev) >= g_MaxDragDelta) + this.SwitchToNextState("FREEHAND.DRAW"); + }, + "mousebuttonup": function (msg) + { + this.SwitchToNextState("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.SwitchToNextState("NORMAL"); + } + }, + "BATCHTRAINING": { + "leave": function () + { + flushTrainingBatch(); + }, + "hotkeyup": function (msg) + { + if (msg.ev.hotkey == "session.batchtrain") + this.SwitchToNextState("NORMAL"); + } + }, + "NORMAL": { + "leave": function () + { + clickedEntity = INVALID_ENTITY; + }, + "enter": function () + { + g_Selection.highlightEntityAtPoint(g_MousePos) + }, + "mousemotion": function (msg) + { + g_Selection.highlightEntityAtPoint(g_MousePos) + }, + "mousebuttonup": function (msg) + { + if (msg.ev.button == SDL_BUTTON_RIGHT) + { + let action = determineAction(g_MousePos); + if (action) + return doAction(action, msg.ev); + } + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + this.SwitchToNextState("SELECTING.POINT"); + return true; + case SDL_BUTTON_RIGHT: + this.SwitchToNextState("FREEHAND"); + return true; + } + }, + "hotkeydown": function (msg) + { + groupSelectionAction(msg.ev.hotkey); + }, + "default": function (msg) + { + g_Selection.highlightEntityAtPoint(g_MousePos) + } + }, + "PRESELECTEDACTION": { + "mousemotion": function (msg) + { + g_Selection.highlightEntityAtPoint(g_MousePos) + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + let action = determineAction(g_MousePos); + if (!action) + return; + if (!Engine.HotkeyIsPressed("session.queue")) + this.SwitchToNextState("NORMAL"); + return doAction(action, msg.ev); + case SDL_BUTTON_RIGHT: + this.SwitchToNextState("NORMAL"); + return; + } + }, + "default": function (msg) + { + // Slight hack: If selection is empty, reset the input state + if (g_Selection.toList().length == 0) + this.SwitchToNextState("NORMAL"); + }, + "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.SwitchToNextState("SELECTING.BOX"); + else + g_Selection.highlightEntityAtPoint(g_MousePos); + }, + "mousebuttonup": function (msg) + { + if (msg.ev.button != SDL_BUTTON_LEFT) + return; + + 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(); + } + this.SwitchToNextState("NORMAL"); + return true; + } + + // If camera following and we select different unit, stop + if (Engine.GetFollowedEntity() != clickedEntity) + Engine.CameraFollow(0); + + let ents = this.getSimilarEntities(msg.ev.click, clickedEntity); + g_Selection.processSelection(ents); + + this.SwitchToNextState("NORMAL"); + return true; + } + }, + "BOX": { + "leave": function () + { + Engine.GetGUIObjectByName("bandbox").hidden = true; + g_Selection.setHighlightList([]); + }, + "mousemotion": function (msg) + { + let rect = updateBandbox("bandbox", msg.ev, false); + let ents = Engine.PickPlayerEntitiesInRect(...rect, g_ViewedPlayer); + g_Selection.setHighlightList(getPreferredEntities(ents)); + }, + "mousebuttonup": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + g_Selection.selectionBoxProcessSelected(msg.ev) + this.SwitchToNextState("NORMAL"); + return true; + case SDL_BUTTON_RIGHT: + this.SwitchToNextState("NORMAL"); + return true; + } + }, + "default": function (msg) + { + let rect = updateBandbox("bandbox", g_MousePos, false); + let ents = Engine.PickPlayerEntitiesInRect(...rect, g_ViewedPlayer); + g_Selection.setHighlightList(getPreferredEntities(ents)); + } + } + }, + "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.SwitchToNextState("PLACEMENT.BUILDING.PLACE"); + return true; + case SDL_BUTTON_RIGHT: + this.SwitchToNextState("NORMAL"); + return true; + } + }, + "hotkeydown": function (msg) + { + let rotation_step = (2 * Math.PI) / 24; + switch (msg.ev.hotkey) + { + case "session.rotate.cw": + placementSupport.angle += rotation_step; + updateBuildingPlacementPreview(); + return; + case "session.rotate.ccw": + placementSupport.angle -= rotation_step; + updateBuildingPlacementPreview(); + return; + } + }, + "mousebuttonup": function (msg) + { + if (msg.ev.button == SDL_BUTTON_LEFT) + { + let queue = Engine.HotkeyIsPressed("session.queue"); + if (!tryPlaceBuilding(queue) || queue) + this.SwitchToNextState("PLACEMENT.BUILDING"); + else + this.SwitchToNextState("NORMAL"); + 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.SwitchToNextState("PLACEMENT.BUILDING.ROTATE"); + } + }, + "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.SwitchToNextState("PLACEMENT.WALL.PLACE"); + return true; + case SDL_BUTTON_RIGHT: + this.SwitchToNextState("NORMAL"); + return true; + } + }, + "PLACE": { + "enter": function () + { + placementSupport.position = Engine.GetTerrainAtScreenPoint(g_MousePos.x, g_MousePos.y); + placementSupport.previewWallPlacing(g_MousePos) + }, + "mousemotion": function (msg) + { + this.SwitchToNextState("PLACEMENT.WALL.EXTEND"); + } + }, + "EXTEND": { + "mousemotion": function (msg) + { + placementSupport.previewWallPlacing(g_MousePos) + }, + "mousebuttondown": function (msg) + { + switch (msg.ev.button) + { + case SDL_BUTTON_LEFT: + placementSupport.placeWall(); + return true; + case SDL_BUTTON_RIGHT: + this.SwitchToNextState("NORMAL"); + 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); +} + +var g_InputEvents = new InputEvents("NORMAL"); 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.SwitchToNextState(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.SwitchToNextState("MASSTRIBUTING"); multiplier += multiplier == 1 ? 4 : 5; } @@ -1262,7 +1249,7 @@ function closeOpenDialogs() { - closeMenu(); + g_InputEvents.SwitchToNextState("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.SwitchToNextState("PLACEMENT.WALL.PLACE"); + } + else + g_InputEvents.SwitchToNextState("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.SwitchToNextState("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,32 @@ 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); +} + /** * 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.Init(); } 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.SwitchToNextState("PRESELECTEDACTION.GARRISON"); + } }, "unload": { @@ -1150,8 +1149,8 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_REPAIR; + g_InputEvents.SwitchToNextState("PRESELECTEDACTION.REPAIR"); + }, }, @@ -1223,8 +1222,7 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_GUARD; + g_InputEvents.SwitchToNextState("PRESELECTEDACTION.GUARD"); }, }, @@ -1277,8 +1275,7 @@ }, "execute": function() { - inputState = INPUT_PRESELECTEDACTION; - preSelectedAction = ACTION_PATROL; + g_InputEvents.SwitchToNextState("PRESELECTEDACTION.PATROL"); }, },