1
0
forked from 0ad/0ad

Adds snapping to edges for buildings

Allows to place buildings a bit faster and more perfectly aligned. Also
it helps to find a nearest placeable position in some cases.

Reviewed By: elexis
Comments By: Stan, wraitii
Differential Revision: https://code.wildfiregames.com/D2079
This was SVN commit r23330.
This commit is contained in:
Vladislav Belov 2020-01-05 01:08:05 +00:00
parent cf7635f57b
commit a8f241da5d
13 changed files with 308 additions and 16 deletions

View File

@ -313,6 +313,7 @@ unloadtype = Shift ; Modifier to unload all units of type
deselectgroup = Ctrl ; Modifier to deselect units when clicking group icon, instead of selecting
rotate.cw = RightBracket ; Rotate building placement preview clockwise
rotate.ccw = LeftBracket ; Rotate building placement preview anticlockwise
snaptoedges = Ctrl ; Modifier to align new structures with nearby existing structure
[hotkey.session.gui]
toggle = "Alt+G" ; Toggle visibility of session GUI
@ -377,6 +378,7 @@ healrange = true ; Display heal range overlays of selected unit
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

View File

@ -30,6 +30,13 @@ Vector2D.prototype.set = function(x, y)
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 @@ Vector2D.sum = function(vectorList)
return sum;
};
Vector2D.dot = function(v1, v2)
{
return v1.x * v2.x + v1.y * v2.y;
};
/////////////////////////////////////////////////////////////////////
// Vector3D
//

View File

@ -115,6 +115,7 @@ When you are ready to start, click the "Start game" button.
• 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

View File

@ -722,11 +722,15 @@ function handleInputBeforeGui(ev, hoveredObject)
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 @@ function handleInputAfterGui(ev)
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 @@ function handleInputAfterGui(ev)
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;
}

View File

@ -35,6 +35,7 @@ GuiInterface.prototype.Init = function()
this.entsWithAuraAndStatusBars = new Set();
this.enabledVisualRangeOverlayTypes = {};
this.templateModified = {};
this.obstructionSnap = new ObstructionSnap();
};
/*
@ -1668,6 +1669,13 @@ GuiInterface.prototype.GetFoundationSnapData = function(player, data)
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);

View File

@ -1,3 +1,4 @@
Engine.LoadHelperScript("ObstructionSnap.js");
Engine.LoadHelperScript("Player.js");
Engine.LoadComponentScript("interfaces/Attack.js");
Engine.LoadComponentScript("interfaces/AlertRaiser.js");

View File

@ -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);

View File

@ -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 @@ template<> void ScriptInterface::ToJSVal<char[N]>(JSContext* cx, JS::MutableHand
ToJSVal(cx, ret, static_cast<const char*>(val)); \
}
TOJSVAL_CHAR(3)
TOJSVAL_CHAR(5)
TOJSVAL_CHAR(6)
TOJSVAL_CHAR(7)

View File

@ -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 @@ public:
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)

View File

@ -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 @@ public:
virtual entity_pos_t GetSize() const = 0;
virtual CFixedVector2D GetStaticSize() const = 0;
virtual entity_pos_t GetUnitRadius() const = 0;
virtual EObstructionType GetObstructionType() const = 0;

View File

@ -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 @@ public:
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)) { }

View File

@ -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 <array>
#include <fstream>
JS::Value JSI_Simulation::GuiInterfaceCall(ScriptInterface::CxPrivate* pCxPrivate, const std::wstring& name, JS::HandleValue data)
@ -114,6 +117,79 @@ std::vector<entity_id_t> JSI_Simulation::GetEntitiesWithStaticObstructionOnScree
return EntitySelection::GetEntitiesWithComponentInRect<StaticObstructionFilter>(*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<entity_id_t> entities = GetEntitiesWithStaticObstructionOnScreen(pCxPrivate);
for (entity_id_t entity : entities)
{
CmpPtr<ICmpObstruction> cmpObstruction(sim->GetSimContext(), entity);
if (!cmpObstruction)
continue;
CmpPtr<ICmpPosition> 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<CFixedVector2D, 4> 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<entity_id_t> 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 @@ void JSI_Simulation::RegisterScriptFunctions(const ScriptInterface& scriptInterf
scriptInterface.RegisterFunction<std::vector<entity_id_t>, int, &PickPlayerEntitiesOnScreen>("PickPlayerEntitiesOnScreen");
scriptInterface.RegisterFunction<std::vector<entity_id_t>, &PickNonGaiaEntitiesOnScreen>("PickNonGaiaEntitiesOnScreen");
scriptInterface.RegisterFunction<std::vector<entity_id_t>, &GetEntitiesWithStaticObstructionOnScreen>("GetEntitiesWithStaticObstructionOnScreen");
scriptInterface.RegisterFunction<JS::Value, entity_pos_t, entity_pos_t, &GetEdgesOfStaticObstructionsOnScreenNearTo>("GetEdgesOfStaticObstructionsOnScreenNearTo");
scriptInterface.RegisterFunction<std::vector<entity_id_t>, std::string, bool, bool, bool, &PickSimilarPlayerEntities>("PickSimilarPlayerEntities");
scriptInterface.RegisterFunction<void, bool, &SetBoundingBoxDebugOverlay>("SetBoundingBoxDebugOverlay");
}

View File

@ -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 @@ namespace JSI_Simulation
std::vector<entity_id_t> PickPlayerEntitiesOnScreen(ScriptInterface::CxPrivate* pCxPrivate, int player);
std::vector<entity_id_t> PickNonGaiaEntitiesOnScreen(ScriptInterface::CxPrivate* pCxPrivate);
std::vector<entity_id_t> GetEntitiesWithStaticObstructionOnScreen(ScriptInterface::CxPrivate* pCxPrivate);
JS::Value GetEdgesOfStaticObstructionsOnScreenNearTo(ScriptInterface::CxPrivate* pCxPrivate, entity_pos_t x, entity_pos_t z);
std::vector<entity_id_t> 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);