Index: ps/trunk/binaries/data/config/default.cfg =================================================================== --- ps/trunk/binaries/data/config/default.cfg +++ ps/trunk/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 +snaptoedges = Ctrl ; Modifier to align new structures with nearby existing structure [hotkey.session.gui] toggle = "Alt+G" ; Toggle visibility of session GUI @@ -377,6 +378,7 @@ rankabovestatusbar = true ; Show rank icons above status bars experiencestatusbar = true ; Show an experience status bar above each selected unit respoptooltipsort = 0 ; Sorting players in the resources and population tooltip by value (0 - no sort, -1 - ascending, 1 - descending) +snaptoedgesdistancethreshold = 15 ; On which distance we don't snap to edges [gui.session.minimap] blinkduration = 1.7 ; The blink duration while pinging Index: ps/trunk/binaries/data/mods/public/globalscripts/vector.js =================================================================== --- ps/trunk/binaries/data/mods/public/globalscripts/vector.js +++ ps/trunk/binaries/data/mods/public/globalscripts/vector.js @@ -30,6 +30,13 @@ return this; }; +Vector2D.prototype.setFrom = function(v) +{ + this.x = v.x; + this.y = v.y; + return this; +}; + Vector2D.prototype.add = function(v) { this.x += v.x; @@ -240,6 +247,11 @@ return sum; }; +Vector2D.dot = function(v1, v2) +{ + return v1.x * v2.x + v1.y * v2.y; +}; + ///////////////////////////////////////////////////////////////////// // Vector3D // Index: ps/trunk/binaries/data/mods/public/gui/manual/intro.txt =================================================================== --- ps/trunk/binaries/data/mods/public/gui/manual/intro.txt +++ ps/trunk/binaries/data/mods/public/gui/manual/intro.txt @@ -115,6 +115,7 @@ • If the cursor is over an enemy unit or building – Attack (instead of capture or gather) • Otherwise – Attack move (by default all enemy units and structures along the way are targeted) Ctrl + Q + Right Click with unit(s) selected – Attack move, only units along the way are targeted + Ctrl + Mouse Move near structures – Align the new structure with an existing nearby structure [font="sans-bold-14"]Overlays[font="sans-14"] Alt + G – Hide/show the GUI Index: ps/trunk/binaries/data/mods/public/gui/session/input.js =================================================================== --- ps/trunk/binaries/data/mods/public/gui/session/input.js +++ ps/trunk/binaries/data/mods/public/gui/session/input.js @@ -722,11 +722,15 @@ placementSupport.SetDefaultAngle(); } - var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { - "template": placementSupport.template, - "x": placementSupport.position.x, - "z": placementSupport.position.z - }); + let snapToEdges = Engine.HotkeyIsPressed("session.snaptoedges"); + let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + "template": placementSupport.template, + "x": placementSupport.position.x, + "z": placementSupport.position.z, + "angle": placementSupport.angle, + "snapToEdges": snapToEdges && Engine.GetEdgesOfStaticObstructionsOnScreenNearTo( + placementSupport.position.x, placementSupport.position.z) + }); if (snapData) { placementSupport.angle = snapData.angle; @@ -1045,11 +1049,14 @@ return true; } - var snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { - "template": placementSupport.template, - "x": placementSupport.position.x, - "z": placementSupport.position.z, - }); + let snapToEdges = Engine.HotkeyIsPressed("session.snaptoedges"); + let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + "template": placementSupport.template, + "x": placementSupport.position.x, + "z": placementSupport.position.z, + "snapToEdges": snapToEdges && Engine.GetEdgesOfStaticObstructionsOnScreenNearTo( + placementSupport.position.x, placementSupport.position.z) + }); if (snapData) { placementSupport.angle = snapData.angle; @@ -1073,6 +1080,25 @@ else { placementSupport.position = Engine.GetTerrainAtScreenPoint(ev.x, ev.y); + + let snapToEdges = Engine.HotkeyIsPressed("session.snaptoedges"); + if (snapToEdges) + { + let snapData = Engine.GuiInterfaceCall("GetFoundationSnapData", { + "template": placementSupport.template, + "x": placementSupport.position.x, + "z": placementSupport.position.z, + "snapToEdges": Engine.GetEdgesOfStaticObstructionsOnScreenNearTo( + placementSupport.position.x, placementSupport.position.z) + }); + if (snapData) + { + placementSupport.angle = snapData.angle; + placementSupport.position.x = snapData.x; + placementSupport.position.z = snapData.z; + } + } + g_DragStart = new Vector2D(ev.x, ev.y); inputState = INPUT_BUILDING_CLICK; } Index: ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js +++ ps/trunk/binaries/data/mods/public/simulation/components/GuiInterface.js @@ -35,6 +35,7 @@ this.entsWithAuraAndStatusBars = new Set(); this.enabledVisualRangeOverlayTypes = {}; this.templateModified = {}; + this.obstructionSnap = new ObstructionSnap(); }; /* @@ -1668,6 +1669,13 @@ return minDistEntitySnapData; } + if (data.snapToEdges) + { + let position = this.obstructionSnap.getPosition(data, template); + if (position) + return position; + } + if (template.BuildRestrictions.PlacementType == "shore") { let angle = GetDockAngle(template, data.x, data.z); Index: ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js +++ ps/trunk/binaries/data/mods/public/simulation/components/tests/test_GuiInterface.js @@ -1,3 +1,4 @@ +Engine.LoadHelperScript("ObstructionSnap.js"); Engine.LoadHelperScript("Player.js"); Engine.LoadComponentScript("interfaces/Attack.js"); Engine.LoadComponentScript("interfaces/AlertRaiser.js"); Index: ps/trunk/binaries/data/mods/public/simulation/helpers/ObstructionSnap.js =================================================================== --- ps/trunk/binaries/data/mods/public/simulation/helpers/ObstructionSnap.js +++ ps/trunk/binaries/data/mods/public/simulation/helpers/ObstructionSnap.js @@ -0,0 +1,154 @@ +/** + * The class allows the player to position structures so that they are aligned + * with nearby structures. + */ +class ObstructionSnap +{ + getValidEdges(allEdges, position, maxSide) + { + let edges = []; + let dir1 = new Vector2D(); + let dir2 = new Vector2D(); + 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 + // lays behind the edge. + if (signedDistance < -this.MinimalDistanceToSnap - maxSide || + signedDistance > this.MinimalDistanceToSnap + maxSide) + continue; + dir1.setFrom(edge.begin).sub(edge.end).normalize(); + dir2.setFrom(dir1).mult(-1); + let offsetDistance = Math.max( + Vector2D.dot(dir1, position) - Vector2D.dot(dir1, edge.begin), + Vector2D.dot(dir2, position) - Vector2D.dot(dir2, edge.end)); + if (offsetDistance > this.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; + } + + // We need a small padding to avoid unnecessary collisions + // because of loss of accuracy. + 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 : snapPadding; + } + + // 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. + compareEdges(a, b) + { + const behindA = a.signedDistance < -this.EPS; + const behindB = b.signedDistance < -this.EPS; + const scoreA = Math.abs(a.signedDistance) + a.offsetDistance; + const scoreB = Math.abs(b.signedDistance) + b.offsetDistance; + if (Math.abs(scoreA - scoreB) < this.EPS) + { + if (behindA != behindB) + return behindA - behindB; + if (!behindA) + return a.offsetDistance - b.offsetDistance; + return -a.signedDistance - -b.signedDistance; + } + return scoreA - scoreB; + } + + 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 = Vector2D.from3D(data); + let templateAngle = data.angle || 0; + + let edges = this.getValidEdges(data.snapToEdges, templatePos, maxSide); + if (!edges.length) + return undefined; + + let baseEdge = edges[0]; + for (let edge of edges) + if (this.compareEdges(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 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 + this.EPS) + { + // We need to swap sides for orthogonal cases. + if (dir % 2 == 0) + [width, depth] = [depth, width]; + templateAngle = angleCandidate; + break; + } + } + + let distance = Vector2D.dot(baseEdge.normal, templatePos) - Vector2D.dot(baseEdge.normal, baseEdge.begin); + templatePos.sub(Vector2D.mult(baseEdge.normal, distance - width - this.getPadding(baseEdge))); + edges = this.getValidEdges(data.snapToEdges, templatePos, maxSide); + if (edges.length > 1) + { + let pairedEdges = []; + for (let edge of edges) + { + // We have to place a rectangle, so the angle between + // edges should be 90 degrees. + if (Math.abs(Vector2D.dot(baseEdge.normal, edge.normal)) > this.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(this.compareEdges.bind(this)); + if (pairedEdges.length) + { + let secondEdge = pairedEdges[0]; + for (let edge of pairedEdges) + if (this.compareEdges(edge, secondEdge) < 0) + secondEdge = edge; + let distance = Vector2D.dot(secondEdge.normal, templatePos) - Vector2D.dot(secondEdge.normal, secondEdge.begin); + templatePos.sub(Vector2D.mult(secondEdge.normal, distance - depth - this.getPadding(secondEdge))); + } + } + return { + "x": templatePos.x, + "z": templatePos.y, + "angle": templateAngle + }; + } +} + +ObstructionSnap.prototype.MinimalDistanceToSnap = 5; + +ObstructionSnap.prototype.EPS = 1e-3; + +Engine.RegisterGlobal("ObstructionSnap", ObstructionSnap); Index: ps/trunk/source/scriptinterface/ScriptConversions.cpp =================================================================== --- ps/trunk/source/scriptinterface/ScriptConversions.cpp +++ ps/trunk/source/scriptinterface/ScriptConversions.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -298,6 +298,7 @@ ToJSVal(cx, ret, static_cast(val)); \ } +TOJSVAL_CHAR(3) TOJSVAL_CHAR(5) TOJSVAL_CHAR(6) TOJSVAL_CHAR(7) Index: ps/trunk/source/simulation2/components/CCmpObstruction.cpp =================================================================== --- ps/trunk/source/simulation2/components/CCmpObstruction.cpp +++ ps/trunk/source/simulation2/components/CCmpObstruction.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -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: ps/trunk/source/simulation2/components/ICmpObstruction.h =================================================================== --- ps/trunk/source/simulation2/components/ICmpObstruction.h +++ ps/trunk/source/simulation2/components/ICmpObstruction.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -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: ps/trunk/source/simulation2/components/tests/test_ObstructionManager.h =================================================================== --- ps/trunk/source/simulation2/components/tests/test_ObstructionManager.h +++ ps/trunk/source/simulation2/components/tests/test_ObstructionManager.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -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: ps/trunk/source/simulation2/scripting/JSInterface_Simulation.h =================================================================== --- ps/trunk/source/simulation2/scripting/JSInterface_Simulation.h +++ ps/trunk/source/simulation2/scripting/JSInterface_Simulation.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -19,6 +19,7 @@ #define INCLUDED_JSI_SIMULATION #include "scriptinterface/ScriptInterface.h" +#include "simulation2/helpers/Position.h" #include "simulation2/system/Entity.h" namespace JSI_Simulation @@ -31,6 +32,7 @@ std::vector PickPlayerEntitiesOnScreen(ScriptInterface::CxPrivate* pCxPrivate, int player); std::vector PickNonGaiaEntitiesOnScreen(ScriptInterface::CxPrivate* pCxPrivate); std::vector GetEntitiesWithStaticObstructionOnScreen(ScriptInterface::CxPrivate* pCxPrivate); + JS::Value GetEdgesOfStaticObstructionsOnScreenNearTo(ScriptInterface::CxPrivate* pCxPrivate, entity_pos_t x, entity_pos_t z); 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: ps/trunk/source/simulation2/scripting/JSInterface_Simulation.cpp =================================================================== --- ps/trunk/source/simulation2/scripting/JSInterface_Simulation.cpp +++ ps/trunk/source/simulation2/scripting/JSInterface_Simulation.cpp @@ -1,4 +1,4 @@ -/* Copyright (C) 2019 Wildfire Games. +/* Copyright (C) 2020 Wildfire Games. * This file is part of 0 A.D. * * 0 A.D. is free software: you can redistribute it and/or modify @@ -20,6 +20,7 @@ #include "JSInterface_Simulation.h" #include "graphics/GameView.h" +#include "ps/ConfigDB.h" #include "ps/Game.h" #include "ps/GameSetup/Config.h" #include "ps/Pyrogenesis.h" @@ -29,9 +30,11 @@ #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" +#include #include JS::Value JSI_Simulation::GuiInterfaceCall(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& name, JS::HandleValue data) @@ -114,6 +117,79 @@ return EntitySelection::GetEntitiesWithComponentInRect(*g_Game->GetSimulation2(), IID_Obstruction, *g_Game->GetView()->GetCamera(), 0, 0, g_xres, g_yres); } +JS::Value JSI_Simulation::GetEdgesOfStaticObstructionsOnScreenNearTo(ScriptInterface::CxPrivate* pCxPrivate, entity_pos_t x, entity_pos_t z) +{ + 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; + + float distanceThreshold = 10.0f; + CFG_GET_VAL("gui.session.snaptoedgesdistancethreshold", distanceThreshold); + CFixedVector2D entityPos(x, z); + + 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; + + std::array corners = { + CFixedVector2D(-halfSize.X, -halfSize.Y), + CFixedVector2D(-halfSize.X, halfSize.Y), + halfSize, + 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 < corners.size(); ++i) + { + JS::RootedValue edge(cx); + const CFixedVector2D& corner = corners[i]; + const CFixedVector2D& nextCorner = corners[(i + 1) % corners.size()]; + + // TODO: calculate real distance; + fixed distanceToEdge = std::min( + (corner - entityPos).Length(), + (nextCorner - entityPos).Length()); + if (distanceToEdge.ToFloat() > distanceThreshold) + continue; + + CFixedVector2D normal = -(nextCorner - corner).Perpendicular(); + normal.Normalize(); + ScriptInterface::CreateObject( + cx, + &edge, + "begin", corner, + "end", nextCorner, + "angle", angle, + "normal", normal, + "order", "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 +216,7 @@ scriptInterface.RegisterFunction, int, &PickPlayerEntitiesOnScreen>("PickPlayerEntitiesOnScreen"); scriptInterface.RegisterFunction, &PickNonGaiaEntitiesOnScreen>("PickNonGaiaEntitiesOnScreen"); scriptInterface.RegisterFunction, &GetEntitiesWithStaticObstructionOnScreen>("GetEntitiesWithStaticObstructionOnScreen"); + scriptInterface.RegisterFunction("GetEdgesOfStaticObstructionsOnScreenNearTo"); scriptInterface.RegisterFunction, std::string, bool, bool, bool, &PickSimilarPlayerEntities>("PickSimilarPlayerEntities"); scriptInterface.RegisterFunction("SetBoundingBoxDebugOverlay"); }