Update README with project details and setup instructions; add data directories to .gitignore
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user