Update README with project details and setup instructions; add data directories to .gitignore

This commit is contained in:
Leonid Pershin
2026-08-16 17:09:16 +03:00
parent 9d09d6e97f
commit 8460921bfa
73 changed files with 6978 additions and 1 deletions
@@ -0,0 +1,89 @@
using TheLivingWorld.Core.Ecs;
namespace TheLivingWorld.Core.Contracts;
/// <summary>
/// The wire format the client renders. Geometry travels as flat <c>[x0, y0, x1, y1, ...]</c> arrays of world
/// metres — that is exactly what PixiJS <c>Graphics.poly()</c> takes, so the client never has to reshape it.
/// X grows east, Y grows north; the client flips Y once at the container level.
/// </summary>
public sealed record ChunkDto
{
public required int X { get; init; }
public required int Y { get; init; }
/// <summary>[minX, minY, maxX, maxY] widened to cover geometry overhanging the chunk's own square.</summary>
public required float[] Bounds { get; init; }
public BuildingDto[] Buildings { get; init; } = [];
public RoadDto[] Roads { get; init; } = [];
public AreaDto[] Areas { get; init; } = [];
public WaterDto[] Water { get; init; } = [];
}
public sealed record BuildingDto
{
public required long Id { get; init; }
public required BuildingKind Kind { get; init; }
public required float Height { get; init; }
public required float[] Outline { get; init; }
public float[][]? Holes { get; init; }
public string? Name { get; init; }
}
public sealed record RoadDto
{
public required long Id { get; init; }
public required RoadClass Class { get; init; }
/// <summary>Carriageway width in metres; the client strokes the centreline at this width.</summary>
public required float Width { get; init; }
public required float[] Path { get; init; }
/// <summary>Bit field matching <see cref="RoadFlags"/>.</summary>
public byte Flags { get; init; }
public string? Name { get; init; }
}
public sealed record AreaDto
{
public required long Id { get; init; }
public required AreaKind Kind { get; init; }
public required float[] Outline { get; init; }
public float[][]? Holes { get; init; }
public string? Name { get; init; }
}
/// <summary>Water is either an area (a lake) or a line (a stream), never both.</summary>
public sealed record WaterDto
{
public required long Id { get; init; }
public required WaterKind Kind { get; init; }
public float[]? Outline { get; init; }
public float[][]? Holes { get; init; }
public float[]? Path { get; init; }
public float Width { get; init; }
public string? Name { get; init; }
}
@@ -0,0 +1,28 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TheLivingWorld.Core.Contracts;
/// <summary>
/// The single serializer configuration for everything on the wire. Chunk files are written to disk with these
/// options and later streamed straight to the client without re-serialization, so the store and the HTTP
/// endpoints must agree on them exactly.
/// </summary>
public static class MapJson
{
public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Applies the same configuration to an options instance owned by someone else, such as ASP.NET Core's.</summary>
public static void Apply(JsonSerializerOptions options)
{
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.PropertyNameCaseInsensitive = true;
options.NumberHandling = Options.NumberHandling;
options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
}
}
@@ -0,0 +1,106 @@
namespace TheLivingWorld.Core.Contracts;
public enum WorldStatus
{
Pending,
Generating,
Ready,
Failed,
}
/// <summary>Request body for <c>POST /api/worlds</c>.</summary>
public sealed record CreateWorldRequest
{
public string? Name { get; init; }
public required double Latitude { get; init; }
public required double Longitude { get; init; }
/// <summary>Side length of the square to generate, in kilometres.</summary>
public required double SizeKm { get; init; }
/// <summary>Re-download from Overpass even if a cached response for this box exists.</summary>
public bool ForceRefresh { get; init; }
}
/// <summary>A world as it appears in listings and while generation is still running.</summary>
public sealed record WorldSummaryDto
{
public required string Id { get; init; }
public required string Name { get; init; }
public required double Latitude { get; init; }
public required double Longitude { get; init; }
public required double SizeMeters { get; init; }
public required WorldStatus Status { get; init; }
/// <summary>Human-readable progress line while <see cref="Status"/> is <see cref="WorldStatus.Generating"/>.</summary>
public string? Stage { get; init; }
public string? Error { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public WorldStatsDto? Stats { get; init; }
}
/// <summary>Everything the client needs to set up its camera and decide which chunks to fetch.</summary>
public sealed record WorldDto
{
public required string Id { get; init; }
public required string Name { get; init; }
public required double Latitude { get; init; }
public required double Longitude { get; init; }
public required double SizeMeters { get; init; }
public required GeoBoundsDto Bounds { get; init; }
public required float ChunkSizeMeters { get; init; }
public required int ChunkCountX { get; init; }
public required int ChunkCountY { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required WorldStatsDto Stats { get; init; }
/// <summary>Only chunks that actually hold features; empty ones are omitted.</summary>
public required ChunkIndexEntryDto[] Chunks { get; init; }
}
public sealed record GeoBoundsDto(double South, double West, double North, double East);
public sealed record ChunkIndexEntryDto
{
public required int X { get; init; }
public required int Y { get; init; }
/// <summary>[minX, minY, maxX, maxY], widened to cover overhanging geometry.</summary>
public required float[] Bounds { get; init; }
public required int Features { get; init; }
}
public sealed record WorldStatsDto
{
public required int Buildings { get; init; }
public required int Roads { get; init; }
public required int Areas { get; init; }
public required int Water { get; init; }
public required int Vertices { get; init; }
}
+32
View File
@@ -0,0 +1,32 @@
using TheLivingWorld.Core.Geo;
namespace TheLivingWorld.Core.Ecs;
/// <summary>Where this entity came from in OpenStreetMap. Kept so features stay traceable back to the source data.</summary>
public record struct OsmSource(long Id, OsmElementKind Kind);
/// <summary>A closed ring: the entity's area footprint. Indexes into <see cref="Worlds.ShapeStore"/>.</summary>
public record struct Outline(int ShapeId);
/// <summary>Inner rings cut out of <see cref="Outline"/>. <c>ShapeIds</c> is null for the common no-hole case.</summary>
public record struct Holes(int[]? ShapeIds);
/// <summary>An open polyline: the entity's centreline. Indexes into <see cref="Worlds.ShapeStore"/>.</summary>
public record struct Polyline(int ShapeId);
/// <summary>Cached world-space extent, filled in by <see cref="Systems.ComputeBoundsSystem"/>.</summary>
public record struct Bounds(RectBounds Value);
/// <summary>Spatial bucket, filled in by <see cref="Systems.AssignChunksSystem"/>.</summary>
public record struct InChunk(int X, int Y);
public record struct Building(BuildingKind Kind, float HeightMeters, byte Levels);
public record struct Road(RoadClass Class, float WidthMeters, byte Lanes, RoadFlags Flags);
public record struct AreaFeature(AreaKind Kind);
public record struct Water(WaterKind Kind, float WidthMeters);
/// <summary>The <c>name</c> tag, or null. Always present so every feature archetype stays uniform.</summary>
public record struct DisplayName(string? Value);
@@ -0,0 +1,99 @@
namespace TheLivingWorld.Core.Ecs;
public enum OsmElementKind : byte
{
Node = 0,
Way = 1,
Relation = 2,
}
public enum BuildingKind : byte
{
Unknown = 0,
House,
Residential,
Apartments,
Commercial,
Retail,
Industrial,
Civic,
School,
Church,
Garage,
Shed,
Farm,
Ruins,
}
public enum RoadClass : byte
{
Unknown = 0,
Motorway,
Trunk,
Primary,
Secondary,
Tertiary,
Unclassified,
Residential,
LivingStreet,
Service,
Track,
Pedestrian,
Footway,
Cycleway,
Steps,
Path,
Railway,
}
public enum AreaKind : byte
{
Unknown = 0,
Forest,
Grass,
Meadow,
Farmland,
Orchard,
Scrub,
Heath,
Sand,
BareRock,
Wetland,
Park,
Garden,
Pitch,
Cemetery,
ResidentialZone,
IndustrialZone,
CommercialZone,
RetailZone,
Quarry,
Parking,
School,
Beach,
}
public enum WaterKind : byte
{
Unknown = 0,
Water,
Lake,
Pond,
Reservoir,
Riverbank,
River,
Stream,
Canal,
Ditch,
Drain,
}
[Flags]
public enum RoadFlags : byte
{
None = 0,
Bridge = 1 << 0,
Tunnel = 1 << 1,
Oneway = 1 << 2,
Unpaved = 1 << 3,
}
@@ -0,0 +1,204 @@
using System.Numerics;
using Arch.Core;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Geo;
using TheLivingWorld.Core.Worlds;
namespace TheLivingWorld.Core.Export;
public sealed record ExportedChunk(ChunkCoord Coord, ChunkDto Chunk, ChunkIndexEntryDto Index);
/// <summary>
/// Turns the ECS world into the per-chunk payloads the client fetches. Runs once per generation, right after
/// <see cref="Systems.AssignChunksSystem"/> has bucketed every feature.
/// </summary>
public sealed class ChunkExporter
{
private static readonly QueryDescription Buildings =
new QueryDescription().WithAll<OsmSource, Building, Outline, Holes, Bounds, InChunk, DisplayName>();
private static readonly QueryDescription Roads =
new QueryDescription().WithAll<OsmSource, Road, Polyline, Bounds, InChunk, DisplayName>();
private static readonly QueryDescription Areas =
new QueryDescription().WithAll<OsmSource, AreaFeature, Outline, Holes, Bounds, InChunk, DisplayName>();
private static readonly QueryDescription WaterAreas =
new QueryDescription().WithAll<OsmSource, Water, Outline, Holes, Bounds, InChunk, DisplayName>();
private static readonly QueryDescription WaterLines =
new QueryDescription().WithAll<OsmSource, Water, Polyline, Bounds, InChunk, DisplayName>();
public IReadOnlyList<ExportedChunk> Export(GameWorld world)
{
var shapes = world.Shapes;
var grid = world.Grid;
var buckets = new Dictionary<ChunkCoord, Bucket>();
Bucket BucketFor(int x, int y)
{
var coord = new ChunkCoord(x, y);
if (!buckets.TryGetValue(coord, out var bucket))
{
bucket = new Bucket(grid.BoundsOf(coord));
buckets[coord] = bucket;
}
return bucket;
}
world.Ecs.Query(in Buildings, (
ref OsmSource source, ref Building building, ref Outline outline, ref Holes holes,
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
{
var bucket = BucketFor(chunk.X, chunk.Y);
bucket.Extend(bounds.Value);
bucket.Buildings.Add(new BuildingDto
{
Id = source.Id,
Kind = building.Kind,
Height = building.HeightMeters,
Outline = Flatten(shapes.Get(outline.ShapeId)),
Holes = FlattenHoles(shapes, holes),
Name = name.Value,
});
});
world.Ecs.Query(in Roads, (
ref OsmSource source, ref Road road, ref Polyline line,
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
{
var bucket = BucketFor(chunk.X, chunk.Y);
bucket.Extend(bounds.Value);
bucket.Roads.Add(new RoadDto
{
Id = source.Id,
Class = road.Class,
Width = road.WidthMeters,
Path = Flatten(shapes.Get(line.ShapeId)),
Flags = (byte)road.Flags,
Name = name.Value,
});
});
world.Ecs.Query(in Areas, (
ref OsmSource source, ref AreaFeature area, ref Outline outline, ref Holes holes,
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
{
var bucket = BucketFor(chunk.X, chunk.Y);
bucket.Extend(bounds.Value);
bucket.Areas.Add(new AreaDto
{
Id = source.Id,
Kind = area.Kind,
Outline = Flatten(shapes.Get(outline.ShapeId)),
Holes = FlattenHoles(shapes, holes),
Name = name.Value,
});
});
world.Ecs.Query(in WaterAreas, (
ref OsmSource source, ref Water water, ref Outline outline, ref Holes holes,
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
{
var bucket = BucketFor(chunk.X, chunk.Y);
bucket.Extend(bounds.Value);
bucket.Water.Add(new WaterDto
{
Id = source.Id,
Kind = water.Kind,
Outline = Flatten(shapes.Get(outline.ShapeId)),
Holes = FlattenHoles(shapes, holes),
Name = name.Value,
});
});
world.Ecs.Query(in WaterLines, (
ref OsmSource source, ref Water water, ref Polyline line,
ref Bounds bounds, ref InChunk chunk, ref DisplayName name) =>
{
var bucket = BucketFor(chunk.X, chunk.Y);
bucket.Extend(bounds.Value);
bucket.Water.Add(new WaterDto
{
Id = source.Id,
Kind = water.Kind,
Path = Flatten(shapes.Get(line.ShapeId)),
Width = water.WidthMeters,
Name = name.Value,
});
});
var result = new List<ExportedChunk>(buckets.Count);
foreach (var (coord, bucket) in buckets)
result.Add(bucket.Build(coord));
result.Sort(static (a, b) => a.Coord.Y != b.Coord.Y ? a.Coord.Y - b.Coord.Y : a.Coord.X - b.Coord.X);
return result;
}
/// <summary>Packs points into <c>[x0, y0, x1, y1, ...]</c>, rounded to the centimetre to keep JSON small.</summary>
private static float[] Flatten(ReadOnlySpan<Vector2> points)
{
var flat = new float[points.Length * 2];
for (var i = 0; i < points.Length; i++)
{
flat[i * 2] = MathF.Round(points[i].X, 2);
flat[i * 2 + 1] = MathF.Round(points[i].Y, 2);
}
return flat;
}
private static float[][]? FlattenHoles(ShapeStore shapes, in Holes holes)
{
if (holes.ShapeIds is not { Length: > 0 } ids) return null;
var flat = new float[ids.Length][];
for (var i = 0; i < ids.Length; i++)
flat[i] = Flatten(shapes.Get(ids[i]));
return flat;
}
private sealed class Bucket(RectBounds square)
{
private RectBounds _bounds = square;
public List<BuildingDto> Buildings { get; } = [];
public List<RoadDto> Roads { get; } = [];
public List<AreaDto> Areas { get; } = [];
public List<WaterDto> Water { get; } = [];
public void Extend(in RectBounds featureBounds) => _bounds.Encapsulate(featureBounds);
public ExportedChunk Build(ChunkCoord coord)
{
var bounds = _bounds.ToArray();
var chunk = new ChunkDto
{
X = coord.X,
Y = coord.Y,
Bounds = bounds,
Buildings = [.. Buildings],
Roads = [.. Roads],
Areas = [.. Areas],
Water = [.. Water],
};
var index = new ChunkIndexEntryDto
{
X = coord.X,
Y = coord.Y,
Bounds = bounds,
Features = Buildings.Count + Roads.Count + Areas.Count + Water.Count,
};
return new ExportedChunk(coord, chunk, index);
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Globalization;
namespace TheLivingWorld.Core.Geo;
/// <summary>An axis-aligned WGS84 bounding box, in degrees.</summary>
public readonly record struct GeoBounds(double South, double West, double North, double East)
{
/// <summary>
/// Builds the square that encloses <paramref name="sizeMeters"/> of ground on a side, centred on
/// <paramref name="center"/>. The longitude span is widened by 1/cos(lat) so the box stays square in metres.
/// </summary>
public static GeoBounds FromCenter(GeoPoint center, double sizeMeters)
{
var half = sizeMeters / 2.0;
var latDelta = half / LocalProjection.MetersPerDegreeLatitude;
var cosLat = Math.Cos(center.Latitude * Math.PI / 180.0);
// Guard the poles: cos(lat) collapses to zero there and the longitude span would explode.
var lonDelta = half / (LocalProjection.MetersPerDegreeLatitude * Math.Max(cosLat, 1e-6));
return new GeoBounds(
South: center.Latitude - latDelta,
West: center.Longitude - lonDelta,
North: center.Latitude + latDelta,
East: center.Longitude + lonDelta);
}
public GeoPoint Center => new((South + North) / 2.0, (West + East) / 2.0);
public bool Contains(GeoPoint p) =>
p.Latitude >= South && p.Latitude <= North && p.Longitude >= West && p.Longitude <= East;
/// <summary>Overpass expects bounding boxes as <c>(south,west,north,east)</c>.</summary>
public string ToOverpassBbox() => string.Create(
CultureInfo.InvariantCulture,
$"{South:F7},{West:F7},{North:F7},{East:F7}");
}
+11
View File
@@ -0,0 +1,11 @@
namespace TheLivingWorld.Core.Geo;
/// <summary>A WGS84 coordinate. Latitude and longitude are in degrees.</summary>
public readonly record struct GeoPoint(double Latitude, double Longitude)
{
public bool IsValid =>
double.IsFinite(Latitude) && double.IsFinite(Longitude) &&
Latitude is >= -90 and <= 90 && Longitude is >= -180 and <= 180;
public override string ToString() => $"{Latitude:F7}, {Longitude:F7}";
}
@@ -0,0 +1,168 @@
using System.Numerics;
namespace TheLivingWorld.Core.Geo;
/// <summary>
/// Trims imported geometry to the generated square. OpenStreetMap returns whole ways whenever they touch the
/// query box, so a highway crossing town can reach tens of kilometres past the map edge; clipping keeps the
/// world's extents honest and stops one stray way from inflating a chunk's bounds.
/// </summary>
public static class GeometryClipper
{
/// <summary>
/// Sutherland-Hodgman clip of a closed ring against the rectangle. Returns null when nothing survives.
/// The ring is expected without a repeated closing vertex, and comes back the same way.
/// </summary>
public static Vector2[]? ClipPolygon(ReadOnlySpan<Vector2> ring, in RectBounds rect)
{
if (ring.Length < 3) return null;
var current = new List<Vector2>(ring.Length + 8);
current.AddRange(ring);
var next = new List<Vector2>(ring.Length + 8);
for (var edge = 0; edge < 4; edge++)
{
next.Clear();
if (current.Count == 0) return null;
for (var i = 0; i < current.Count; i++)
{
var a = current[i];
var b = current[(i + 1) % current.Count];
var aInside = Inside(a, edge, rect);
var bInside = Inside(b, edge, rect);
if (aInside)
{
next.Add(a);
if (!bInside) next.Add(IntersectEdge(a, b, edge, rect));
}
else if (bInside)
{
next.Add(IntersectEdge(a, b, edge, rect));
}
}
(current, next) = (next, current);
}
return current.Count >= 3 ? current.ToArray() : null;
}
/// <summary>
/// Liang-Barsky clip of an open polyline. A line that leaves and re-enters the rectangle comes back as
/// several pieces, so the result is a list of runs rather than a single path.
/// </summary>
public static List<Vector2[]> ClipPolyline(ReadOnlySpan<Vector2> line, in RectBounds rect)
{
var runs = new List<Vector2[]>();
if (line.Length < 2) return runs;
var run = new List<Vector2>();
for (var i = 0; i < line.Length - 1; i++)
{
if (!ClipSegment(line[i], line[i + 1], rect, out var start, out var end))
{
// The segment misses the box entirely, which breaks the current run.
FlushRun(run, runs);
continue;
}
if (run.Count > 0 && !NearlyEqual(run[^1], start))
FlushRun(run, runs);
if (run.Count == 0) run.Add(start);
run.Add(end);
}
FlushRun(run, runs);
return runs;
}
private static void FlushRun(List<Vector2> run, List<Vector2[]> runs)
{
if (run.Count >= 2) runs.Add(run.ToArray());
run.Clear();
}
private static bool NearlyEqual(Vector2 a, Vector2 b) => Vector2.DistanceSquared(a, b) < 1e-4f;
private static bool ClipSegment(Vector2 a, Vector2 b, in RectBounds rect, out Vector2 start, out Vector2 end)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
var t0 = 0f;
var t1 = 1f;
Span<float> p = [-dx, dx, -dy, dy];
Span<float> q = [a.X - rect.MinX, rect.MaxX - a.X, a.Y - rect.MinY, rect.MaxY - a.Y];
for (var i = 0; i < 4; i++)
{
if (p[i] == 0f)
{
// Parallel to this boundary: outside it means the whole segment is rejected.
if (q[i] < 0f)
{
start = default;
end = default;
return false;
}
continue;
}
var t = q[i] / p[i];
if (p[i] < 0f)
{
if (t > t1) { start = default; end = default; return false; }
if (t > t0) t0 = t;
}
else
{
if (t < t0) { start = default; end = default; return false; }
if (t < t1) t1 = t;
}
}
start = new Vector2(a.X + t0 * dx, a.Y + t0 * dy);
end = new Vector2(a.X + t1 * dx, a.Y + t1 * dy);
return true;
}
private static bool Inside(Vector2 p, int edge, in RectBounds rect) => edge switch
{
0 => p.X >= rect.MinX,
1 => p.X <= rect.MaxX,
2 => p.Y >= rect.MinY,
_ => p.Y <= rect.MaxY,
};
private static Vector2 IntersectEdge(Vector2 a, Vector2 b, int edge, in RectBounds rect)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
switch (edge)
{
case 0:
case 1:
{
var x = edge == 0 ? rect.MinX : rect.MaxX;
var t = dx == 0f ? 0f : (x - a.X) / dx;
return new Vector2(x, a.Y + t * dy);
}
default:
{
var y = edge == 2 ? rect.MinY : rect.MaxY;
var t = dy == 0f ? 0f : (y - a.Y) / dy;
return new Vector2(a.X + t * dx, y);
}
}
}
}
@@ -0,0 +1,39 @@
using System.Numerics;
namespace TheLivingWorld.Core.Geo;
/// <summary>
/// Flattens WGS84 coordinates onto a local metric plane centred on an origin: X grows east, Y grows north,
/// both in metres. This is an equirectangular projection with the scale factor frozen at the origin latitude,
/// which stays sub-metre accurate across the 5-20 km worlds this game generates and — unlike Web Mercator —
/// keeps distances usable directly as game units.
/// </summary>
public sealed class LocalProjection
{
public const double EarthRadiusMeters = 6378137.0;
public const double MetersPerDegreeLatitude = EarthRadiusMeters * Math.PI / 180.0;
private readonly double _metersPerDegreeLongitude;
public LocalProjection(GeoPoint origin)
{
if (!origin.IsValid)
throw new ArgumentOutOfRangeException(nameof(origin), origin, "Origin is not a valid WGS84 coordinate.");
Origin = origin;
var cosLat = Math.Cos(origin.Latitude * Math.PI / 180.0);
_metersPerDegreeLongitude = MetersPerDegreeLatitude * Math.Max(cosLat, 1e-6);
}
public GeoPoint Origin { get; }
public Vector2 Project(GeoPoint point) => Project(point.Latitude, point.Longitude);
public Vector2 Project(double latitude, double longitude) => new(
(float)((longitude - Origin.Longitude) * _metersPerDegreeLongitude),
(float)((latitude - Origin.Latitude) * MetersPerDegreeLatitude));
public GeoPoint Unproject(Vector2 local) => new(
Origin.Latitude + local.Y / MetersPerDegreeLatitude,
Origin.Longitude + local.X / _metersPerDegreeLongitude);
}
+45
View File
@@ -0,0 +1,45 @@
using System.Numerics;
namespace TheLivingWorld.Core.Geo;
public static class Polygons
{
/// <summary>Twice the signed area of a ring: positive when the ring winds counter-clockwise.</summary>
public static float SignedDoubleArea(ReadOnlySpan<Vector2> ring)
{
if (ring.Length < 3) return 0f;
var sum = 0f;
for (var i = 0; i < ring.Length; i++)
{
var a = ring[i];
var b = ring[(i + 1) % ring.Length];
sum += a.X * b.Y - b.X * a.Y;
}
return sum;
}
public static float Area(ReadOnlySpan<Vector2> ring) => MathF.Abs(SignedDoubleArea(ring)) * 0.5f;
/// <summary>Standard even-odd ray cast. Points exactly on the boundary may land either way.</summary>
public static bool Contains(ReadOnlySpan<Vector2> ring, Vector2 point)
{
if (ring.Length < 3) return false;
var inside = false;
for (int i = 0, j = ring.Length - 1; i < ring.Length; j = i++)
{
var a = ring[i];
var b = ring[j];
if (a.Y > point.Y != b.Y > point.Y &&
point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
{
inside = !inside;
}
}
return inside;
}
}
+70
View File
@@ -0,0 +1,70 @@
using System.Numerics;
namespace TheLivingWorld.Core.Geo;
/// <summary>An axis-aligned bounding box in world metres.</summary>
public struct RectBounds
{
public float MinX;
public float MinY;
public float MaxX;
public float MaxY;
public RectBounds(float minX, float minY, float maxX, float maxY)
{
MinX = minX;
MinY = minY;
MaxX = maxX;
MaxY = maxY;
}
/// <summary>An inverted box that swallows the first point it encapsulates.</summary>
public static RectBounds Empty => new(float.MaxValue, float.MaxValue, float.MinValue, float.MinValue);
public readonly bool IsEmpty => MinX > MaxX || MinY > MaxY;
public readonly float Width => MaxX - MinX;
public readonly float Height => MaxY - MinY;
public readonly Vector2 Center => new((MinX + MaxX) * 0.5f, (MinY + MaxY) * 0.5f);
public void Encapsulate(Vector2 point)
{
if (point.X < MinX) MinX = point.X;
if (point.Y < MinY) MinY = point.Y;
if (point.X > MaxX) MaxX = point.X;
if (point.Y > MaxY) MaxY = point.Y;
}
public void Encapsulate(in RectBounds other)
{
if (other.IsEmpty) return;
if (other.MinX < MinX) MinX = other.MinX;
if (other.MinY < MinY) MinY = other.MinY;
if (other.MaxX > MaxX) MaxX = other.MaxX;
if (other.MaxY > MaxY) MaxY = other.MaxY;
}
public void Expand(float margin)
{
MinX -= margin;
MinY -= margin;
MaxX += margin;
MaxY += margin;
}
public readonly bool Intersects(in RectBounds other) =>
MinX <= other.MaxX && MaxX >= other.MinX &&
MinY <= other.MaxY && MaxY >= other.MinY;
public static RectBounds FromPoints(ReadOnlySpan<Vector2> points)
{
var bounds = Empty;
foreach (var point in points)
bounds.Encapsulate(point);
return bounds;
}
public readonly float[] ToArray() => [MinX, MinY, MaxX, MaxY];
}
@@ -0,0 +1,30 @@
using Arch.Core;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Worlds;
namespace TheLivingWorld.Core.Systems;
/// <summary>
/// Buckets each feature into the chunk containing the centre of its extent. Features are never split, so a
/// long road stays whole and simply overhangs its chunk; the exported chunk bounds are widened to match, and
/// the client uses those widened bounds to decide what to fetch.
/// </summary>
public sealed class AssignChunksSystem : IWorldSystem
{
private static readonly QueryDescription Placeable = new QueryDescription().WithAll<Bounds, InChunk>();
public string Name => nameof(AssignChunksSystem);
public void Execute(GameWorld world)
{
var grid = world.Grid;
world.Ecs.Query(in Placeable, (ref Bounds bounds, ref InChunk chunk) =>
{
if (bounds.Value.IsEmpty) return;
var coord = grid.CoordOf(bounds.Value.Center);
chunk.X = coord.X;
chunk.Y = coord.Y;
});
}
}
@@ -0,0 +1,31 @@
using Arch.Core;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Geo;
using TheLivingWorld.Core.Worlds;
namespace TheLivingWorld.Core.Systems;
/// <summary>Fills every feature's cached world-space extent from the geometry it points at.</summary>
public sealed class ComputeBoundsSystem : IWorldSystem
{
private static readonly QueryDescription AreaFeatures = new QueryDescription().WithAll<Outline, Bounds>();
private static readonly QueryDescription LineFeatures = new QueryDescription().WithAll<Polyline, Bounds>();
public string Name => nameof(ComputeBoundsSystem);
public void Execute(GameWorld world)
{
var shapes = world.Shapes;
// Holes are contained by their outline, so the outline alone determines the extent.
world.Ecs.Query(in AreaFeatures, (ref Outline outline, ref Bounds bounds) =>
{
bounds.Value = RectBounds.FromPoints(shapes.Get(outline.ShapeId));
});
world.Ecs.Query(in LineFeatures, (ref Polyline line, ref Bounds bounds) =>
{
bounds.Value = RectBounds.FromPoints(shapes.Get(line.ShapeId));
});
}
}
@@ -0,0 +1,28 @@
using TheLivingWorld.Core.Worlds;
namespace TheLivingWorld.Core.Systems;
/// <summary>A pass over the ECS world. Systems are ordered and run once per generation for now.</summary>
public interface IWorldSystem
{
string Name { get; }
void Execute(GameWorld world);
}
/// <summary>Runs a fixed sequence of systems over a world.</summary>
public sealed class WorldPipeline(params IWorldSystem[] systems)
{
public IReadOnlyList<IWorldSystem> Systems { get; } = systems;
public void Run(GameWorld world)
{
foreach (var system in Systems)
system.Execute(world);
}
/// <summary>The passes every freshly built world needs before it can be sliced into chunks.</summary>
public static WorldPipeline CreateDefault() => new(
new ComputeBoundsSystem(),
new AssignChunksSystem());
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Arch" Version="2.1.0" />
</ItemGroup>
</Project>
@@ -0,0 +1,49 @@
using System.Numerics;
using TheLivingWorld.Core.Geo;
namespace TheLivingWorld.Core.Worlds;
public readonly record struct ChunkCoord(int X, int Y);
/// <summary>
/// A uniform square grid over the world square, used to slice the map into separately fetchable pieces.
/// The world is centred on the projection origin, so it spans [-half, +half] on both axes.
/// </summary>
public sealed class ChunkGrid
{
public ChunkGrid(double worldSizeMeters, float chunkSizeMeters)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(worldSizeMeters);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(chunkSizeMeters);
ChunkSizeMeters = chunkSizeMeters;
MinX = (float)(-worldSizeMeters / 2.0);
MinY = (float)(-worldSizeMeters / 2.0);
CountX = (int)Math.Ceiling(worldSizeMeters / chunkSizeMeters);
CountY = CountX;
}
public float ChunkSizeMeters { get; }
public float MinX { get; }
public float MinY { get; }
public int CountX { get; }
public int CountY { get; }
/// <summary>Locates the chunk containing <paramref name="point"/>, clamped to the grid.</summary>
public ChunkCoord CoordOf(Vector2 point)
{
var x = (int)Math.Floor((point.X - MinX) / ChunkSizeMeters);
var y = (int)Math.Floor((point.Y - MinY) / ChunkSizeMeters);
return new ChunkCoord(Math.Clamp(x, 0, CountX - 1), Math.Clamp(y, 0, CountY - 1));
}
public RectBounds BoundsOf(ChunkCoord coord) => new(
MinX + coord.X * ChunkSizeMeters,
MinY + coord.Y * ChunkSizeMeters,
MinX + (coord.X + 1) * ChunkSizeMeters,
MinY + (coord.Y + 1) * ChunkSizeMeters);
}
@@ -0,0 +1,39 @@
using Arch.Core;
using TheLivingWorld.Core.Geo;
namespace TheLivingWorld.Core.Worlds;
/// <summary>
/// A generated world: the Arch ECS world holding every map feature as an entity, the shape store its
/// components point into, and the projection/grid needed to interpret their coordinates.
/// </summary>
public sealed class GameWorld : IDisposable
{
private bool _disposed;
public GameWorld(WorldMetadata metadata)
{
Metadata = metadata;
Projection = new LocalProjection(metadata.Origin);
Grid = new ChunkGrid(metadata.SizeMeters, metadata.ChunkSizeMeters);
Shapes = new ShapeStore();
Ecs = World.Create();
}
public World Ecs { get; }
public ShapeStore Shapes { get; }
public LocalProjection Projection { get; }
public ChunkGrid Grid { get; }
public WorldMetadata Metadata { get; set; }
public void Dispose()
{
if (_disposed) return;
_disposed = true;
World.Destroy(Ecs);
}
}
@@ -0,0 +1,36 @@
using System.Numerics;
namespace TheLivingWorld.Core.Worlds;
/// <summary>
/// Side table holding every polyline and ring in the world. ECS components carry a plain integer handle into
/// this store instead of an array reference, which keeps component data blittable and keeps the archetype
/// chunks dense.
/// </summary>
public sealed class ShapeStore
{
private readonly List<Vector2[]> _shapes = [];
public int Count => _shapes.Count;
/// <summary>Takes ownership of <paramref name="points"/> and returns its handle.</summary>
public int Add(Vector2[] points)
{
ArgumentNullException.ThrowIfNull(points);
_shapes.Add(points);
return _shapes.Count - 1;
}
public ReadOnlySpan<Vector2> Get(int shapeId) => _shapes[shapeId];
public int VertexCount
{
get
{
var total = 0;
foreach (var shape in _shapes)
total += shape.Length;
return total;
}
}
}
@@ -0,0 +1,44 @@
using TheLivingWorld.Core.Geo;
namespace TheLivingWorld.Core.Worlds;
/// <summary>Everything needed to describe a generated world without loading its geometry.</summary>
public sealed record WorldMetadata
{
public required string Id { get; init; }
public required string Name { get; init; }
/// <summary>The point the player asked for. It is the projection origin, so it sits at world (0, 0).</summary>
public required GeoPoint Origin { get; init; }
/// <summary>Side length of the generated square, in metres.</summary>
public required double SizeMeters { get; init; }
public required GeoBounds Bounds { get; init; }
public required float ChunkSizeMeters { get; init; }
public required int ChunkCountX { get; init; }
public required int ChunkCountY { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public WorldStats Stats { get; init; } = new();
}
public sealed record WorldStats
{
public int Buildings { get; init; }
public int Roads { get; init; }
public int Areas { get; init; }
public int Water { get; init; }
public int Vertices { get; init; }
public int Total => Buildings + Roads + Areas + Water;
}