Index: binaries/data/config/default.cfg =================================================================== --- binaries/data/config/default.cfg +++ binaries/data/config/default.cfg @@ -313,6 +313,7 @@ deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting rotate.cw = RightBracket ; Rotate building placement preview clockwise rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise +snaptoedge = Shift ; Snap building placement by edge [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI Index: binaries/data/mods/public/globalscripts/vector.js =================================================================== --- binaries/data/mods/public/globalscripts/vector.js +++ binaries/data/mods/public/globalscripts/vector.js @@ -240,6 +240,11 @@ return sum; }; +Vector2D.dot = function(v1, v2) +{ + return v1.x * v2.x + v1.y * v2.y; +}; + ///////////////////////////////////////////////////////////////////// // Vector3D // 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 @@ -722,10 +722,13 @@ placementSupport.SetDefaultAngle(); } - var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + let snapToEdge = Engine.HotkeyIsPressed("session.snaptoedge"); + let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { "template": placementSupport.template, "x": placementSupport.position.x, - "z": placementSupport.position.z + "z": placementSupport.position.z, + "angle": placementSupport.angle, + "snapToEdges": snapToEdge && Engine.GetEdgesOfStaticObstructionsOnScreen() }); if (snapData) { @@ -1045,10 +1048,12 @@ return true; } - var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + let snapToEdge = Engine.HotkeyIsPressed("session.snaptoedge"); + let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { "template": placementSupport.template, "x": placementSupport.position.x, "z": placementSupport.position.z, + "snapToEdges": snapToEdge && Engine.GetEdgesOfStaticObstructionsOnScreen(g_ViewedPlayer) }); if (snapData) { @@ -1073,6 +1078,24 @@ else { placementSupport.position = Engine.GetTerrainAtScreenPoint(ev.x, ev.y); + + let snapToEdge = Engine.HotkeyIsPressed("session.snaptoedge"); + if (snapToEdge) + { + let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + "template": placementSupport.template, + "x": placementSupport.position.x, + "z": placementSupport.position.z, + "snapToEdges": Engine.GetEdgesOfStaticObstructionsOnScreen(g_ViewedPlayer) + }); + if (snapData) + { + placementSupport.angle = snapData.angle; + placementSupport.position.x = snapData.x; + placementSupport.position.z = snapData.z; + } + } + g_DragStart = new Vector2D(ev.x, ev.y); inputState = INPUT_BUILDING_CLICK; } Index: binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/GuiInterface.js +++ binaries/data/mods/public/simulation/components/GuiInterface.js @@ -35,6 +35,7 @@ this.entsWithAuraAndStatusBars = new Set(); this.enabledVisualRangeOverlayTypes = {}; this.templateModified = {}; + this.buildingSnapping = new BuildingSnapping(); }; /* @@ -1666,6 +1667,13 @@ return minDistEntitySnapData; } + if (data.snapToEdges) + { + let position = this.buildingSnapping.getPosition(data, template); + if (position) + return position; + } + if (template.BuildRestrictions.PlacementType == "shore") { let angle = GetDockAngle(template, data.x, data.z); Index: binaries/data/mods/public/simulation/helpers/BuildingPlacement.js =================================================================== --- binaries/data/mods/public/simulation/helpers/BuildingPlacement.js +++ binaries/data/mods/public/simulation/helpers/BuildingPlacement.js @@ -0,0 +1,150 @@ +class BuildingSnapping +{ + constructor() + { + } + + getValidEdges(allEdges, position, maxSide) + { + const minimalDistanceToSnap = 5; + let edges = []; + for (let edge of allEdges) + { + let signedDistance = Vector2D.dot(edge.normal, position) - Vector2D.dot(edge.normal, edge.begin); + // Negative signed distance means that the template position + // is lying behind the edge. + if (signedDistance < -minimalDistanceToSnap - maxSide || + signedDistance > minimalDistanceToSnap + maxSide) + continue; + let dir1 = Vector2D.sub(edge.begin, edge.end).normalize(); + let dir2 = new Vector2D(-dir1.x, -dir1.y); + let offsetDistance = Math.max( + Vector2D.dot(dir1, position) - Vector2D.dot(dir1, edge.begin), + Vector2D.dot(dir2, position) - Vector2D.dot(dir2, edge.end) + ); + if (offsetDistance > minimalDistanceToSnap + maxSide) + continue; + // If a projection of the template position on the edge is + // lying inside the edge then obviously we don't need to + // account the offset distance. + if (offsetDistance < 0) + offsetDistance = 0; + edge.signedDistance = signedDistance; + edge.offsetDistance = offsetDistance; + edges.push(edge); + } + return edges; + } + + getPosition(data, template) + { + if (!data.snapToEdges || !template.Obstruction || !template.Obstruction.Static) + return undefined; + + let width = template.Obstruction.Static["@depth"] / 2; + let depth = template.Obstruction.Static["@width"] / 2; + const maxSide = Math.max(width, depth); + let templatePos = new Vector2D(data.x, data.z); + let templateAngle = data.angle ? data.angle : 0; + + let edges = this.getValidEdges(data.snapToEdges, templatePos, maxSide); + if (edges.length) + { + // Pick a base edge, it will be the first axis and fix the angle. + // We can't just pick an edge by signed distance, because we might have + // a case when one segment is closer by signed distance than another + // one but much farther by actual (euclid) distance. + const compare = (a, b) => { + const EPS = 1e-3; + const behindA = a.signedDistance < -EPS; + const behindB = b.signedDistance < -EPS; + const scoreA = Math.abs(a.signedDistance) + a.offsetDistance; + const scoreB = Math.abs(b.signedDistance) + b.offsetDistance; + if (Math.abs(scoreA - scoreB) < EPS) + { + if (behindA != behindB) + return behindA - behindB; + if (!behindA) + return a.offsetDistance - b.offsetDistance; + return -a.signedDistance - -b.signedDistance; + } + return scoreA - scoreB; + }; + + let baseEdge = edges[0]; + for (let edge of edges) + if (compare(edge, baseEdge) < 0) + baseEdge = edge; + // Now we have the normal, we need to determine an angle, + // which side will be snapped first. + for (let dir = 0; dir < 4; ++dir) + { + const EPS = 1e-3; + const angleCandidate = baseEdge.angle + dir * Math.PI / 2; + // We need to find a minimal angle difference. + let difference = Math.abs(angleCandidate - templateAngle); + difference = Math.min(difference, Math.PI * 2 - difference); + if (difference < Math.PI / 4 + EPS) + { + // We need to swap sides for orthogonal cases. + if (dir % 2 == 0) + [width, depth] = [depth, width]; + templateAngle = angleCandidate; + break; + } + } + + // We need a small padding to avoid unnecessary collisions + // because of loss of accuracy. + const getPadding = (edge) => { + const snapPadding = 0.05; + // We don't need to padding for edges with normals directed inside + // its entity, as we try to snap from an internal side of the edge. + return edge.order == 'ccw' ? 0.0 : snapPadding; + }; + + let distance = Vector2D.dot(baseEdge.normal, templatePos) - Vector2D.dot(baseEdge.normal, baseEdge.begin); + templatePos.sub(Vector2D.mult(baseEdge.normal, distance - width - getPadding(baseEdge))); + edges = this.getValidEdges(data.snapToEdges, templatePos, maxSide); + if (edges.length > 1) + { + let pairedEdges = []; + for (let edge of edges) + { + const EPS = 1e-3; + // We have to place a rectangle, so the angle between + // edges should be 90 degrees. + if (Math.abs(Vector2D.dot(baseEdge.normal, edge.normal)) > EPS) + continue; + let newEdge = { + 'begin': edge.end, + 'end': edge.begin, + 'normal': Vector2D.mult(edge.normal, -1), + 'signedDistance': -edge.signedDistance, + 'offsetDistance': edge.offsetDistance, + 'order': 'ccw', + }; + pairedEdges.push(edge); + pairedEdges.push(newEdge); + } + pairedEdges.sort(compare); + if (pairedEdges.length) + { + let secondEdge = pairedEdges[0]; + for (let edge of pairedEdges) + if (compare(edge, secondEdge) < 0.0) + secondEdge = edge; + let distance = Vector2D.dot(secondEdge.normal, templatePos) - Vector2D.dot(secondEdge.normal, secondEdge.begin); + templatePos.sub(Vector2D.mult(secondEdge.normal, distance - depth - getPadding(secondEdge))); + } + } + return { + "x": templatePos.x, + "z": templatePos.y, + "angle": templateAngle + }; + } + } +} + +Engine.RegisterGlobal("BuildingSnapping", BuildingSnapping); Index: source/simulation2/components/CCmpObstruction.cpp =================================================================== --- source/simulation2/components/CCmpObstruction.cpp +++ source/simulation2/components/CCmpObstruction.cpp @@ -519,6 +519,11 @@ return CFixedVector2D(m_Size0 / 2, m_Size1 / 2).Length(); } + virtual CFixedVector2D GetStaticSize() const + { + return m_Type == STATIC ? CFixedVector2D(m_Size0, m_Size1) : CFixedVector2D(); + } + virtual void SetUnitClearance(const entity_pos_t& clearance) { if (m_Type == UNIT) Index: source/simulation2/components/ICmpObstruction.h =================================================================== --- source/simulation2/components/ICmpObstruction.h +++ source/simulation2/components/ICmpObstruction.h @@ -60,6 +60,8 @@ virtual entity_pos_t GetSize() const = 0; + virtual CFixedVector2D GetStaticSize() const = 0; + virtual entity_pos_t GetUnitRadius() const = 0; virtual EObstructionType GetObstructionType() const = 0; Index: source/simulation2/components/tests/test_ObstructionManager.h =================================================================== --- source/simulation2/components/tests/test_ObstructionManager.h +++ source/simulation2/components/tests/test_ObstructionManager.h @@ -30,6 +30,7 @@ virtual bool GetObstructionSquare(ICmpObstructionManager::ObstructionSquare& out) const { out = obstruction; return true; } virtual bool GetPreviousObstructionSquare(ICmpObstructionManager::ObstructionSquare& UNUSED(out)) const { return true; } virtual entity_pos_t GetSize() const { return entity_pos_t::Zero(); } + virtual CFixedVector2D GetStaticSize() const { return CFixedVector2D(); } virtual entity_pos_t GetUnitRadius() const { return entity_pos_t::Zero(); } virtual EObstructionType GetObstructionType() const { return ICmpObstruction::STATIC; } virtual void SetUnitClearance(const entity_pos_t& UNUSED(clearance)) { } Index: source/simulation2/scripting/JSInterface_Simulation.h =================================================================== --- source/simulation2/scripting/JSInterface_Simulation.h +++ source/simulation2/scripting/JSInterface_Simulation.h @@ -31,6 +31,7 @@ std::vector PickPlayerEntitiesOnScreen(ScriptInterface::CxPrivate* pCxPrivate, int player); std::vector PickNonGaiaEntitiesOnScreen(ScriptInterface::CxPrivate* pCxPrivate); std::vector GetEntitiesWithStaticObstructionOnScreen(ScriptInterface::CxPrivate* pCxPrivate); + JS::Value GetEdgesOfStaticObstructionsOnScreen(ScriptInterface::CxPrivate* pCxPrivate); std::vector PickSimilarPlayerEntities(ScriptInterface::CxPrivate* pCxPrivate, const std::string& templateName, bool includeOffScreen, bool matchRank, bool allowFoundations); JS::Value GetAIs(ScriptInterface::CxPrivate* pCxPrivate); void SetBoundingBoxDebugOverlay(ScriptInterface::CxPrivate* pCxPrivate, bool enabled); Index: source/simulation2/scripting/JSInterface_Simulation.cpp =================================================================== --- source/simulation2/scripting/JSInterface_Simulation.cpp +++ source/simulation2/scripting/JSInterface_Simulation.cpp @@ -29,6 +29,7 @@ #include "simulation2/components/ICmpAIManager.h" #include "simulation2/components/ICmpCommandQueue.h" #include "simulation2/components/ICmpGuiInterface.h" +#include "simulation2/components/ICmpPosition.h" #include "simulation2/components/ICmpSelectable.h" #include "simulation2/helpers/Selection.h" @@ -114,6 +115,68 @@ return EntitySelection::GetEntitiesWithComponentInRect(*g_Game->GetSimulation2(), IID_Obstruction, *g_Game->GetView()->GetCamera(), 0, 0, g_xres, g_yres); } +JS::Value JSI_Simulation::GetEdgesOfStaticObstructionsOnScreen(ScriptInterface::CxPrivate* pCxPrivate) +{ + if (!g_Game) + return JS::UndefinedValue(); + + CSimulation2* sim = g_Game->GetSimulation2(); + ENSURE(sim); + + JSContext* cx = pCxPrivate->pScriptInterface->GetContext(); + JSAutoRequest rq(cx); + JS::RootedValue edgeList(cx); + ScriptInterface::CreateArray(cx, &edgeList); + int edgeListIndex = 0; + + std::vector entities = GetEntitiesWithStaticObstructionOnScreen(pCxPrivate); + for (entity_id_t entity : entities) + { + CmpPtr cmpObstruction(sim->GetSimContext(), entity); + if (!cmpObstruction) + continue; + + CmpPtr cmpPosition(sim->GetSimContext(), entity); + if (!cmpPosition || !cmpPosition->IsInWorld()) + continue; + + CFixedVector2D halfSize = cmpObstruction->GetStaticSize() / 2; + if (halfSize.X.IsZero() || halfSize.Y.IsZero() || std::max(halfSize.X, halfSize.Y) <= fixed::FromInt(2)) + continue; + + CFixedVector2D corners[4] = { + CFixedVector2D(-halfSize.X, -halfSize.Y), + CFixedVector2D(-halfSize.X, halfSize.Y), + CFixedVector2D(halfSize.X, halfSize.Y), + CFixedVector2D(halfSize.X, -halfSize.Y) + }; + fixed angle = cmpPosition->GetRotation().Y; + for (CFixedVector2D& corner : corners) + corner = corner.Rotate(angle) + cmpPosition->GetPosition2D(); + + for (size_t i = 0; i < 4; ++i) + { + JS::RootedValue edge(cx); + const CFixedVector2D& corner = corners[i]; + const CFixedVector2D& nextCorner = corners[(i + 1) & 0x3]; + + CFixedVector2D normal = -(nextCorner - corner).Perpendicular(); + normal.Normalize(); + ScriptInterface::CreateObject( + cx, + &edge, + "begin", corner, + "end", nextCorner, + "angle", angle, + "normal", normal, + "order", std::string("cw")); + + pCxPrivate->pScriptInterface->SetPropertyInt(edgeList, edgeListIndex++, edge); + } + } + return edgeList; +} + std::vector JSI_Simulation::PickSimilarPlayerEntities(ScriptInterface::CxPrivate* UNUSED(pCxPrivate), const std::string& templateName, bool includeOffScreen, bool matchRank, bool allowFoundations) { return EntitySelection::PickSimilarEntities(*g_Game->GetSimulation2(), *g_Game->GetView()->GetCamera(), templateName, g_Game->GetViewedPlayerID(), includeOffScreen, matchRank, false, allowFoundations); @@ -140,6 +203,7 @@ scriptInterface.RegisterFunction, int, &PickPlayerEntitiesOnScreen>("PickPlayerEntitiesOnScreen"); scriptInterface.RegisterFunction, &PickNonGaiaEntitiesOnScreen>("PickNonGaiaEntitiesOnScreen"); scriptInterface.RegisterFunction, &GetEntitiesWithStaticObstructionOnScreen>("GetEntitiesWithStaticObstructionOnScreen"); + scriptInterface.RegisterFunction("GetEdgesOfStaticObstructionsOnScreen"); scriptInterface.RegisterFunction, std::string, bool, bool, bool, &PickSimilarPlayerEntities>("PickSimilarPlayerEntities"); scriptInterface.RegisterFunction("SetBoundingBoxDebugOverlay"); }