549 lines
20 KiB
C#
549 lines
20 KiB
C#
using UnityEngine;
|
|
using System.Collections.Generic;
|
|
using Unity.Mathematics;
|
|
using Pathfinding.Pooling;
|
|
|
|
namespace Pathfinding {
|
|
/// <summary>
|
|
/// Basic path, finds the shortest path from A to B.
|
|
///
|
|
/// This is the most basic path object it will try to find the shortest path between two points.
|
|
/// Many other path types inherit from this type.
|
|
/// See: Seeker.StartPath
|
|
/// See: calling-pathfinding (view in online documentation for working links)
|
|
/// See: getstarted (view in online documentation for working links)
|
|
/// </summary>
|
|
public class ABPath : Path {
|
|
/// <summary>Start node of the path</summary>
|
|
public GraphNode startNode => path.Count > 0 ? path[0] : null;
|
|
|
|
/// <summary>End node of the path</summary>
|
|
public GraphNode endNode => path.Count > 0 ? path[path.Count-1] : null;
|
|
|
|
/// <summary>Start Point exactly as in the path request</summary>
|
|
public Vector3 originalStartPoint;
|
|
|
|
/// <summary>End Point exactly as in the path request</summary>
|
|
public Vector3 originalEndPoint;
|
|
|
|
/// <summary>
|
|
/// Start point of the path.
|
|
/// This is the closest point on the <see cref="startNode"/> to <see cref="originalStartPoint"/>
|
|
/// </summary>
|
|
public Vector3 startPoint;
|
|
|
|
/// <summary>
|
|
/// End point of the path.
|
|
/// This is the closest point on the <see cref="endNode"/> to <see cref="originalEndPoint"/>
|
|
/// </summary>
|
|
public Vector3 endPoint;
|
|
|
|
/// <summary>
|
|
/// Total cost of this path as used by the pathfinding algorithm.
|
|
///
|
|
/// The cost is influenced by both the length of the path, as well as any tags or penalties on the nodes.
|
|
/// By default, the cost to move 1 world unit is <see cref="Int3.Precision"/>.
|
|
///
|
|
/// If the path failed, the cost will be set to zero.
|
|
///
|
|
/// See: tags (view in online documentation for working links)
|
|
/// </summary>
|
|
public uint cost;
|
|
|
|
/// <summary>
|
|
/// Determines if a search for an end node should be done.
|
|
/// Set by different path types.
|
|
/// Since: Added in 3.0.8.3
|
|
/// </summary>
|
|
protected virtual bool hasEndPoint => true;
|
|
|
|
/// <summary>
|
|
/// True if this path type has a well defined end point, even before calculation starts.
|
|
///
|
|
/// This is for example true for the <see cref="ABPath"/> type, but false for the <see cref="RandomPath"/> type.
|
|
/// </summary>
|
|
public virtual bool endPointKnownBeforeCalculation => true;
|
|
|
|
/// <summary>
|
|
/// Calculate partial path if the target node cannot be reached.
|
|
/// If the target node cannot be reached, the node which was closest (given by heuristic) will be chosen as target node
|
|
/// and a partial path will be returned.
|
|
/// This only works if a heuristic is used (which is the default).
|
|
/// If a partial path is found, CompleteState is set to Partial.
|
|
/// Note: It is not required by other path types to respect this setting
|
|
///
|
|
/// The <see cref="endNode"/> and <see cref="endPoint"/> will be modified and be set to the node which ends up being closest to the target.
|
|
///
|
|
/// Warning: Using this may make path calculations significantly slower if you have a big graph. The reason is that
|
|
/// when the target node cannot be reached, the path must search through every single other node that it can reach in order
|
|
/// to determine which one is closest. This may be expensive, and is why this option is disabled by default.
|
|
/// </summary>
|
|
public bool calculatePartial;
|
|
|
|
/// <summary>
|
|
/// Current best target for the partial path.
|
|
/// This is the node with the lowest H score.
|
|
/// </summary>
|
|
protected uint partialBestTargetPathNodeIndex = GraphNode.InvalidNodeIndex;
|
|
protected uint partialBestTargetHScore = uint.MaxValue;
|
|
protected uint partialBestTargetGScore = uint.MaxValue;
|
|
|
|
/// <summary>
|
|
/// Optional ending condition for the path.
|
|
///
|
|
/// The ending condition determines when the path has been completed.
|
|
/// Can be used to for example mark a path as complete when it is within a specific distance from the target.
|
|
///
|
|
/// If ending conditions are used that are not centered around the endpoint of the path,
|
|
/// then you should also set the <see cref="heuristic"/> to None to ensure the path is still optimal.
|
|
/// The performance impact of setting the <see cref="heuristic"/> to None is quite large, so you might want to try to run it with the default
|
|
/// heuristic to see if the path is good enough for your use case anyway.
|
|
///
|
|
/// If null, no custom ending condition will be used. This means that the path will end when the target node has been reached.
|
|
///
|
|
/// Note: If the ending condition returns false for all nodes, the path will just keep searching until it has searched the whole graph. This can be slow.
|
|
///
|
|
/// See: <see cref="PathEndingCondition"/>
|
|
/// </summary>
|
|
public PathEndingCondition endingCondition;
|
|
|
|
/// <summary>@{ @name Constructors</summary>
|
|
|
|
/// <summary>
|
|
/// Default constructor.
|
|
/// Do not use this. Instead use the static Construct method which can handle path pooling.
|
|
/// </summary>
|
|
public ABPath () {}
|
|
|
|
/// <summary>
|
|
/// Construct a path with a start and end point.
|
|
/// The delegate will be called when the path has been calculated.
|
|
/// Do not confuse it with the Seeker callback as they are sent at different times.
|
|
/// If you are using a Seeker to start the path you can set callback to null.
|
|
///
|
|
/// Returns: The constructed path object
|
|
/// </summary>
|
|
public static ABPath Construct (Vector3 start, Vector3 end, OnPathDelegate callback = null) {
|
|
var p = PathPool.GetPath<ABPath>();
|
|
|
|
p.Setup(start, end, callback);
|
|
return p;
|
|
}
|
|
|
|
protected void Setup (Vector3 start, Vector3 end, OnPathDelegate callbackDelegate) {
|
|
callback = callbackDelegate;
|
|
UpdateStartEnd(start, end);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a fake path.
|
|
/// Creates a path that looks almost exactly like it would if the pathfinding system had calculated it.
|
|
///
|
|
/// This is useful if you want your agents to follow some known path that cannot be calculated using the pathfinding system for some reason.
|
|
///
|
|
/// <code>
|
|
/// var path = ABPath.FakePath(new List<Vector3> { new Vector3(1, 2, 3), new Vector3(4, 5, 6) });
|
|
///
|
|
/// ai.SetPath(path);
|
|
/// </code>
|
|
///
|
|
/// You can use it to combine existing paths like this:
|
|
///
|
|
/// <code>
|
|
/// var a = Vector3.zero;
|
|
/// var b = new Vector3(1, 2, 3);
|
|
/// var c = new Vector3(2, 3, 4);
|
|
/// var path1 = ABPath.Construct(a, b);
|
|
/// var path2 = ABPath.Construct(b, c);
|
|
///
|
|
/// AstarPath.StartPath(path1);
|
|
/// AstarPath.StartPath(path2);
|
|
/// path1.BlockUntilCalculated();
|
|
/// path2.BlockUntilCalculated();
|
|
///
|
|
/// // Combine the paths
|
|
/// // Note: Skip the first element in the second path as that will likely be the last element in the first path
|
|
/// var newVectorPath = path1.vectorPath.Concat(path2.vectorPath.Skip(1)).ToList();
|
|
/// var newNodePath = path1.path.Concat(path2.path.Skip(1)).ToList();
|
|
/// var combinedPath = ABPath.FakePath(newVectorPath, newNodePath);
|
|
/// </code>
|
|
/// </summary>
|
|
public static ABPath FakePath (List<Vector3> vectorPath, List<GraphNode> nodePath = null) {
|
|
var path = PathPool.GetPath<ABPath>();
|
|
|
|
for (int i = 0; i < vectorPath.Count; i++) path.vectorPath.Add(vectorPath[i]);
|
|
|
|
path.completeState = PathCompleteState.Complete;
|
|
((IPathInternals)path).AdvanceState(PathState.Returned);
|
|
|
|
if (vectorPath.Count > 0) {
|
|
path.UpdateStartEnd(vectorPath[0], vectorPath[vectorPath.Count - 1]);
|
|
}
|
|
|
|
if (nodePath != null) {
|
|
for (int i = 0; i < nodePath.Count; i++) path.path.Add(nodePath[i]);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/// <summary>@}</summary>
|
|
|
|
/// <summary>
|
|
/// Sets the start and end points.
|
|
/// Sets <see cref="originalStartPoint"/>, <see cref="originalEndPoint"/>, <see cref="startPoint"/>, <see cref="endPoint"/>
|
|
/// </summary>
|
|
protected void UpdateStartEnd (Vector3 start, Vector3 end) {
|
|
originalStartPoint = start;
|
|
originalEndPoint = end;
|
|
|
|
startPoint = start;
|
|
endPoint = end;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset all values to their default values.
|
|
/// All inheriting path types must implement this function, resetting ALL their variables to enable recycling of paths.
|
|
/// Call this base function in inheriting types with base.Reset ();
|
|
/// </summary>
|
|
protected override void Reset () {
|
|
base.Reset();
|
|
|
|
originalStartPoint = Vector3.zero;
|
|
originalEndPoint = Vector3.zero;
|
|
startPoint = Vector3.zero;
|
|
endPoint = Vector3.zero;
|
|
calculatePartial = false;
|
|
partialBestTargetPathNodeIndex = GraphNode.InvalidNodeIndex;
|
|
partialBestTargetHScore = uint.MaxValue;
|
|
partialBestTargetGScore = uint.MaxValue;
|
|
cost = 0;
|
|
endingCondition = null;
|
|
}
|
|
|
|
#if !ASTAR_NO_GRID_GRAPH
|
|
/// <summary>Cached <see cref="Pathfinding.NNConstraint.None"/> to reduce allocations</summary>
|
|
static readonly NNConstraint NNConstraintNone = NNConstraint.None;
|
|
|
|
/// <summary>
|
|
/// Applies a special case for grid nodes.
|
|
///
|
|
/// Assume the closest walkable node is a grid node.
|
|
/// We will now apply a special case only for grid graphs.
|
|
/// In tile based games, an obstacle often occupies a whole
|
|
/// node. When a path is requested to the position of an obstacle
|
|
/// (single unwalkable node) the closest walkable node will be
|
|
/// one of the 8 nodes surrounding that unwalkable node
|
|
/// but that node is not neccessarily the one that is most
|
|
/// optimal to walk to so in this special case
|
|
/// we mark all nodes around the unwalkable node as targets
|
|
/// and when we search and find any one of them we simply exit
|
|
/// and set that first node we found to be the 'real' end node
|
|
/// because that will be the optimal node (this does not apply
|
|
/// in general unless the heuristic is set to None, but
|
|
/// for a single unwalkable node it does).
|
|
/// This also applies if the nearest node cannot be traversed for
|
|
/// some other reason like restricted tags.
|
|
///
|
|
/// Returns: True if the workaround was applied. If this happens, new temporary endpoints will have been added
|
|
///
|
|
/// Image below shows paths when this special case is applied. The path goes from the white sphere to the orange box.
|
|
/// [Open online documentation to see images]
|
|
///
|
|
/// Image below shows paths when this special case has been disabled
|
|
/// [Open online documentation to see images]
|
|
/// </summary>
|
|
protected virtual bool EndPointGridGraphSpecialCase (GraphNode closestWalkableEndNode, Vector3 originalEndPoint, int targetIndex) {
|
|
var gridNode = closestWalkableEndNode as GridNode;
|
|
|
|
if (gridNode != null) {
|
|
var gridGraph = GridNode.GetGridGraph(gridNode.GraphIndex);
|
|
|
|
// Find the closest node, not neccessarily walkable
|
|
var endNNInfo2 = gridGraph.GetNearest(originalEndPoint, NNConstraintNone);
|
|
var gridNode2 = endNNInfo2.node as GridNode;
|
|
|
|
if (gridNode != gridNode2 && gridNode2 != null) {
|
|
// Calculate the coordinates of the nodes
|
|
var x1 = gridNode.NodeInGridIndex % gridGraph.width;
|
|
var z1 = gridNode.NodeInGridIndex / gridGraph.width;
|
|
|
|
var x2 = gridNode2.NodeInGridIndex % gridGraph.width;
|
|
var z2 = gridNode2.NodeInGridIndex / gridGraph.width;
|
|
|
|
bool wasClose = false;
|
|
switch (gridGraph.neighbours) {
|
|
case NumNeighbours.Four:
|
|
if ((x1 == x2 && System.Math.Abs(z1-z2) == 1) || (z1 == z2 && System.Math.Abs(x1-x2) == 1)) {
|
|
// If 'O' is gridNode2, then gridNode is one of the nodes marked with an 'x'
|
|
// x
|
|
// x O x
|
|
// x
|
|
wasClose = true;
|
|
}
|
|
break;
|
|
case NumNeighbours.Eight:
|
|
if (System.Math.Abs(x1-x2) <= 1 && System.Math.Abs(z1-z2) <= 1) {
|
|
// If 'O' is gridNode2, then gridNode is one of the nodes marked with an 'x'
|
|
// x x x
|
|
// x O x
|
|
// x x x
|
|
wasClose = true;
|
|
}
|
|
break;
|
|
case NumNeighbours.Six:
|
|
// Hexagon graph
|
|
for (int i = 0; i < 6; i++) {
|
|
var nx = x2 + GridGraph.neighbourXOffsets[GridGraph.hexagonNeighbourIndices[i]];
|
|
var nz = z2 + GridGraph.neighbourZOffsets[GridGraph.hexagonNeighbourIndices[i]];
|
|
if (x1 == nx && z1 == nz) {
|
|
// If 'O' is gridNode2, then gridNode is one of the nodes marked with an 'x'
|
|
// x x
|
|
// x O x
|
|
// x x
|
|
wasClose = true;
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
// Should not happen unless NumNeighbours is modified in the future
|
|
throw new System.Exception("Unhandled NumNeighbours");
|
|
}
|
|
|
|
if (wasClose) {
|
|
// We now need to find all nodes marked with an x to be able to mark them as targets
|
|
AddEndpointsForSurroundingGridNodes(gridNode2, originalEndPoint, targetIndex);
|
|
|
|
// hTargetNode is used for heuristic optimizations
|
|
// (also known as euclidean embedding).
|
|
// Even though the endNode is not walkable
|
|
// we can use it for better heuristics since
|
|
// there is a workaround added (EuclideanEmbedding.ApplyGridGraphEndpointSpecialCase)
|
|
// which is there to support this case.
|
|
// TODO
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>Helper method to add endpoints around a specific unwalkable grid node</summary>
|
|
void AddEndpointsForSurroundingGridNodes (GridNode gridNode, Vector3 desiredPoint, int targetIndex) {
|
|
// Loop through all adjacent grid nodes
|
|
var gridGraph = GridNode.GetGridGraph(gridNode.GraphIndex);
|
|
|
|
// Number of neighbours as an int
|
|
int mxnum = gridGraph.neighbours == NumNeighbours.Four ? 4 : (gridGraph.neighbours == NumNeighbours.Eight ? 8 : 6);
|
|
|
|
// Calculate the coordinates of the node
|
|
var x = gridNode.NodeInGridIndex % gridGraph.width;
|
|
var z = gridNode.NodeInGridIndex / gridGraph.width;
|
|
|
|
for (int i = 0; i < mxnum; i++) {
|
|
int nx, nz;
|
|
if (gridGraph.neighbours == NumNeighbours.Six) {
|
|
// Hexagon graph
|
|
nx = x + GridGraph.neighbourXOffsets[GridGraph.hexagonNeighbourIndices[i]];
|
|
nz = z + GridGraph.neighbourZOffsets[GridGraph.hexagonNeighbourIndices[i]];
|
|
} else {
|
|
nx = x + GridGraph.neighbourXOffsets[i];
|
|
nz = z + GridGraph.neighbourZOffsets[i];
|
|
}
|
|
|
|
var adjacentNode = gridGraph.GetNode(nx, nz);
|
|
// Check if the position is still inside the grid
|
|
if (adjacentNode != null) {
|
|
pathHandler.AddTemporaryNode(new TemporaryNode {
|
|
type = TemporaryNodeType.End,
|
|
position = (Int3)adjacentNode.ClosestPointOnNode(desiredPoint),
|
|
associatedNode = adjacentNode.NodeIndex,
|
|
targetIndex = targetIndex,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// <summary>Prepares the path. Searches for start and end nodes and does some simple checking if a path is at all possible</summary>
|
|
protected override void Prepare () {
|
|
var startNNInfo = GetNearest(startPoint);
|
|
|
|
//Tell the NNConstraint which node was found as the start node if it is a PathNNConstraint and not a normal NNConstraint
|
|
if (nnConstraint is PathNNConstraint pathNNConstraint) {
|
|
pathNNConstraint.SetStart(startNNInfo.node);
|
|
}
|
|
|
|
startPoint = startNNInfo.position;
|
|
|
|
if (startNNInfo.node == null) {
|
|
FailWithError("Couldn't find a node close to the start point");
|
|
return;
|
|
}
|
|
|
|
if (!CanTraverse(startNNInfo.node)) {
|
|
FailWithError("The node closest to the start point could not be traversed");
|
|
return;
|
|
}
|
|
|
|
pathHandler.AddTemporaryNode(new TemporaryNode {
|
|
associatedNode = startNNInfo.node.NodeIndex,
|
|
position = (Int3)startNNInfo.position,
|
|
type = TemporaryNodeType.Start,
|
|
});
|
|
|
|
// If it is declared that this path type has an end point
|
|
// Some path types might want to use most of the ABPath code, but will not have an explicit end point at this stage
|
|
uint endNodeIndex = 0;
|
|
if (hasEndPoint) {
|
|
var endNNInfo = GetNearest(originalEndPoint);
|
|
endPoint = endNNInfo.position;
|
|
|
|
if (endNNInfo.node == null) {
|
|
FailWithError("Couldn't find a node close to the end point");
|
|
return;
|
|
}
|
|
|
|
// This should not trigger unless the user has modified the NNConstraint
|
|
if (!CanTraverse(endNNInfo.node)) {
|
|
FailWithError("The node closest to the end point could not be traversed");
|
|
return;
|
|
}
|
|
|
|
// This should not trigger unless the user has modified the NNConstraint
|
|
if (startNNInfo.node.Area != endNNInfo.node.Area) {
|
|
FailWithError("There is no valid path to the target");
|
|
return;
|
|
}
|
|
|
|
endNodeIndex = endNNInfo.node.NodeIndex;
|
|
|
|
#if !ASTAR_NO_GRID_GRAPH
|
|
// Potentially we want to special case grid graphs a bit
|
|
// to better support some kinds of games
|
|
if (!EndPointGridGraphSpecialCase(endNNInfo.node, originalEndPoint, 0))
|
|
#endif
|
|
{
|
|
pathHandler.AddTemporaryNode(new TemporaryNode {
|
|
associatedNode = endNNInfo.node.NodeIndex,
|
|
position = (Int3)endNNInfo.position,
|
|
type = TemporaryNodeType.End,
|
|
});
|
|
}
|
|
}
|
|
|
|
TemporaryEndNodesBoundingBox(out var mnTarget, out var mxTarget);
|
|
heuristicObjective = new HeuristicObjective(mnTarget, mxTarget, heuristic, heuristicScale, endNodeIndex, AstarPath.active.euclideanEmbedding);
|
|
MarkNodesAdjacentToTemporaryEndNodes();
|
|
AddStartNodesToHeap();
|
|
}
|
|
|
|
void CompletePartial () {
|
|
CompleteState = PathCompleteState.Partial;
|
|
// TODO: Add unit test for this
|
|
endPoint = pathHandler.GetNode(partialBestTargetPathNodeIndex).ClosestPointOnNode(originalEndPoint);
|
|
cost = partialBestTargetGScore;
|
|
Trace(partialBestTargetPathNodeIndex);
|
|
}
|
|
|
|
protected override void OnHeapExhausted () {
|
|
if (calculatePartial && partialBestTargetPathNodeIndex != GraphNode.InvalidNodeIndex) {
|
|
CompletePartial();
|
|
} else {
|
|
FailWithError("Searched all reachable nodes, but could not find target. This can happen if you have nodes with a different tag blocking the way to the goal. You can enable path.calculatePartial to handle that case as a workaround (though this comes with a performance cost).");
|
|
}
|
|
}
|
|
|
|
protected override void OnFoundEndNode (uint pathNode, uint hScore, uint gScore) {
|
|
if (pathHandler.IsTemporaryNode(pathNode)) {
|
|
// Common case, a temporary node is used to represent the target.
|
|
// However, it may not be clamped to the closest point on the node.
|
|
// In particular the grid graph special case will not clamp it.
|
|
// So we clamp it here instead.
|
|
var tempNode = pathHandler.GetTemporaryNode(pathNode);
|
|
var associatedNode = pathHandler.GetNode(tempNode.associatedNode);
|
|
if (endingCondition != null && !endingCondition.TargetFound(associatedNode, partialBestTargetHScore, gScore)) {
|
|
// The ending condition is not fulfilled, so we should not stop here.
|
|
// This is a weird situation where the ending condition doesn't consider the closest node to the destination
|
|
// as a valid end node. It can be useful in rare cases, though.
|
|
return;
|
|
}
|
|
endPoint = (Vector3)tempNode.position;
|
|
endPoint = associatedNode.ClosestPointOnNode(endPoint);
|
|
} else {
|
|
// The target node is a normal node. We use the center of the node as the end point.
|
|
// This can happen when using a custom ending condition.
|
|
var node = pathHandler.GetNode(pathNode);
|
|
endPoint = (Vector3)node.position;
|
|
}
|
|
cost = gScore;
|
|
CompleteState = PathCompleteState.Complete;
|
|
Trace(pathNode);
|
|
}
|
|
|
|
public override void OnVisitNode (uint pathNode, uint hScore, uint gScore) {
|
|
// This method may be called multiple times without checking if the path is complete yet.
|
|
if (CompleteState != PathCompleteState.NotCalculated) return;
|
|
|
|
if (endingCondition != null) {
|
|
var node = pathHandler.GetNode(pathNode);
|
|
if (endingCondition.TargetFound(node, hScore, gScore)) {
|
|
OnFoundEndNode(pathNode, hScore, gScore);
|
|
if (CompleteState == PathCompleteState.Complete) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hScore < partialBestTargetHScore) {
|
|
partialBestTargetPathNodeIndex = pathNode;
|
|
partialBestTargetHScore = hScore;
|
|
partialBestTargetGScore = gScore;
|
|
}
|
|
}
|
|
|
|
/// <summary>Returns a debug string for this path.</summary>
|
|
protected override string DebugString (PathLog logMode) {
|
|
if (logMode == PathLog.None || (!error && logMode == PathLog.OnlyErrors)) {
|
|
return "";
|
|
}
|
|
|
|
var text = new System.Text.StringBuilder();
|
|
|
|
DebugStringPrefix(logMode, text);
|
|
if (!error) {
|
|
text.Append(" Path Cost: ");
|
|
text.Append(cost);
|
|
}
|
|
|
|
if (!error && logMode == PathLog.Heavy) {
|
|
if (hasEndPoint && endNode != null) {
|
|
// text.Append("\nEnd Node\n G: ");
|
|
// text.Append(nodeR.G);
|
|
// text.Append("\n H: ");
|
|
// text.Append(nodeR.H);
|
|
// text.Append("\n F: ");
|
|
// text.Append(nodeR.F);
|
|
text.Append("\n Point: ");
|
|
text.Append(((Vector3)endPoint).ToString());
|
|
text.Append("\n Graph: ");
|
|
text.Append(endNode.GraphIndex);
|
|
}
|
|
|
|
text.Append("\nStart Node");
|
|
text.Append("\n Point: ");
|
|
text.Append(((Vector3)startPoint).ToString());
|
|
text.Append("\n Graph: ");
|
|
if (startNode != null) text.Append(startNode.GraphIndex);
|
|
else text.Append("< null startNode >");
|
|
}
|
|
|
|
DebugStringSuffix(logMode, text);
|
|
|
|
return text.ToString();
|
|
}
|
|
}
|
|
}
|