using System.Numerics; using System.Text.RegularExpressions; using LScape = ACE.Server.Physics.Common.LScape; using SortCell = ACE.Server.Physics.Common.SortCell; namespace Aeshnidae.Expedition; /// /// Copied from Aeshnidae.Invasions (2026-10-01, for Act 2's beacon sieges): cross-mod types are /// unsafe here, so the engine is copied, not referenced. Where invaders stand. Towns are written in map coordinates ("42.0N 33.5E"), which is how /// players and the wiki name a place; everything here works in ACE's global metres (one /// landblock is 192 m, one map unit 240 m) and turns a point back into a landblock, a cell /// and a ground height only at the end, so a ring round a town crosses landblock seams freely. /// public static partial class Placement { [GeneratedRegex(@"^\s*(\d+(?:\.\d+)?)\s*([NS])[\s,]+(\d+(?:\.\d+)?)\s*([EW])\s*$", RegexOptions.IgnoreCase)] private static partial Regex MapCoords(); /// "42.0N 33.5E" -> global metres (X east, Y north), or null. public static Vector2? ParseMapCoords(string text) { var m = MapCoords().Match(text ?? ""); if (!m.Success) return null; var ns = float.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture); var ew = float.Parse(m.Groups[3].Value, CultureInfo.InvariantCulture); if (m.Groups[2].Value.Equals("S", StringComparison.OrdinalIgnoreCase)) ns = -ns; if (m.Groups[4].Value.Equals("W", StringComparison.OrdinalIgnoreCase)) ew = -ew; // The inverse of ACE's PositionExtensions.GetMapCoords: map = global / 240 - 102. return new Vector2((ew + 102f) * 240f, (ns + 102f) * 240f); } /// Global metres of an outdoor position (indoor ones have no map place: null). public static Vector2? Global(Position? p) { if (p is null || p.Indoors) return null; var g = p.ToGlobal(); return new Vector2(g.X, g.Y); } /// /// An outdoor position on the ground at global metres (x, y), or null when the spot is /// no good: off the world, inside a building's cell, on a slope too steep to stand on. /// Must run on the world thread - it reads the physics landscape. /// public static Position? GroundAt(Vector2 global, float headingRadians = 0f) { try { if (global.X < 0 || global.Y < 0 || global.X >= 255 * Position.BlockLength || global.Y >= 255 * Position.BlockLength) return null; var pos = new Position().FromGlobal(new Vector3(global.X, global.Y, 0f)); // FromGlobal resolves the cell by the given height; at z 0 a spot under a building // or a cave can land in an indoor cell. Only open ground will do. if (pos.Indoors || pos.LandblockId.Raw == 0) return null; if (LScape.get_landcell(pos.Cell) is not SortCell cell || cell.has_building()) return null; pos.PositionZ = pos.GetTerrainZ() + 0.05f; if (!pos.IsWalkable()) return null; // Face the given heading (radians, 0 = north, clockwise), as ACE's rotation does. pos.Rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, -headingRadians); return pos; } catch { return null; } } /// /// A ground spot on a ring round the centre, tried up to `tries` times at random angles /// and distances in [min, max]. The heading faces the centre. /// public static Position? RingSpot(Vector2 center, double min, double max, int tries = 20, double? angle = null, double spread = Math.PI) { for (var i = 0; i < tries; i++) { var a = angle.HasValue ? angle.Value + (Random.Shared.NextDouble() * 2 - 1) * spread : Random.Shared.NextDouble() * Math.PI * 2; var r = min + Random.Shared.NextDouble() * Math.Max(0, max - min); // a is measured from north, clockwise: x east = sin, y north = cos. var spot = new Vector2(center.X + (float)(Math.Sin(a) * r), center.Y + (float)(Math.Cos(a) * r)); // Heading back toward the centre. var heading = (float)Math.Atan2(center.X - spot.X, center.Y - spot.Y); var pos = GroundAt(spot, heading); if (pos is not null) return pos; } return null; } /// Global 2D distance from an outdoor position to a point; +inf indoors. public static double Distance(Position? p, Vector2 point) { var g = Global(p); return g is null ? double.PositiveInfinity : Vector2.Distance(g.Value, point); } /// "42.0N 33.5E" for a global point, the way ACE prints map coordinates. public static string MapText(Vector2 global) { var ew = global.X / 240f - 102f; var ns = global.Y / 240f - 102f; return $"{Math.Abs(ns):0.0}{(ns >= 0 ? "N" : "S")} {Math.Abs(ew):0.0}{(ew >= 0 ? "E" : "W")}"; } }