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,96 @@
using TheLivingWorld.Api.Generation;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
namespace TheLivingWorld.Api.Endpoints;
public static class WorldEndpoints
{
public static IEndpointRouteBuilder MapWorldEndpoints(this IEndpointRouteBuilder app)
{
var worlds = app.MapGroup("/api/worlds").WithTags("worlds");
worlds.MapGet("/", ListWorlds);
worlds.MapPost("/", CreateWorld);
worlds.MapGet("/{id}", GetWorld);
worlds.MapGet("/{id}/map", GetMap);
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
worlds.MapDelete("/{id}", DeleteWorld);
return app;
}
private static async Task<IResult> ListWorlds(
WorldStore store,
WorldGenerationService generation,
CancellationToken cancellationToken)
{
var stored = await store.ListAsync(cancellationToken);
// A world being generated right now has a fresher status in memory than on disk.
var merged = stored
.Select(summary => generation.GetInFlight(summary.Id) ?? summary)
.ToArray();
return Results.Ok(merged);
}
private static async Task<IResult> CreateWorld(
CreateWorldRequest request,
WorldGenerationService generation,
CancellationToken cancellationToken)
{
try
{
var summary = await generation.StartAsync(request, cancellationToken);
return Results.Created($"/api/worlds/{summary.Id}", summary);
}
catch (ArgumentException ex)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = [ex.Message],
});
}
}
private static async Task<IResult> GetWorld(
string id,
WorldStore store,
WorldGenerationService generation,
CancellationToken cancellationToken)
{
if (!WorldStore.IsValidId(id)) return Results.NotFound();
if (generation.GetInFlight(id) is { } live) return Results.Ok(live);
var summary = await store.GetSummaryAsync(id, cancellationToken);
return summary is null ? Results.NotFound() : Results.Ok(summary);
}
private static async Task<IResult> GetMap(string id, WorldStore store, CancellationToken cancellationToken)
{
if (!WorldStore.IsValidId(id)) return Results.NotFound();
var world = await store.GetWorldAsync(id, cancellationToken);
return world is null ? Results.NotFound() : Results.Ok(world);
}
private static IResult GetChunk(string id, int x, int y, WorldStore store, HttpContext context)
{
if (!WorldStore.IsValidId(id)) return Results.NotFound();
var stream = store.OpenChunk(id, x, y);
if (stream is null) return Results.NotFound();
// World ids are unique per generation, so a chunk's contents can never change under a client.
context.Response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.Stream(stream, "application/json");
}
private static IResult DeleteWorld(string id, WorldStore store)
{
if (!WorldStore.IsValidId(id)) return Results.NotFound();
return store.Delete(id) ? Results.NoContent() : Results.NotFound();
}
}
@@ -0,0 +1,228 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export;
using TheLivingWorld.Core.Geo;
using TheLivingWorld.Core.Worlds;
using TheLivingWorld.Osm.Import;
namespace TheLivingWorld.Api.Generation;
/// <summary>
/// Owns world generation. Generation takes tens of seconds - most of it waiting on Overpass - so the request
/// returns immediately with a pending world and the work continues in the background; the client polls the
/// world's status until it turns ready.
/// </summary>
public sealed class WorldGenerationService(
OsmWorldGenerator generator,
WorldStore store,
ChunkExporter exporter,
IHostApplicationLifetime lifetime,
ILogger<WorldGenerationService> logger) : IDisposable
{
public const double MinSizeKm = 0.5;
public const double MaxSizeKm = 50.0;
/// <summary>Overpass mirrors are shared infrastructure, so only one download runs at a time.</summary>
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
{
var origin = new GeoPoint(request.Latitude, request.Longitude);
if (!origin.IsValid)
throw new ArgumentException("Latitude must be within +/-90 and longitude within +/-180.");
if (!double.IsFinite(request.SizeKm) || request.SizeKm is < MinSizeKm or > MaxSizeKm)
throw new ArgumentException($"Size must be between {MinSizeKm} and {MaxSizeKm} km.");
var name = string.IsNullOrWhiteSpace(request.Name)
? $"{origin.Latitude:F4}, {origin.Longitude:F4}"
: request.Name.Trim();
var id = CreateId(name);
var summary = new WorldSummaryDto
{
Id = id,
Name = name,
Latitude = origin.Latitude,
Longitude = origin.Longitude,
SizeMeters = request.SizeKm * 1000.0,
Status = WorldStatus.Pending,
Stage = "Queued",
CreatedAt = DateTimeOffset.UtcNow,
};
_inFlight[id] = summary;
await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false);
// Detached on purpose: the caller gets the pending world back straight away.
_ = Task.Run(() => RunAsync(summary, request.ForceRefresh), CancellationToken.None);
return summary;
}
/// <summary>Returns the live status of a generation still in progress, if there is one.</summary>
public WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh)
{
// Generation should stop when the host does, not drag shutdown out for minutes.
var cancellationToken = lifetime.ApplicationStopping;
try
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
try
{
var progress = new CallbackProgress(stage => Publish(summary with
{
Status = WorldStatus.Generating,
Stage = stage,
}));
Publish(summary with { Status = WorldStatus.Generating, Stage = "Starting" });
using var world = await generator.GenerateAsync(
new WorldGenerationRequest
{
Id = summary.Id,
Name = summary.Name,
Origin = new GeoPoint(summary.Latitude, summary.Longitude),
SizeMeters = summary.SizeMeters,
ForceRefresh = forceRefresh,
},
progress,
cancellationToken).ConfigureAwait(false);
progress.Report("Slicing into chunks");
var chunks = exporter.Export(world);
progress.Report($"Writing {chunks.Count:N0} chunks");
await store.SaveWorldAsync(ToDto(world, chunks), chunks, cancellationToken).ConfigureAwait(false);
var stats = world.Metadata.Stats;
var ready = summary with
{
Status = WorldStatus.Ready,
Stage = null,
Stats = new WorldStatsDto
{
Buildings = stats.Buildings,
Roads = stats.Roads,
Areas = stats.Areas,
Water = stats.Water,
Vertices = stats.Vertices,
},
};
await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false);
Publish(ready);
_inFlight.TryRemove(summary.Id, out _);
}
catch (Exception ex)
{
logger.LogError(ex, "Generation of world {Id} failed", summary.Id);
var failed = summary with
{
Status = WorldStatus.Failed,
Stage = null,
Error = ex.Message,
};
Publish(failed);
try
{
await store.SaveSummaryAsync(failed, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception saveFailure)
{
logger.LogError(saveFailure, "Could not record the failure of world {Id}", summary.Id);
}
}
finally
{
_gate.Release();
}
}
private void Publish(WorldSummaryDto summary) => _inFlight[summary.Id] = summary;
private static WorldDto ToDto(GameWorld world, IReadOnlyList<ExportedChunk> chunks)
{
var metadata = world.Metadata;
var stats = metadata.Stats;
return new WorldDto
{
Id = metadata.Id,
Name = metadata.Name,
Latitude = metadata.Origin.Latitude,
Longitude = metadata.Origin.Longitude,
SizeMeters = metadata.SizeMeters,
Bounds = new GeoBoundsDto(
metadata.Bounds.South, metadata.Bounds.West, metadata.Bounds.North, metadata.Bounds.East),
ChunkSizeMeters = metadata.ChunkSizeMeters,
ChunkCountX = metadata.ChunkCountX,
ChunkCountY = metadata.ChunkCountY,
GeneratedAt = metadata.GeneratedAt,
Stats = new WorldStatsDto
{
Buildings = stats.Buildings,
Roads = stats.Roads,
Areas = stats.Areas,
Water = stats.Water,
Vertices = stats.Vertices,
},
Chunks = [.. chunks.Select(static chunk => chunk.Index)],
};
}
/// <summary>Builds a readable, filesystem-safe id: a slug of the name plus enough entropy to stay unique.</summary>
private static string CreateId(string name)
{
var slug = new StringBuilder(32);
var previousWasDash = false;
foreach (var rune in name.Normalize(NormalizationForm.FormD))
{
if (slug.Length >= 24) break;
if (CharUnicodeInfo.GetUnicodeCategory(rune) == UnicodeCategory.NonSpacingMark) continue;
var lower = char.ToLowerInvariant(rune);
if (char.IsAsciiLetterLower(lower) || char.IsAsciiDigit(lower))
{
slug.Append(lower);
previousWasDash = false;
}
else if (!previousWasDash && slug.Length > 0)
{
slug.Append('-');
previousWasDash = true;
}
}
var prefix = slug.ToString().Trim('-');
if (prefix.Length == 0) prefix = "world";
return $"{prefix}-{Guid.NewGuid().ToString("n")[..8]}";
}
public void Dispose() => _gate.Dispose();
private sealed class CallbackProgress(Action<string> callback) : IProgress<string>
{
public void Report(string value) => callback(value);
}
}
+69
View File
@@ -0,0 +1,69 @@
using System.IO.Compression;
using Microsoft.AspNetCore.ResponseCompression;
using TheLivingWorld.Api.Endpoints;
using TheLivingWorld.Api.Generation;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export;
using TheLivingWorld.Osm;
const string WebClientCorsPolicy = "web-client";
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.ConfigureHttpJsonOptions(options => MapJson.Apply(options.SerializerOptions));
builder.Services.Configure<WorldStorageOptions>(
builder.Configuration.GetSection(WorldStorageOptions.SectionName));
// Relative paths in configuration are meant to sit next to the app, not next to whatever the working
// directory happens to be when it is launched.
builder.Services.PostConfigure<WorldStorageOptions>(options =>
options.RootDirectory = RootPath(options.RootDirectory));
#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is still marked experimental.
builder.Services.AddOsmImport(builder.Configuration)
// Overpass answers in minutes, not seconds; the resilience pipeline that AddServiceDefaults installs on
// every client would abandon the request long before that. OverpassClient retries across mirrors itself.
.RemoveAllResilienceHandlers();
#pragma warning restore EXTEXP0001
builder.Services.PostConfigure<OsmOptions>(options =>
options.CacheDirectory = RootPath(options.CacheDirectory));
builder.Services.AddSingleton<WorldStore>();
builder.Services.AddSingleton<ChunkExporter>();
builder.Services.AddSingleton<WorldGenerationService>();
// Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude.
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
});
builder.Services.Configure<BrotliCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
builder.Services.Configure<GzipCompressionProviderOptions>(o => o.Level = CompressionLevel.Fastest);
// The Vite dev server proxies /api during `aspire run`, but the client can also be pointed at the API
// directly, which crosses an origin.
builder.Services.AddCors(options => options.AddPolicy(WebClientCorsPolicy, policy => policy
.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader()
.AllowAnyMethod()));
var app = builder.Build();
app.UseResponseCompression();
app.UseCors(WebClientCorsPolicy);
app.MapDefaultEndpoints();
app.MapWorldEndpoints();
app.Run();
string RootPath(string path) =>
Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, path));
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5195",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7162;http://localhost:5195",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,9 @@
namespace TheLivingWorld.Api.Storage;
public sealed class WorldStorageOptions
{
public const string SectionName = "WorldStorage";
/// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
public string RootDirectory { get; set; } = "data/worlds";
}
@@ -0,0 +1,141 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export;
namespace TheLivingWorld.Api.Storage;
/// <summary>
/// Persists generated worlds as plain files: one folder per world holding its state, its index, and one file
/// per chunk. Chunk files are written in the exact wire format so serving them is a byte copy.
/// </summary>
public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<WorldStore> logger)
{
private const string StateFileName = "state.json";
private const string IndexFileName = "world.json";
private const string ChunksDirectoryName = "chunks";
private readonly string _root = options.Value.RootDirectory;
/// <summary>
/// Ids are used as directory names, so only a conservative slug alphabet is accepted. Everything that
/// reaches the filesystem goes through here.
/// </summary>
public static bool IsValidId(string? id) =>
!string.IsNullOrEmpty(id) &&
id.Length <= 64 &&
id.All(static c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-');
public async Task<IReadOnlyList<WorldSummaryDto>> ListAsync(CancellationToken cancellationToken = default)
{
if (!Directory.Exists(_root)) return [];
var summaries = new List<WorldSummaryDto>();
foreach (var directory in Directory.EnumerateDirectories(_root))
{
var id = Path.GetFileName(directory);
if (!IsValidId(id)) continue;
if (await GetSummaryAsync(id, cancellationToken).ConfigureAwait(false) is { } summary)
summaries.Add(summary);
}
summaries.Sort(static (a, b) => b.CreatedAt.CompareTo(a.CreatedAt));
return summaries;
}
public async Task<WorldSummaryDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
{
var path = Path.Combine(WorldDirectory(id), StateFileName);
if (!File.Exists(path)) return null;
try
{
await using var stream = File.OpenRead(path);
return await JsonSerializer
.DeserializeAsync<WorldSummaryDto>(stream, MapJson.Options, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is JsonException or IOException)
{
logger.LogWarning(ex, "Could not read state for world {Id}", id);
return null;
}
}
public async Task SaveSummaryAsync(WorldSummaryDto summary, CancellationToken cancellationToken = default)
{
var directory = WorldDirectory(summary.Id);
Directory.CreateDirectory(directory);
await WriteJsonAsync(Path.Combine(directory, StateFileName), summary, cancellationToken).ConfigureAwait(false);
}
public async Task<WorldDto?> GetWorldAsync(string id, CancellationToken cancellationToken = default)
{
var path = Path.Combine(WorldDirectory(id), IndexFileName);
if (!File.Exists(path)) return null;
await using var stream = File.OpenRead(path);
return await JsonSerializer
.DeserializeAsync<WorldDto>(stream, MapJson.Options, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Opens a chunk file for streaming straight to the response. Null when the chunk is empty.</summary>
public Stream? OpenChunk(string id, int x, int y)
{
if (x < 0 || y < 0) return null;
var path = Path.Combine(WorldDirectory(id), ChunksDirectoryName, $"{x}_{y}.json");
return File.Exists(path) ? File.OpenRead(path) : null;
}
public async Task SaveWorldAsync(
WorldDto world,
IReadOnlyList<ExportedChunk> chunks,
CancellationToken cancellationToken = default)
{
var directory = WorldDirectory(world.Id);
var chunkDirectory = Path.Combine(directory, ChunksDirectoryName);
// A regenerated world must not keep chunks that no longer exist.
if (Directory.Exists(chunkDirectory)) Directory.Delete(chunkDirectory, recursive: true);
Directory.CreateDirectory(chunkDirectory);
foreach (var chunk in chunks)
{
var path = Path.Combine(chunkDirectory, $"{chunk.Coord.X}_{chunk.Coord.Y}.json");
await WriteJsonAsync(path, chunk.Chunk, cancellationToken).ConfigureAwait(false);
}
await WriteJsonAsync(Path.Combine(directory, IndexFileName), world, cancellationToken).ConfigureAwait(false);
logger.LogInformation("Saved world {Id} with {Chunks:N0} chunks to {Directory}", world.Id, chunks.Count, directory);
}
public bool Delete(string id)
{
var directory = WorldDirectory(id);
if (!Directory.Exists(directory)) return false;
Directory.Delete(directory, recursive: true);
return true;
}
private string WorldDirectory(string id)
{
if (!IsValidId(id)) throw new ArgumentException($"'{id}' is not a valid world id.", nameof(id));
return Path.Combine(_root, id);
}
private static async Task WriteJsonAsync<T>(string path, T value, CancellationToken cancellationToken)
{
// Write beside the target and swap, so a reader never sees a half-written file.
var temporary = path + ".tmp";
await using (var stream = File.Create(temporary))
{
await JsonSerializer.SerializeAsync(stream, value, MapJson.Options, cancellationToken).ConfigureAwait(false);
}
File.Move(temporary, path, overwrite: true);
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
<ProjectReference Include="..\TheLivingWorld.Osm\TheLivingWorld.Osm.csproj" />
<ProjectReference Include="..\TheLivingWorld.ServiceDefaults\TheLivingWorld.ServiceDefaults.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"WorldStorage": {
"RootDirectory": "data/worlds"
},
"Osm": {
"Endpoints": [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter"
],
"QueryTimeoutSeconds": 180,
"RequestTimeoutSeconds": 240,
"MaxAttemptsPerEndpoint": 2,
"CacheDirectory": "data/osm-cache"
}
}
+13
View File
@@ -0,0 +1,13 @@
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.TheLivingWorld_Api>("api")
.WithHttpHealthCheck("/health");
// The Vite dev server proxies /api to the address Aspire injects, so the browser only ever talks to one origin.
builder.AddViteApp("web", "../TheLivingWorld.Web")
.WithNpm()
.WithReference(api)
.WaitFor(api)
.WithExternalHttpEndpoints();
builder.Build().Run();
@@ -0,0 +1,31 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17232;http://localhost:15180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21024",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23156",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22053"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15180",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19142",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18115",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20218"
}
}
}
}
@@ -0,0 +1,19 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
<ItemGroup>
<ProjectReference Include="..\TheLivingWorld.Api\TheLivingWorld.Api.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.4.6" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>2ffb5eff-6e16-47c4-a3f8-37b68224c539</UserSecretsId>
</PropertyGroup>
</Project>
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
@@ -0,0 +1,5 @@
{
"appHost": {
"path": "TheLivingWorld.AppHost.csproj"
}
}
@@ -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;
}
@@ -0,0 +1,283 @@
using System.Numerics;
using Microsoft.Extensions.Logging;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Geo;
using TheLivingWorld.Core.Worlds;
using TheLivingWorld.Osm.Overpass;
namespace TheLivingWorld.Osm.Import;
/// <summary>
/// Turns raw Overpass elements into ECS entities. Every feature becomes one entity whose components describe
/// what it is; the geometry itself lives in the world's <see cref="ShapeStore"/> and is referenced by handle.
/// </summary>
public sealed class OsmWorldBuilder(ILogger<OsmWorldBuilder> logger)
{
/// <summary>Rings smaller than this are noise at any zoom the client offers.</summary>
private const float MinimumAreaSquareMeters = 1.0f;
public WorldStats Populate(GameWorld world, IEnumerable<OverpassElement> elements)
{
var half = (float)(world.Metadata.SizeMeters / 2.0);
var clip = new RectBounds(-half, -half, half, half);
var counters = new Counters();
foreach (var element in elements)
{
try
{
if (element.IsWay) AddWay(world, element, clip, counters);
else if (element.IsRelation) AddRelation(world, element, clip, counters);
}
catch (Exception ex)
{
// One malformed element must not abort a whole world.
logger.LogWarning(ex, "Skipped {Type}/{Id} while importing", element.Type, element.Id);
}
}
return new WorldStats
{
Buildings = counters.Buildings,
Roads = counters.Roads,
Areas = counters.Areas,
Water = counters.Water,
Vertices = world.Shapes.VertexCount,
};
}
private void AddWay(GameWorld world, OverpassElement element, in RectBounds clip, Counters counters)
{
if (element.Geometry is not { Count: >= 2 } geometry) return;
var points = Project(world.Projection, geometry);
var closed = IsClosed(points);
var ring = closed ? TrimClosingVertex(points) : null;
var name = TagInterpreter.ReadName(element);
var source = new OsmSource(element.Id, OsmElementKind.Way);
if (element.HasTag("building") || element.HasTag("building:part"))
{
if (ring is null) return;
AddBuilding(world, source, TagInterpreter.ReadBuilding(element), ring, holes: null, name, clip, counters);
return;
}
if (TagInterpreter.ReadWater(element) is { } water)
{
if (ring is not null) AddWaterArea(world, source, water, ring, holes: null, name, clip, counters);
else AddWaterLine(world, source, water, points, name, clip, counters);
return;
}
if (TagInterpreter.IsRoad(element))
{
AddRoad(world, source, TagInterpreter.ReadRoad(element), points, name, clip, counters);
return;
}
if (TagInterpreter.ReadAreaKind(element) is { } areaKind && ring is not null)
AddArea(world, source, areaKind, ring, holes: null, name, clip, counters);
}
private void AddRelation(GameWorld world, OverpassElement element, in RectBounds clip, Counters counters)
{
if (element.Members is not { Count: > 0 } members) return;
var outerRings = AssembleRings(world.Projection, members, "outer");
if (outerRings.Count == 0) return;
var innerRings = AssembleRings(world.Projection, members, "inner");
var name = TagInterpreter.ReadName(element);
var source = new OsmSource(element.Id, OsmElementKind.Relation);
var isBuilding = element.HasTag("building");
var water = TagInterpreter.ReadWater(element);
var areaKind = TagInterpreter.ReadAreaKind(element);
if (!isBuilding && water is null && areaKind is null) return;
var building = isBuilding ? TagInterpreter.ReadBuilding(element) : default;
foreach (var outer in outerRings)
{
var holes = MatchHoles(outer, innerRings, outerRings.Count);
if (isBuilding) AddBuilding(world, source, building, outer, holes, name, clip, counters);
else if (water is { } w) AddWaterArea(world, source, w, outer, holes, name, clip, counters);
else if (areaKind is { } kind) AddArea(world, source, kind, outer, holes, name, clip, counters);
}
}
/// <summary>
/// Assigns inner rings to the outer ring that encloses them. With a single outer ring the containment
/// test is skipped - every inner ring must belong to it.
/// </summary>
private static List<Vector2[]>? MatchHoles(Vector2[] outer, List<Vector2[]> innerRings, int outerCount)
{
if (innerRings.Count == 0) return null;
if (outerCount == 1) return innerRings;
List<Vector2[]>? matched = null;
foreach (var inner in innerRings)
{
if (!Polygons.Contains(outer, inner[0])) continue;
matched ??= [];
matched.Add(inner);
}
return matched;
}
private static List<Vector2[]> AssembleRings(LocalProjection projection, List<OverpassMember> members, string role)
{
var segments = new List<IReadOnlyList<Vector2>>();
foreach (var member in members)
{
if (member.Type != "way") continue;
// An empty role means "outer" by the multipolygon spec.
var memberRole = string.IsNullOrEmpty(member.Role) ? "outer" : member.Role;
if (memberRole != role) continue;
if (member.Geometry is { Count: >= 2 } geometry)
segments.Add(Project(projection, geometry));
}
return segments.Count == 0 ? [] : RingAssembler.Assemble(segments);
}
private void AddBuilding(
GameWorld world, OsmSource source, Building building, Vector2[] ring,
List<Vector2[]>? holes, string? name, in RectBounds clip, Counters counters)
{
if (ClipRing(ring, clip) is not { } clipped) return;
world.Ecs.Create(
source,
building,
new Outline(world.Shapes.Add(clipped)),
new Holes(StoreHoles(world, holes, clip)),
new Bounds(),
new InChunk(),
new DisplayName(name));
counters.Buildings++;
}
private void AddArea(
GameWorld world, OsmSource source, AreaKind kind, Vector2[] ring,
List<Vector2[]>? holes, string? name, in RectBounds clip, Counters counters)
{
if (ClipRing(ring, clip) is not { } clipped) return;
world.Ecs.Create(
source,
new AreaFeature(kind),
new Outline(world.Shapes.Add(clipped)),
new Holes(StoreHoles(world, holes, clip)),
new Bounds(),
new InChunk(),
new DisplayName(name));
counters.Areas++;
}
private void AddWaterArea(
GameWorld world, OsmSource source, Water water, Vector2[] ring,
List<Vector2[]>? holes, string? name, in RectBounds clip, Counters counters)
{
if (ClipRing(ring, clip) is not { } clipped) return;
world.Ecs.Create(
source,
water,
new Outline(world.Shapes.Add(clipped)),
new Holes(StoreHoles(world, holes, clip)),
new Bounds(),
new InChunk(),
new DisplayName(name));
counters.Water++;
}
private void AddRoad(
GameWorld world, OsmSource source, Road road, Vector2[] points,
string? name, in RectBounds clip, Counters counters)
{
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
{
world.Ecs.Create(
source,
road,
new Polyline(world.Shapes.Add(run)),
new Bounds(),
new InChunk(),
new DisplayName(name));
counters.Roads++;
}
}
private void AddWaterLine(
GameWorld world, OsmSource source, Water water, Vector2[] points,
string? name, in RectBounds clip, Counters counters)
{
foreach (var run in GeometryClipper.ClipPolyline(points, clip))
{
world.Ecs.Create(
source,
water,
new Polyline(world.Shapes.Add(run)),
new Bounds(),
new InChunk(),
new DisplayName(name));
counters.Water++;
}
}
private static int[]? StoreHoles(GameWorld world, List<Vector2[]>? holes, in RectBounds clip)
{
if (holes is null || holes.Count == 0) return null;
List<int>? ids = null;
foreach (var hole in holes)
{
if (ClipRing(hole, clip) is not { } clipped) continue;
ids ??= [];
ids.Add(world.Shapes.Add(clipped));
}
return ids?.ToArray();
}
private static Vector2[]? ClipRing(Vector2[] ring, in RectBounds clip)
{
var clipped = GeometryClipper.ClipPolygon(ring, clip);
return clipped is not null && Polygons.Area(clipped) >= MinimumAreaSquareMeters ? clipped : null;
}
private static Vector2[] Project(LocalProjection projection, List<OverpassNode> geometry)
{
var points = new Vector2[geometry.Count];
for (var i = 0; i < geometry.Count; i++)
points[i] = projection.Project(geometry[i].Lat, geometry[i].Lon);
return points;
}
private static bool IsClosed(Vector2[] points) =>
points.Length >= 4 && Vector2.DistanceSquared(points[0], points[^1]) < 0.0025f;
private static Vector2[] TrimClosingVertex(Vector2[] points) => points[..^1];
private sealed class Counters
{
public int Buildings;
public int Roads;
public int Areas;
public int Water;
}
}
@@ -0,0 +1,134 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using TheLivingWorld.Core.Geo;
using TheLivingWorld.Core.Systems;
using TheLivingWorld.Core.Worlds;
using TheLivingWorld.Osm.Overpass;
namespace TheLivingWorld.Osm.Import;
public sealed record WorldGenerationRequest
{
public required string Id { get; init; }
public required string Name { get; init; }
public required GeoPoint Origin { get; init; }
public required double SizeMeters { get; init; }
public float ChunkSizeMeters { get; init; } = 512f;
public bool ForceRefresh { get; init; }
}
/// <summary>
/// The one-shot world generation pass: pull the box from Overpass, import it into an ECS world, then run the
/// systems that give every feature its extent and chunk.
/// </summary>
public sealed class OsmWorldGenerator(
OverpassClient client,
OsmWorldBuilder builder,
ILogger<OsmWorldGenerator> logger)
{
private static readonly JsonSerializerOptions ParseOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly WorldPipeline _pipeline = WorldPipeline.CreateDefault();
public async Task<GameWorld> GenerateAsync(
WorldGenerationRequest request,
IProgress<string>? progress = null,
CancellationToken cancellationToken = default)
{
var bounds = GeoBounds.FromCenter(request.Origin, request.SizeMeters);
logger.LogInformation(
"Generating world {Id} at {Origin} spanning {Size:N0} m",
request.Id, request.Origin, request.SizeMeters);
var cachePath = await client
.FetchAsync(bounds, request.ForceRefresh, progress, cancellationToken)
.ConfigureAwait(false);
progress?.Report("Parsing OpenStreetMap response");
var response = await ReadResponseAsync(cachePath, cancellationToken).ConfigureAwait(false);
// Overpass reports an aborted query as a remark inside an otherwise valid 200 response. Caching that
// would poison every later run for this box, so the file goes and the caller sees the reason.
if (response.Remark is { Length: > 0 } remark && response.Elements.Count == 0)
{
TryDelete(cachePath);
throw new OverpassException($"Overpass could not complete the query: {remark}");
}
logger.LogInformation("Parsed {Count:N0} OSM elements", response.Elements.Count);
var world = new GameWorld(new WorldMetadata
{
Id = request.Id,
Name = request.Name,
Origin = request.Origin,
SizeMeters = request.SizeMeters,
Bounds = bounds,
ChunkSizeMeters = request.ChunkSizeMeters,
ChunkCountX = 0,
ChunkCountY = 0,
GeneratedAt = DateTimeOffset.UtcNow,
});
try
{
progress?.Report($"Importing {response.Elements.Count:N0} elements");
var stats = builder.Populate(world, response.Elements);
progress?.Report("Computing bounds and chunks");
_pipeline.Run(world);
world.Metadata = world.Metadata with
{
ChunkCountX = world.Grid.CountX,
ChunkCountY = world.Grid.CountY,
Stats = stats,
};
logger.LogInformation(
"World {Id} built: {Buildings:N0} buildings, {Roads:N0} roads, {Areas:N0} areas, {Water:N0} water",
request.Id, stats.Buildings, stats.Roads, stats.Areas, stats.Water);
return world;
}
catch
{
world.Dispose();
throw;
}
}
/// <summary>
/// Reads the cached response in one pass. Small-town boxes are a few megabytes of JSON; streaming the
/// element array would be the next step if this ever has to swallow a dense metropolis.
/// </summary>
private static async Task<OverpassResponse> ReadResponseAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var response = await JsonSerializer
.DeserializeAsync<OverpassResponse>(stream, ParseOptions, cancellationToken)
.ConfigureAwait(false);
return response ?? throw new OverpassException($"Cached Overpass response '{path}' is empty or malformed.");
}
private void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch (IOException ex)
{
logger.LogWarning(ex, "Could not remove the unusable cached response {Path}", path);
}
}
}
@@ -0,0 +1,81 @@
using System.Numerics;
namespace TheLivingWorld.Osm.Import;
/// <summary>
/// Stitches the member ways of an OpenStreetMap multipolygon into closed rings. Members arrive as arbitrary
/// fragments in arbitrary order and direction, so rings are grown by repeatedly attaching whichever fragment
/// shares an endpoint with the open end.
/// </summary>
public static class RingAssembler
{
/// <summary>Endpoints come from identical OSM nodes, so they match to well under a centimetre.</summary>
private const float ToleranceMeters = 0.05f;
private const float ToleranceSquared = ToleranceMeters * ToleranceMeters;
/// <summary>
/// Assembles <paramref name="segments"/> into closed rings. Fragments that never close are dropped -
/// broken multipolygons exist in the wild and a half-open ring cannot be filled.
/// </summary>
public static List<Vector2[]> Assemble(IEnumerable<IReadOnlyList<Vector2>> segments)
{
var pending = new List<List<Vector2>>();
foreach (var segment in segments)
{
if (segment.Count >= 2) pending.Add([.. segment]);
}
var rings = new List<Vector2[]>();
while (pending.Count > 0)
{
var ring = pending[^1];
pending.RemoveAt(pending.Count - 1);
while (!IsClosed(ring) && TryTakeConnecting(pending, ring[^1], out var next))
{
// The first vertex of `next` duplicates the open end, so skip it when appending.
for (var i = 1; i < next.Count; i++)
ring.Add(next[i]);
}
if (!IsClosed(ring)) continue;
ring.RemoveAt(ring.Count - 1);
if (ring.Count >= 3) rings.Add(ring.ToArray());
}
return rings;
}
private static bool IsClosed(List<Vector2> ring) =>
ring.Count >= 4 && Vector2.DistanceSquared(ring[0], ring[^1]) <= ToleranceSquared;
/// <summary>Removes and returns the fragment touching <paramref name="openEnd"/>, reversed if needed.</summary>
private static bool TryTakeConnecting(List<List<Vector2>> pending, Vector2 openEnd, out List<Vector2> segment)
{
for (var i = 0; i < pending.Count; i++)
{
var candidate = pending[i];
if (Vector2.DistanceSquared(candidate[0], openEnd) <= ToleranceSquared)
{
pending.RemoveAt(i);
segment = candidate;
return true;
}
if (Vector2.DistanceSquared(candidate[^1], openEnd) <= ToleranceSquared)
{
pending.RemoveAt(i);
candidate.Reverse();
segment = candidate;
return true;
}
}
segment = [];
return false;
}
}
@@ -0,0 +1,286 @@
using System.Globalization;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Osm.Overpass;
namespace TheLivingWorld.Osm.Import;
/// <summary>
/// Collapses OpenStreetMap's open-ended tagging into the small closed set of kinds the game renders and will
/// later simulate. Anything unrecognised falls back to <c>Unknown</c> rather than being dropped, so a feature
/// still shows up on the map even if the game does not yet understand it.
/// </summary>
public static class TagInterpreter
{
private const float MetersPerLevel = 3.0f;
private static readonly string[] UnpavedSurfaces =
["unpaved", "gravel", "dirt", "ground", "grass", "sand", "compacted", "fine_gravel", "earth", "mud"];
public static Building ReadBuilding(OverpassElement element)
{
var kind = ReadBuildingKind(element.Tag("building") ?? element.Tag("building:part"));
var levels = ReadLevels(element);
var height = ReadHeight(element, kind, levels);
return new Building(kind, height, levels);
}
private static BuildingKind ReadBuildingKind(string? value) => value switch
{
"house" or "detached" or "semidetached_house" or "bungalow" or "cabin" or "static_caravan" => BuildingKind.House,
"residential" or "terrace" or "dormitory" => BuildingKind.Residential,
"apartments" or "flats" => BuildingKind.Apartments,
"commercial" or "office" or "hotel" or "kiosk" => BuildingKind.Commercial,
"retail" or "supermarket" or "shop" or "mall" => BuildingKind.Retail,
"industrial" or "warehouse" or "factory" or "manufacture" or "silo" or "storage_tank" => BuildingKind.Industrial,
"civic" or "public" or "government" or "hospital" or "fire_station" or "train_station" => BuildingKind.Civic,
"school" or "university" or "college" or "kindergarten" => BuildingKind.School,
"church" or "chapel" or "cathedral" or "mosque" or "temple" or "synagogue" or "religious" => BuildingKind.Church,
"garage" or "garages" or "carport" => BuildingKind.Garage,
"shed" or "hut" or "roof" or "container" or "service" => BuildingKind.Shed,
"farm" or "barn" or "farm_auxiliary" or "stable" or "greenhouse" or "cowshed" => BuildingKind.Farm,
"ruins" or "collapsed" => BuildingKind.Ruins,
_ => BuildingKind.Unknown,
};
private static byte ReadLevels(OverpassElement element)
{
var raw = element.Tag("building:levels") ?? element.Tag("levels");
if (TryParseLeadingNumber(raw, out var levels) && levels is > 0 and < 200)
return (byte)Math.Round(levels);
return 0;
}
private static float ReadHeight(OverpassElement element, BuildingKind kind, byte levels)
{
if (TryParseLeadingNumber(element.Tag("height"), out var height) && height is > 0 and < 1000)
return (float)height;
if (levels > 0) return levels * MetersPerLevel;
// A sensible silhouette beats no height at all; the client shades buildings by it.
return kind switch
{
BuildingKind.Shed or BuildingKind.Garage => 3f,
BuildingKind.House => 5f,
BuildingKind.Farm => 7f,
BuildingKind.Apartments => 15f,
BuildingKind.Industrial => 9f,
BuildingKind.Church => 12f,
_ => 6f,
};
}
public static bool IsRoad(OverpassElement element) =>
element.HasTag("highway") || element.HasTag("railway");
public static Road ReadRoad(OverpassElement element)
{
if (element.Tag("highway") is not { } highway)
return new Road(RoadClass.Railway, 3f, 0, ReadRoadFlags(element));
var roadClass = ReadRoadClass(highway);
var lanes = ReadLanes(element);
var width = ReadRoadWidth(element, roadClass, lanes);
return new Road(roadClass, width, lanes, ReadRoadFlags(element));
}
private static RoadClass ReadRoadClass(string highway)
{
// motorway_link and friends carry the same traffic role as their parent class.
var trimmed = highway.EndsWith("_link", StringComparison.Ordinal) ? highway[..^5] : highway;
return trimmed switch
{
"motorway" => RoadClass.Motorway,
"trunk" => RoadClass.Trunk,
"primary" => RoadClass.Primary,
"secondary" => RoadClass.Secondary,
"tertiary" => RoadClass.Tertiary,
"unclassified" or "road" => RoadClass.Unclassified,
"residential" => RoadClass.Residential,
"living_street" => RoadClass.LivingStreet,
"service" => RoadClass.Service,
"track" => RoadClass.Track,
"pedestrian" => RoadClass.Pedestrian,
"footway" or "sidewalk" => RoadClass.Footway,
"cycleway" => RoadClass.Cycleway,
"steps" => RoadClass.Steps,
"path" or "bridleway" => RoadClass.Path,
_ => RoadClass.Unknown,
};
}
private static byte ReadLanes(OverpassElement element) =>
TryParseLeadingNumber(element.Tag("lanes"), out var lanes) && lanes is > 0 and <= 16
? (byte)Math.Round(lanes)
: (byte)0;
private static float ReadRoadWidth(OverpassElement element, RoadClass roadClass, byte lanes)
{
if (TryParseLeadingNumber(element.Tag("width"), out var width) && width is > 0 and < 100)
return (float)width;
if (lanes > 0) return lanes * 3.4f;
return roadClass switch
{
RoadClass.Motorway => 14f,
RoadClass.Trunk => 12f,
RoadClass.Primary => 10f,
RoadClass.Secondary => 9f,
RoadClass.Tertiary => 8f,
RoadClass.Residential or RoadClass.Unclassified => 6.5f,
RoadClass.LivingStreet => 5.5f,
RoadClass.Pedestrian => 5f,
RoadClass.Service => 4f,
RoadClass.Track => 3f,
RoadClass.Cycleway => 2f,
RoadClass.Footway or RoadClass.Path or RoadClass.Steps => 1.5f,
RoadClass.Railway => 3f,
_ => 5f,
};
}
private static RoadFlags ReadRoadFlags(OverpassElement element)
{
var flags = RoadFlags.None;
if (element.Tag("bridge") is { } bridge && bridge != "no") flags |= RoadFlags.Bridge;
if (element.Tag("tunnel") is { } tunnel && tunnel != "no") flags |= RoadFlags.Tunnel;
if (element.Tag("oneway") is "yes" or "1" or "-1") flags |= RoadFlags.Oneway;
if (element.Tag("surface") is { } surface && Array.IndexOf(UnpavedSurfaces, surface) >= 0)
flags |= RoadFlags.Unpaved;
return flags;
}
/// <summary>Recognises water bodies and watercourses. Returns null when the element is not water.</summary>
public static Water? ReadWater(OverpassElement element)
{
if (element.Tag("natural") == "water")
return new Water(ReadWaterBodyKind(element.Tag("water")), 0f);
if (element.Tag("landuse") is "reservoir")
return new Water(WaterKind.Reservoir, 0f);
if (element.Tag("landuse") is "basin")
return new Water(WaterKind.Water, 0f);
if (element.Tag("waterway") is not { } waterway) return null;
return waterway switch
{
"riverbank" => new Water(WaterKind.Riverbank, 0f),
"river" => new Water(WaterKind.River, ReadWaterwayWidth(element, 12f)),
"stream" => new Water(WaterKind.Stream, ReadWaterwayWidth(element, 3f)),
"canal" => new Water(WaterKind.Canal, ReadWaterwayWidth(element, 8f)),
"ditch" => new Water(WaterKind.Ditch, ReadWaterwayWidth(element, 1.5f)),
"drain" => new Water(WaterKind.Drain, ReadWaterwayWidth(element, 1.5f)),
// dam, weir, lock_gate and the like are structures, not water.
_ => null,
};
}
private static WaterKind ReadWaterBodyKind(string? water) => water switch
{
"lake" => WaterKind.Lake,
"pond" => WaterKind.Pond,
"reservoir" or "basin" => WaterKind.Reservoir,
"river" => WaterKind.Riverbank,
_ => WaterKind.Water,
};
private static float ReadWaterwayWidth(OverpassElement element, float fallback) =>
TryParseLeadingNumber(element.Tag("width"), out var width) && width is > 0 and < 500
? (float)width
: fallback;
/// <summary>Recognises land cover polygons. Returns null when the element is not one.</summary>
public static AreaKind? ReadAreaKind(OverpassElement element)
{
if (element.Tag("landuse") is { } landuse)
{
var kind = landuse switch
{
"forest" => AreaKind.Forest,
"grass" or "village_green" or "greenfield" => AreaKind.Grass,
"meadow" => AreaKind.Meadow,
"farmland" or "farmyard" or "allotments" => AreaKind.Farmland,
"orchard" or "vineyard" or "plant_nursery" => AreaKind.Orchard,
"residential" => AreaKind.ResidentialZone,
"industrial" or "railway" or "landfill" => AreaKind.IndustrialZone,
"commercial" => AreaKind.CommercialZone,
"retail" => AreaKind.RetailZone,
"quarry" => AreaKind.Quarry,
"cemetery" => AreaKind.Cemetery,
"recreation_ground" => AreaKind.Park,
_ => (AreaKind?)null,
};
if (kind is not null) return kind;
}
if (element.Tag("natural") is { } natural)
{
var kind = natural switch
{
"wood" or "tree_row" => AreaKind.Forest,
"scrub" => AreaKind.Scrub,
"heath" => AreaKind.Heath,
"grassland" => AreaKind.Grass,
"sand" or "dune" => AreaKind.Sand,
"bare_rock" or "rock" or "scree" or "cliff" => AreaKind.BareRock,
"wetland" or "marsh" => AreaKind.Wetland,
"beach" => AreaKind.Beach,
_ => (AreaKind?)null,
};
if (kind is not null) return kind;
}
if (element.Tag("leisure") is { } leisure)
{
var kind = leisure switch
{
"park" or "nature_reserve" or "common" => AreaKind.Park,
"garden" => AreaKind.Garden,
"pitch" or "playground" or "track" or "sports_centre" or "stadium" => AreaKind.Pitch,
"golf_course" => AreaKind.Grass,
_ => (AreaKind?)null,
};
if (kind is not null) return kind;
}
return element.Tag("amenity") switch
{
"parking" => AreaKind.Parking,
"school" or "college" or "university" or "kindergarten" => AreaKind.School,
"grave_yard" => AreaKind.Cemetery,
_ => null,
};
}
public static string? ReadName(OverpassElement element) =>
element.Tag("name") is { Length: > 0 } name ? name : null;
/// <summary>
/// Parses the numeric prefix of a tag value. OpenStreetMap allows units and stray text, so
/// <c>"12 m"</c> and <c>"3.5"</c> both need to work while <c>"tall"</c> must not.
/// </summary>
internal static bool TryParseLeadingNumber(string? value, out double number)
{
number = 0;
if (string.IsNullOrWhiteSpace(value)) return false;
var span = value.AsSpan().TrimStart();
var length = 0;
while (length < span.Length && (char.IsAsciiDigit(span[length]) || span[length] is '.' or '-' or '+'))
length++;
return length > 0 &&
double.TryParse(span[..length], NumberStyles.Float, CultureInfo.InvariantCulture, out number);
}
}
+31
View File
@@ -0,0 +1,31 @@
namespace TheLivingWorld.Osm;
public sealed class OsmOptions
{
public const string SectionName = "Osm";
/// <summary>
/// Overpass mirrors, tried in order. The public instances are shared infrastructure and rate limited, so
/// responses are cached on disk and a world is only ever downloaded once.
/// </summary>
public string[] Endpoints { get; set; } =
[
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
];
/// <summary>Server-side query budget, passed to Overpass as <c>[timeout:...]</c>.</summary>
public int QueryTimeoutSeconds { get; set; } = 180;
/// <summary>Client-side ceiling; must exceed <see cref="QueryTimeoutSeconds"/> to let the server reply first.</summary>
public int RequestTimeoutSeconds { get; set; } = 240;
public int MaxAttemptsPerEndpoint { get; set; } = 2;
/// <summary>Overpass asks that clients identify themselves so abusive traffic can be traced.</summary>
public string UserAgent { get; set; } = "TheLivingWorld/0.1 (map generator; contact: repository owner)";
/// <summary>Where raw Overpass responses are cached, relative to the content root unless rooted.</summary>
public string CacheDirectory { get; set; } = "data/osm-cache";
}
@@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TheLivingWorld.Osm.Import;
using TheLivingWorld.Osm.Overpass;
namespace TheLivingWorld.Osm;
public static class OsmServiceCollectionExtensions
{
/// <summary>
/// Registers the OpenStreetMap import pipeline. The returned builder is the Overpass HTTP client, which
/// callers usually need to adjust: Overpass queries run for minutes and do not fit default HTTP policies.
/// </summary>
public static IHttpClientBuilder AddOsmImport(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<OsmOptions>()
.Bind(configuration.GetSection(OsmOptions.SectionName));
services.AddSingleton<OsmWorldBuilder>();
services.AddSingleton<OsmWorldGenerator>();
return services.AddHttpClient<OverpassClient>(OverpassClient.HttpClientName, static (provider, client) =>
{
var options = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<OsmOptions>>().Value;
client.Timeout = TimeSpan.FromSeconds(options.RequestTimeoutSeconds);
client.DefaultRequestHeaders.UserAgent.ParseAdd(options.UserAgent);
});
}
}
@@ -0,0 +1,183 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TheLivingWorld.Core.Geo;
namespace TheLivingWorld.Osm.Overpass;
/// <summary>
/// Downloads a bounding box of OpenStreetMap data through Overpass and keeps the raw response on disk.
/// A world is generated once, so the cache means re-running generation for the same box costs nothing and
/// the public Overpass mirrors are not hit twice for the same data.
/// </summary>
public sealed class OverpassClient(
HttpClient http,
IOptions<OsmOptions> options,
ILogger<OverpassClient> logger)
{
public const string HttpClientName = "overpass";
private static readonly HttpStatusCode[] RetryableStatuses =
[
HttpStatusCode.TooManyRequests,
HttpStatusCode.BadGateway,
HttpStatusCode.ServiceUnavailable,
HttpStatusCode.GatewayTimeout,
];
private readonly OsmOptions _options = options.Value;
/// <summary>
/// Returns the path of the file holding the raw Overpass JSON for <paramref name="bounds"/>, downloading
/// it first if it is not already cached.
/// </summary>
public async Task<string> FetchAsync(
GeoBounds bounds,
bool forceRefresh,
IProgress<string>? progress = null,
CancellationToken cancellationToken = default)
{
var query = OverpassQuery.Build(bounds, _options.QueryTimeoutSeconds);
var cachePath = ResolveCachePath(query);
if (!forceRefresh && File.Exists(cachePath))
{
logger.LogInformation("Using cached Overpass response {Path}", cachePath);
progress?.Report("Using cached OpenStreetMap data");
return cachePath;
}
Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
// Download to a temporary file first: a half-written response must never end up in the cache.
var tempPath = cachePath + ".partial";
Exception? lastFailure = null;
foreach (var endpoint in _options.Endpoints)
{
for (var attempt = 1; attempt <= _options.MaxAttemptsPerEndpoint; attempt++)
{
try
{
progress?.Report($"Querying OpenStreetMap ({new Uri(endpoint).Host}, attempt {attempt})");
await DownloadAsync(endpoint, query, tempPath, cancellationToken).ConfigureAwait(false);
File.Move(tempPath, cachePath, overwrite: true);
logger.LogInformation(
"Downloaded {Bytes:N0} bytes of OSM data from {Endpoint}",
new FileInfo(cachePath).Length,
endpoint);
return cachePath;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
TryDeleteTemp(tempPath);
throw;
}
catch (Exception ex)
{
lastFailure = ex;
TryDeleteTemp(tempPath);
logger.LogWarning(ex, "Overpass request to {Endpoint} failed (attempt {Attempt})", endpoint, attempt);
if (attempt < _options.MaxAttemptsPerEndpoint)
await Task.Delay(TimeSpan.FromSeconds(3 * attempt), cancellationToken).ConfigureAwait(false);
}
}
}
// The message surfaces in the UI, so it carries the last reason rather than pointing at a log.
throw new OverpassException(
$"Every Overpass endpoint failed. Last error: {lastFailure?.Message ?? "unknown"}",
lastFailure);
}
private async Task DownloadAsync(string endpoint, string query, string destination, CancellationToken cancellationToken)
{
// HttpClient.Timeout stops applying once the headers are in, and a busy Overpass instance will answer
// 200 immediately and then sit on the body indefinitely. This deadline covers the whole download.
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(_options.RequestTimeoutSeconds));
var token = deadline.Token;
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new StringContent(query, Encoding.UTF8, "text/plain"),
};
using var response = await http
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var message = $"Overpass returned {(int)response.StatusCode} {response.StatusCode}: " +
await ReadSnippetAsync(response, token).ConfigureAwait(false);
throw Array.IndexOf(RetryableStatuses, response.StatusCode) >= 0
? new OverpassTransientException(message)
: new OverpassException(message);
}
// An overloaded instance reports "the server is probably too busy" as an HTML page under a 200.
var mediaType = response.Content.Headers.ContentType?.MediaType;
if (mediaType is not null && !mediaType.Contains("json", StringComparison.OrdinalIgnoreCase))
{
throw new OverpassTransientException(
$"Overpass returned {mediaType} instead of JSON: " +
await ReadSnippetAsync(response, token).ConfigureAwait(false));
}
await using var source = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
await using var target = File.Create(destination);
await source.CopyToAsync(target, token).ConfigureAwait(false);
}
/// <summary>Overpass error pages are verbose HTML; only the first few hundred characters are useful.</summary>
private static async Task<string> ReadSnippetAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
try
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var text = System.Text.RegularExpressions.Regex.Replace(body, "<[^>]*>", " ");
text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
return text.Length > 300 ? text[..300] : text;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or OperationCanceledException)
{
return "(response body unavailable)";
}
}
/// <summary>The cache key is the query itself, so any change to the box or the query invalidates it.</summary>
private string ResolveCachePath(string query)
{
var payload = $"v{OverpassQuery.Version}\n{query}";
var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(payload)));
return Path.Combine(_options.CacheDirectory, $"{hash}.json");
}
private static void TryDeleteTemp(string path)
{
try
{
if (File.Exists(path)) File.Delete(path);
}
catch (IOException)
{
// A leftover .partial file is harmless; it is overwritten on the next attempt.
}
}
}
public class OverpassException : Exception
{
public OverpassException(string message) : base(message) { }
public OverpassException(string message, Exception? inner) : base(message, inner) { }
}
public sealed class OverpassTransientException(string message) : OverpassException(message);
@@ -0,0 +1,66 @@
using System.Text.Json.Serialization;
namespace TheLivingWorld.Osm.Overpass;
/// <summary>Shape of an Overpass <c>[out:json]</c> response requested with <c>out geom;</c>.</summary>
public sealed class OverpassResponse
{
[JsonPropertyName("elements")]
public List<OverpassElement> Elements { get; set; } = [];
/// <summary>Set when Overpass aborts mid-query, typically on a timeout or memory limit.</summary>
[JsonPropertyName("remark")]
public string? Remark { get; set; }
}
public sealed class OverpassElement
{
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("tags")]
public Dictionary<string, string>? Tags { get; set; }
/// <summary>Present on ways when the query used <c>out geom</c>.</summary>
[JsonPropertyName("geometry")]
public List<OverpassNode>? Geometry { get; set; }
/// <summary>Present on relations; each member carries its own geometry under <c>out geom</c>.</summary>
[JsonPropertyName("members")]
public List<OverpassMember>? Members { get; set; }
public bool IsWay => Type == "way";
public bool IsRelation => Type == "relation";
public string? Tag(string key) => Tags is not null && Tags.TryGetValue(key, out var value) ? value : null;
public bool HasTag(string key) => Tags is not null && Tags.ContainsKey(key);
}
public sealed class OverpassMember
{
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("ref")]
public long Ref { get; set; }
[JsonPropertyName("role")]
public string? Role { get; set; }
[JsonPropertyName("geometry")]
public List<OverpassNode>? Geometry { get; set; }
}
public readonly struct OverpassNode
{
[JsonPropertyName("lat")]
public double Lat { get; init; }
[JsonPropertyName("lon")]
public double Lon { get; init; }
}
@@ -0,0 +1,44 @@
using System.Globalization;
using TheLivingWorld.Core.Geo;
namespace TheLivingWorld.Osm.Overpass;
public static class OverpassQuery
{
/// <summary>
/// Bumped whenever <see cref="Build"/> changes shape. It is mixed into the cache key so an edited query
/// never silently reuses a response fetched under the old one.
/// </summary>
public const int Version = 1;
/// <summary>
/// Everything the map renderer draws: building footprints, the road and rail network, water, and the
/// land-cover polygons underneath them. <c>out geom</c> inlines coordinates into each way and relation
/// member, so no second pass is needed to resolve node references.
/// </summary>
public static string Build(GeoBounds bounds, int timeoutSeconds)
{
var bbox = bounds.ToOverpassBbox();
var timeout = timeoutSeconds.ToString(CultureInfo.InvariantCulture);
return $"""
[out:json][timeout:{timeout}];
(
way["building"]({bbox});
relation["building"]["type"="multipolygon"]({bbox});
way["building:part"]({bbox});
way["highway"]({bbox});
way["railway"~"^(rail|light_rail|narrow_gauge|tram|subway|disused|abandoned)$"]({bbox});
way["waterway"]({bbox});
way["natural"]({bbox});
relation["natural"]["type"="multipolygon"]({bbox});
way["landuse"]({bbox});
relation["landuse"]["type"="multipolygon"]({bbox});
way["leisure"]({bbox});
relation["leisure"]["type"="multipolygon"]({bbox});
way["amenity"~"^(parking|school|grave_yard)$"]({bbox});
);
out geom;
""";
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TheLivingWorld.Core\TheLivingWorld.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="TheLivingWorld.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ServiceDiscovery;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
// Uncomment the following to restrict the allowed schemes for service discovery.
// builder.Services.Configure<ServiceDiscoveryOptions>(options =>
// {
// options.AllowedSchemes = ["https"];
// });
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
// Exclude health check requests from tracing
tracing.Filter = context =>
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath)
)
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
});
builder.AddOpenTelemetryExporters();
return builder;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
// Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
//if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
//{
// builder.Services.AddOpenTelemetry()
// .UseAzureMonitor();
//}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
// Add a default liveness check to ensure app is responsive
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.2.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
</ItemGroup>
</Project>
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>The Living World</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="stage"></div>
<aside id="panel" class="panel">
<header class="panel__header">
<h1>The Living World</h1>
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
</header>
<form id="generate-form" class="form">
<label class="field">
<span>Name</span>
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
</label>
<div class="field-row">
<label class="field">
<span>Latitude</span>
<input id="field-lat" type="number" step="any" value="31.8966010" required />
</label>
<label class="field">
<span>Longitude</span>
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
</label>
</div>
<label class="field">
<span>Size <output id="field-size-value">10 km</output></span>
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
</label>
<button id="generate-button" type="submit" class="button">Generate world</button>
</form>
<section class="worlds">
<h2>Worlds</h2>
<ul id="world-list" class="world-list"></ul>
</section>
<footer id="status" class="status"></footer>
</aside>
<div id="hud" class="hud"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+917
View File
@@ -0,0 +1,917 @@
{
"name": "the-living-world-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "the-living-world-web",
"version": "0.1.0",
"dependencies": {
"pixi.js": "^8.19.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"typescript": "^5.9.3",
"vite": "^8.2.1"
}
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pixi/colord": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz",
"integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==",
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/earcut": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz",
"integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.14",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz",
"integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gifuct-js": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz",
"integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==",
"license": "MIT",
"dependencies": {
"js-binary-schema-parser": "^2.0.3"
}
},
"node_modules/ismobilejs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz",
"integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==",
"license": "MIT"
},
"node_modules/js-binary-schema-parser": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz",
"integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==",
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/parse-svg-path": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz",
"integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pixi.js": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz",
"integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==",
"license": "MIT",
"workspaces": [
"examples",
"playground"
],
"dependencies": {
"@pixi/colord": "^2.9.6",
"@types/earcut": "^3.0.0",
"@webgpu/types": "^0.1.69",
"@xmldom/xmldom": "^0.8.13",
"earcut": "^3.0.2",
"eventemitter3": "^5.0.1",
"gifuct-js": "^2.1.2",
"ismobilejs": "^1.1.1",
"parse-svg-path": "^0.2.0",
"tiny-lru": "^11.4.7"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/pixijs"
}
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.144.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.2.4",
"@rolldown/binding-darwin-arm64": "1.2.4",
"@rolldown/binding-darwin-x64": "1.2.4",
"@rolldown/binding-freebsd-x64": "1.2.4",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
"@rolldown/binding-linux-arm64-musl": "1.2.4",
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
"@rolldown/binding-linux-x64-gnu": "1.2.4",
"@rolldown/binding-linux-x64-musl": "1.2.4",
"@rolldown/binding-openharmony-arm64": "1.2.4",
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
"@rolldown/binding-win32-x64-msvc": "1.2.4"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tiny-lru": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz",
"integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
"postcss": "^8.5.25",
"rolldown": "~1.2.1",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.4.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "the-living-world-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"pixi.js": "^8.19.0"
},
"devDependencies": {
"@types/node": "^26.2.0",
"typescript": "^5.9.3",
"vite": "^8.2.1"
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types';
const BASE = '/api/worlds';
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(await describeFailure(response));
}
return (await response.json()) as T;
}
async function describeFailure(response: Response): Promise<string> {
try {
const problem = (await response.json()) as { title?: string; errors?: Record<string, string[]> };
const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined;
if (details) return details;
if (problem.title) return problem.title;
} catch {
// Not a problem-details body; fall through to the status line.
}
return `${response.status} ${response.statusText}`;
}
export const api = {
listWorlds: () => request<WorldSummary[]>(BASE),
createWorld: (body: CreateWorldRequest) =>
request<WorldSummary>(BASE, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
getWorld: (id: string) => request<WorldSummary>(`${BASE}/${id}`),
getMap: (id: string) => request<WorldMap>(`${BASE}/${id}/map`),
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
deleteWorld: async (id: string): Promise<void> => {
const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error(await describeFailure(response));
},
};
/** Polls a world until generation finishes, reporting each stage change as it happens. */
export async function waitForWorld(
id: string,
onProgress: (summary: WorldSummary) => void,
intervalMs = 1500,
): Promise<WorldSummary> {
for (;;) {
const summary = await api.getWorld(id);
onProgress(summary);
if (summary.status === 'ready' || summary.status === 'failed') return summary;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
+132
View File
@@ -0,0 +1,132 @@
/** Mirrors the DTOs in TheLivingWorld.Core.Contracts. Keep both sides in step. */
export type WorldStatus = 'pending' | 'generating' | 'ready' | 'failed';
export interface WorldStats {
buildings: number;
roads: number;
areas: number;
water: number;
vertices: number;
}
export interface WorldSummary {
id: string;
name: string;
latitude: number;
longitude: number;
sizeMeters: number;
status: WorldStatus;
stage?: string;
error?: string;
createdAt: string;
stats?: WorldStats;
}
export interface CreateWorldRequest {
name?: string;
latitude: number;
longitude: number;
sizeKm: number;
forceRefresh?: boolean;
}
/** [minX, minY, maxX, maxY] in world metres. */
export type BoundsArray = [number, number, number, number];
export interface ChunkIndexEntry {
x: number;
y: number;
bounds: BoundsArray;
features: number;
}
export interface WorldMap {
id: string;
name: string;
latitude: number;
longitude: number;
sizeMeters: number;
bounds: { south: number; west: number; north: number; east: number };
chunkSizeMeters: number;
chunkCountX: number;
chunkCountY: number;
generatedAt: string;
stats: WorldStats;
chunks: ChunkIndexEntry[];
}
export type BuildingKind =
| 'unknown' | 'house' | 'residential' | 'apartments' | 'commercial' | 'retail' | 'industrial'
| 'civic' | 'school' | 'church' | 'garage' | 'shed' | 'farm' | 'ruins';
export type RoadClass =
| 'unknown' | 'motorway' | 'trunk' | 'primary' | 'secondary' | 'tertiary' | 'unclassified'
| 'residential' | 'livingStreet' | 'service' | 'track' | 'pedestrian' | 'footway' | 'cycleway'
| 'steps' | 'path' | 'railway';
export type AreaKind =
| 'unknown' | 'forest' | 'grass' | 'meadow' | 'farmland' | 'orchard' | 'scrub' | 'heath' | 'sand'
| 'bareRock' | 'wetland' | 'park' | 'garden' | 'pitch' | 'cemetery' | 'residentialZone'
| 'industrialZone' | 'commercialZone' | 'retailZone' | 'quarry' | 'parking' | 'school' | 'beach';
export type WaterKind =
| 'unknown' | 'water' | 'lake' | 'pond' | 'reservoir' | 'riverbank' | 'river' | 'stream'
| 'canal' | 'ditch' | 'drain';
/** Bit values matching RoadFlags on the server. */
export const RoadFlags = {
bridge: 1,
tunnel: 2,
oneway: 4,
unpaved: 8,
} as const;
/** Geometry travels as flat [x0, y0, x1, y1, ...] arrays, ready for Graphics.poly(). */
export type FlatPoints = number[];
export interface BuildingFeature {
id: number;
kind: BuildingKind;
height: number;
outline: FlatPoints;
holes?: FlatPoints[];
name?: string;
}
export interface RoadFeature {
id: number;
class: RoadClass;
width: number;
path: FlatPoints;
flags?: number;
name?: string;
}
export interface AreaFeature {
id: number;
kind: AreaKind;
outline: FlatPoints;
holes?: FlatPoints[];
name?: string;
}
export interface WaterFeature {
id: number;
kind: WaterKind;
outline?: FlatPoints;
holes?: FlatPoints[];
path?: FlatPoints;
width?: number;
name?: string;
}
export interface MapChunk {
x: number;
y: number;
bounds: BoundsArray;
buildings: BuildingFeature[];
roads: RoadFeature[];
areas: AreaFeature[];
water: WaterFeature[];
}
+204
View File
@@ -0,0 +1,204 @@
import './styles.css';
import { api, waitForWorld } from './api/client';
import type { WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView';
const LAST_WORLD_KEY = 'the-living-world:last-world';
const elements = {
stage: required<HTMLDivElement>('stage'),
form: required<HTMLFormElement>('generate-form'),
name: required<HTMLInputElement>('field-name'),
latitude: required<HTMLInputElement>('field-lat'),
longitude: required<HTMLInputElement>('field-lon'),
size: required<HTMLInputElement>('field-size'),
sizeValue: required<HTMLOutputElement>('field-size-value'),
generate: required<HTMLButtonElement>('generate-button'),
worldList: required<HTMLUListElement>('world-list'),
status: required<HTMLElement>('status'),
hud: required<HTMLDivElement>('hud'),
};
const view = new MapView();
let activeWorldId: string | null = null;
function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing element #${id}`);
return element as T;
}
function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): void {
elements.status.textContent = message;
elements.status.dataset.tone = tone;
}
function renderHud(status: MapStatus): void {
const scale = status.metersPerPixel >= 10
? `${Math.round(status.metersPerPixel)} m/px`
: `${status.metersPerPixel.toFixed(1)} m/px`;
const chunks = `${status.loadedChunks}/${status.totalChunks} chunks`;
const position = `${Math.round(status.center.x)}, ${Math.round(status.center.y)} m`;
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
}
async function refreshWorldList(): Promise<WorldSummary[]> {
const worlds = await api.listWorlds();
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
return worlds;
}
function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li');
item.className = 'world';
item.dataset.status = world.status;
if (world.id === activeWorldId) item.dataset.active = 'true';
const open = document.createElement('button');
open.type = 'button';
open.className = 'world__open';
open.disabled = world.status !== 'ready';
const title = document.createElement('span');
title.className = 'world__name';
title.textContent = world.name;
const detail = document.createElement('span');
detail.className = 'world__detail';
detail.textContent = describeWorld(world);
open.append(title, detail);
open.addEventListener('click', () => {
void openWorld(world.id);
});
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'world__delete';
remove.title = 'Delete world';
remove.textContent = '×';
remove.addEventListener('click', () => {
void deleteWorld(world);
});
item.append(open, remove);
return item;
}
function describeWorld(world: WorldSummary): string {
if (world.status === 'failed') return world.error ?? 'Generation failed';
if (world.status !== 'ready') return world.stage ?? world.status;
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
if (!world.stats) return size;
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
}
async function openWorld(id: string): Promise<void> {
try {
setStatus('Loading map…', 'busy');
const map = await api.getMap(id);
activeWorldId = id;
localStorage.setItem(LAST_WORLD_KEY, id);
view.showWorld(map);
setStatus(`${map.name}${map.stats.buildings.toLocaleString()} buildings, ${map.chunks.length} chunks`);
await refreshWorldList();
} catch (error) {
setStatus(`Could not open world: ${message(error)}`, 'error');
}
}
async function deleteWorld(world: WorldSummary): Promise<void> {
if (!confirm(`Delete "${world.name}"?`)) return;
try {
await api.deleteWorld(world.id);
if (activeWorldId === world.id) {
activeWorldId = null;
localStorage.removeItem(LAST_WORLD_KEY);
view.clear();
elements.hud.textContent = '';
}
await refreshWorldList();
setStatus(`Deleted ${world.name}`);
} catch (error) {
setStatus(`Could not delete world: ${message(error)}`, 'error');
}
}
async function generate(event: SubmitEvent): Promise<void> {
event.preventDefault();
const latitude = Number(elements.latitude.value);
const longitude = Number(elements.longitude.value);
const sizeKm = Number(elements.size.value);
elements.generate.disabled = true;
setStatus('Requesting world…', 'busy');
try {
const created = await api.createWorld({
name: elements.name.value.trim() || undefined,
latitude,
longitude,
sizeKm,
});
await refreshWorldList();
const finished = await waitForWorld(created.id, (summary) => {
setStatus(summary.stage ? `${summary.name}: ${summary.stage}` : `${summary.name}: ${summary.status}`, 'busy');
void refreshWorldList();
});
if (finished.status === 'failed') {
setStatus(`Generation failed: ${finished.error ?? 'unknown error'}`, 'error');
return;
}
await openWorld(finished.id);
} catch (error) {
setStatus(`Generation failed: ${message(error)}`, 'error');
} finally {
elements.generate.disabled = false;
void refreshWorldList();
}
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function start(): Promise<void> {
elements.size.addEventListener('input', () => {
elements.sizeValue.textContent = `${elements.size.value} km`;
});
elements.form.addEventListener('submit', (event) => {
void generate(event);
});
view.onStatusChange = renderHud;
await view.init(elements.stage);
try {
const worlds = await refreshWorldList();
const remembered = localStorage.getItem(LAST_WORLD_KEY);
const target =
worlds.find((world) => world.id === remembered && world.status === 'ready') ??
worlds.find((world) => world.status === 'ready');
if (target) await openWorld(target.id);
else setStatus('No worlds yet — generate one to get started.');
} catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error');
}
}
void start();
+97
View File
@@ -0,0 +1,97 @@
export interface Viewport {
width: number;
height: number;
}
export interface WorldRect {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
/**
* Maps world metres to screen pixels. World Y grows north, screen Y grows down, so the vertical scale is
* negative — the one place the flip is expressed, and every other module can think in map coordinates.
*/
export class Camera {
/** Camera centre, in world metres. */
x = 0;
y = 0;
/** Screen pixels per world metre. */
zoom = 0.1;
minZoom = 0.005;
maxZoom = 6;
/** Frames the whole world square with a little breathing room and pins the zoom-out limit to it. */
fit(worldSizeMeters: number, viewport: Viewport): void {
const usable = Math.min(viewport.width, viewport.height);
this.zoom = usable > 0 ? (usable / worldSizeMeters) * 0.92 : 0.1;
this.minZoom = this.zoom * 0.35;
this.maxZoom = 6;
this.x = 0;
this.y = 0;
}
translateBy(screenDx: number, screenDy: number): void {
this.x -= screenDx / this.zoom;
this.y += screenDy / this.zoom;
}
/** Zooms about a screen anchor so the world point under the cursor stays put. */
zoomAt(factor: number, screenX: number, screenY: number, viewport: Viewport): void {
const before = this.screenToWorld(screenX, screenY, viewport);
this.zoom = clamp(this.zoom * factor, this.minZoom, this.maxZoom);
const after = this.screenToWorld(screenX, screenY, viewport);
this.x += before.x - after.x;
this.y += before.y - after.y;
}
/** Keeps the centre inside the world square so the map cannot be flung off screen. */
clampToWorld(worldSizeMeters: number): void {
const half = worldSizeMeters / 2;
this.x = clamp(this.x, -half, half);
this.y = clamp(this.y, -half, half);
}
screenToWorld(screenX: number, screenY: number, viewport: Viewport): { x: number; y: number } {
return {
x: this.x + (screenX - viewport.width / 2) / this.zoom,
y: this.y - (screenY - viewport.height / 2) / this.zoom,
};
}
/** The world rectangle currently on screen, optionally grown by a fraction of its size. */
visibleRect(viewport: Viewport, padding = 0): WorldRect {
const halfWidth = viewport.width / 2 / this.zoom;
const halfHeight = viewport.height / 2 / this.zoom;
const padX = halfWidth * padding;
const padY = halfHeight * padding;
return {
minX: this.x - halfWidth - padX,
minY: this.y - halfHeight - padY,
maxX: this.x + halfWidth + padX,
maxY: this.y + halfHeight + padY,
};
}
/** Container offset that places the camera centre at the middle of the viewport. */
containerPosition(viewport: Viewport): { x: number; y: number } {
return {
x: viewport.width / 2 - this.x * this.zoom,
y: viewport.height / 2 + this.y * this.zoom,
};
}
}
export function rectsIntersect(a: WorldRect, b: WorldRect): boolean {
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
}
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
@@ -0,0 +1,207 @@
import { Container } from 'pixi.js';
import { api } from '../api/client';
import type { ChunkIndexEntry, MapChunk } from '../api/types';
import { rectsIntersect, type WorldRect } from './camera';
import { destroyChunkGraphics, renderChunk, type ChunkGraphics, type RenderProfile } from './chunkRenderer';
export interface MapLayers {
areas: Container;
water: Container;
roadCasings: Container;
roads: Container;
buildings: Container;
}
interface ChunkState {
entry: ChunkIndexEntry;
rect: WorldRect;
data?: MapChunk;
graphics?: ChunkGraphics;
/** Profile the current graphics were built for; a mismatch means they need redrawing. */
drawnWith?: string;
request?: AbortController;
failed?: boolean;
}
/** How far past the viewport chunks are fetched, as a fraction of the viewport size. */
const PREFETCH_PADDING = 0.35;
/** Graphics survive a little further out than that, so small pans do not thrash the renderer. */
const RETAIN_PADDING = 1.0;
const MAX_CONCURRENT_REQUESTS = 6;
/**
* Keeps the visible slice of a world on screen: fetches chunks as the camera reaches them, draws them into
* the shared layers, and drops their graphics once they are well out of view. Chunk data itself stays cached,
* so panning back is instant.
*/
export class ChunkManager {
private readonly states = new Map<string, ChunkState>();
private worldId: string | null = null;
private inFlight = 0;
private pending: ChunkState[] = [];
constructor(private readonly layers: MapLayers) {}
get loadedCount(): number {
let count = 0;
for (const state of this.states.values()) if (state.data) count++;
return count;
}
get totalCount(): number {
return this.states.size;
}
get isBusy(): boolean {
return this.inFlight > 0 || this.pending.length > 0;
}
setWorld(worldId: string, index: ChunkIndexEntry[]): void {
this.clear();
this.worldId = worldId;
for (const entry of index) {
const [minX, minY, maxX, maxY] = entry.bounds;
this.states.set(key(entry.x, entry.y), {
entry,
rect: { minX, minY, maxX, maxY },
});
}
}
clear(): void {
for (const state of this.states.values()) {
state.request?.abort();
if (state.graphics) {
detach(state.graphics);
destroyChunkGraphics(state.graphics);
}
}
this.states.clear();
this.pending = [];
this.inFlight = 0;
this.worldId = null;
}
/** Reconciles what is on screen with what should be, given the camera's current view and zoom. */
update(visible: WorldRect, profile: RenderProfile): void {
if (!this.worldId) return;
const prefetch = grow(visible, PREFETCH_PADDING);
const retain = grow(visible, RETAIN_PADDING);
this.pending = [];
for (const state of this.states.values()) {
const wanted = rectsIntersect(state.rect, prefetch);
if (wanted && !state.data && !state.request && !state.failed) {
this.pending.push(state);
continue;
}
if (!state.data) continue;
if (wanted) {
if (!state.graphics || state.drawnWith !== profile.key) this.draw(state, profile);
} else if (state.graphics && !rectsIntersect(state.rect, retain)) {
detach(state.graphics);
destroyChunkGraphics(state.graphics);
state.graphics = undefined;
state.drawnWith = undefined;
}
}
// Nearest chunks first: the middle of the screen is what the player is looking at.
const centerX = (visible.minX + visible.maxX) / 2;
const centerY = (visible.minY + visible.maxY) / 2;
this.pending.sort((a, b) => distanceSquared(a.rect, centerX, centerY) - distanceSquared(b.rect, centerX, centerY));
this.pump(profile);
}
private pump(profile: RenderProfile): void {
while (this.inFlight < MAX_CONCURRENT_REQUESTS && this.pending.length > 0) {
const state = this.pending.shift()!;
void this.fetch(state, profile);
}
}
private async fetch(state: ChunkState, profile: RenderProfile): Promise<void> {
const worldId = this.worldId;
if (!worldId) return;
const controller = new AbortController();
state.request = controller;
this.inFlight++;
try {
const chunk = await api.getChunk(worldId, state.entry.x, state.entry.y, controller.signal);
// The world may have been swapped while this request was in the air.
if (this.worldId !== worldId) return;
state.data = chunk;
this.draw(state, profile);
} catch (error) {
if (!controller.signal.aborted) {
state.failed = true;
console.error(`Failed to load chunk ${state.entry.x},${state.entry.y}`, error);
}
} finally {
this.inFlight--;
state.request = undefined;
this.pump(profile);
}
}
private draw(state: ChunkState, profile: RenderProfile): void {
if (!state.data) return;
if (state.graphics) {
detach(state.graphics);
destroyChunkGraphics(state.graphics);
}
const graphics = renderChunk(state.data, profile);
state.graphics = graphics;
state.drawnWith = profile.key;
this.layers.areas.addChild(graphics.areas);
this.layers.water.addChild(graphics.water);
this.layers.roadCasings.addChild(graphics.roadCasings);
this.layers.roads.addChild(graphics.roads);
this.layers.buildings.addChild(graphics.buildings);
}
}
function detach(graphics: ChunkGraphics): void {
for (const layer of Object.values(graphics)) {
layer.removeFromParent();
}
}
function key(x: number, y: number): string {
return `${x},${y}`;
}
function grow(rect: WorldRect, fraction: number): WorldRect {
const padX = (rect.maxX - rect.minX) * fraction * 0.5;
const padY = (rect.maxY - rect.minY) * fraction * 0.5;
return {
minX: rect.minX - padX,
minY: rect.minY - padY,
maxX: rect.maxX + padX,
maxY: rect.maxY + padY,
};
}
function distanceSquared(rect: WorldRect, x: number, y: number): number {
const dx = (rect.minX + rect.maxX) / 2 - x;
const dy = (rect.minY + rect.maxY) / 2 - y;
return dx * dx + dy * dy;
}
@@ -0,0 +1,228 @@
import { Graphics } from 'pixi.js';
import type { AreaFeature, BuildingFeature, FlatPoints, MapChunk, RoadFeature, WaterFeature } from '../api/types';
import {
AREA_COLORS,
BUILDING_EDGE_COLOR,
ROAD_CASING_COLORS,
ROAD_COLORS,
ROAD_MIN_DETAIL,
ROAD_RANK,
WATER_COLOR,
WATER_EDGE_COLOR,
WATER_LINE_COLORS,
buildingColor,
} from './style';
/** 0 = whole town in view, 1 = neighbourhood, 2 = street level. */
export type DetailLevel = 0 | 1 | 2;
export interface RenderProfile {
/** Changes exactly when the geometry needs redrawing, and not otherwise. */
key: string;
detail: DetailLevel;
/** Floor for stroke widths, in world metres, so hairlines stay visible when zoomed out. */
minStrokeMeters: number;
}
/** Strokes thinner than this fade into the background. */
const MIN_STROKE_PIXELS = 1.3;
const MIN_BUILDING_AREA: Record<DetailLevel, number> = { 0: 150, 1: 30, 2: 0 };
export function profileForZoom(zoom: number): RenderProfile {
const detail: DetailLevel = zoom >= 0.35 ? 2 : zoom >= 0.12 ? 1 : 0;
// Quantising to powers of two means a slow zoom redraws a handful of times rather than every frame.
const step = Math.floor(Math.log2(zoom));
const minStrokeMeters = MIN_STROKE_PIXELS / 2 ** step;
return { key: `${detail}@${step}`, detail, minStrokeMeters };
}
export interface ChunkGraphics {
areas: Graphics;
water: Graphics;
roadCasings: Graphics;
roads: Graphics;
buildings: Graphics;
}
export function renderChunk(chunk: MapChunk, profile: RenderProfile): ChunkGraphics {
return {
areas: renderAreas(chunk.areas),
water: renderWater(chunk.water, profile),
roadCasings: renderRoads(chunk.roads, profile, true),
roads: renderRoads(chunk.roads, profile, false),
buildings: renderBuildings(chunk.buildings, profile),
};
}
export function destroyChunkGraphics(graphics: ChunkGraphics): void {
for (const layer of Object.values(graphics)) {
layer.destroy();
}
}
interface Ring {
outline: FlatPoints;
holes?: FlatPoints[];
}
interface EdgeStyle {
width: number;
color: number;
}
function renderAreas(areas: AreaFeature[]): Graphics {
const g = new Graphics();
// Big landuse blocks first, so the parks and pitches sitting inside them stay visible.
const sorted = [...areas].sort((a, b) => boundingArea(b.outline) - boundingArea(a.outline));
forEachRun(
sorted,
(area) => AREA_COLORS[area.kind] ?? AREA_COLORS.unknown,
(run, color) => fillRings(g, run, color),
);
return g;
}
function renderWater(water: WaterFeature[], profile: RenderProfile): Graphics {
const g = new Graphics();
const bodies = water.filter((feature): feature is WaterFeature & Ring => !!feature.outline);
fillRings(g, bodies, WATER_COLOR, { width: profile.minStrokeMeters, color: WATER_EDGE_COLOR });
for (const stream of water) {
if (!stream.path) continue;
g.poly(stream.path, false).stroke({
width: Math.max(stream.width ?? 2, profile.minStrokeMeters),
color: WATER_LINE_COLORS[stream.kind] ?? WATER_COLOR,
cap: 'round',
join: 'round',
});
}
return g;
}
function renderRoads(roads: RoadFeature[], profile: RenderProfile, casing: boolean): Graphics {
const g = new Graphics();
const visible = roads
.filter((road) => ROAD_MIN_DETAIL[road.class] <= profile.detail)
.sort((a, b) => ROAD_RANK[a.class] - ROAD_RANK[b.class] || a.width - b.width);
// The casing is a slightly wider stroke drawn underneath; it is what gives roads their outline.
const casingMargin = Math.max(1.4, profile.minStrokeMeters * 0.7);
// Sorted by class then width, so a run shares both and can be stroked in a single call.
forEachRun(
visible,
(road) => `${road.class}:${road.width}`,
(run) => {
const first = run[0]!;
for (const road of run) g.poly(road.path, false);
const width = Math.max(first.width, profile.minStrokeMeters);
g.stroke({
width: casing ? width + casingMargin : width,
color: casing ? ROAD_CASING_COLORS[first.class] : ROAD_COLORS[first.class],
cap: 'round',
join: 'round',
});
},
);
return g;
}
function renderBuildings(buildings: BuildingFeature[], profile: RenderProfile): Graphics {
const g = new Graphics();
const minArea = MIN_BUILDING_AREA[profile.detail];
const edge: EdgeStyle | undefined =
profile.detail === 2 ? { width: profile.minStrokeMeters * 0.8, color: BUILDING_EDGE_COLOR } : undefined;
// Footprints never overlap, so they can be regrouped by colour without disturbing the picture.
const byColor = new Map<number, BuildingFeature[]>();
for (const building of buildings) {
if (minArea > 0 && boundingArea(building.outline) < minArea) continue;
const color = buildingColor(building.kind, building.height);
const group = byColor.get(color);
if (group) group.push(building);
else byColor.set(color, [building]);
}
for (const [color, group] of byColor) {
fillRings(g, group, color, edge);
}
return g;
}
/**
* Fills a batch of rings. Rings without holes share one draw call; a ring with holes needs its own, because
* each cut applies to the shape that precedes it.
*/
function fillRings(g: Graphics, rings: Ring[], color: number, edge?: EdgeStyle): void {
let solidCount = 0;
for (const ring of rings) {
if (ring.holes?.length) continue;
g.poly(ring.outline);
solidCount++;
}
if (solidCount > 0) {
g.fill({ color });
if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 });
}
for (const ring of rings) {
if (!ring.holes?.length) continue;
g.poly(ring.outline);
for (const hole of ring.holes) {
g.poly(hole);
g.cut();
}
g.fill({ color });
if (edge) g.stroke({ width: edge.width, color: edge.color, alignment: 0.5 });
}
}
/** Bounding-box area of a flat point array — a cheap stand-in for true area when filtering by size. */
function boundingArea(points: FlatPoints): number {
if (points.length < 6) return 0;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (let i = 0; i < points.length; i += 2) {
const x = points[i]!;
const y = points[i + 1]!;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
return (maxX - minX) * (maxY - minY);
}
/** Walks a sorted list, handing over each maximal run of items that share a key. */
function forEachRun<T, K>(items: T[], keyOf: (item: T) => K, draw: (run: T[], key: K) => void): void {
let index = 0;
while (index < items.length) {
const key = keyOf(items[index]!);
const start = index;
while (index < items.length && keyOf(items[index]!) === key) index++;
draw(items.slice(start, index), key);
}
}
+243
View File
@@ -0,0 +1,243 @@
import { Application, Container, Graphics } from 'pixi.js';
import type { WorldMap } from '../api/types';
import { Camera, type Viewport } from './camera';
import { ChunkManager, type MapLayers } from './chunkManager';
import { profileForZoom, type RenderProfile } from './chunkRenderer';
import { LAND_COLOR } from './style';
const OUTSIDE_COLOR = 0xd5d9d2;
const BORDER_COLOR = 0xb4bab2;
/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
const CHUNK_UPDATE_INTERVAL_MS = 90;
export interface MapStatus {
zoom: number;
metersPerPixel: number;
center: { x: number; y: number };
loadedChunks: number;
totalChunks: number;
loading: boolean;
}
/**
* The PixiJS side of the map: one scaled container holding the layer stack, a camera driving it, and the
* pointer handling that lets the player move around.
*/
export class MapView {
private readonly app = new Application();
private readonly root = new Container();
private readonly background = new Graphics();
private readonly border = new Graphics();
private readonly camera = new Camera();
private readonly layers: MapLayers = {
areas: new Container(),
water: new Container(),
roadCasings: new Container(),
roads: new Container(),
buildings: new Container(),
};
private readonly chunks = new ChunkManager(this.layers);
private host: HTMLElement | null = null;
private worldSizeMeters = 0;
private profile: RenderProfile = profileForZoom(0.1);
private cameraDirty = true;
private lastChunkUpdate = 0;
private readonly activePointers = new Map<number, { x: number; y: number }>();
private pinchDistance = 0;
onStatusChange: ((status: MapStatus) => void) | null = null;
async init(host: HTMLElement): Promise<void> {
this.host = host;
await this.app.init({
background: OUTSIDE_COLOR,
antialias: true,
resizeTo: host,
resolution: window.devicePixelRatio || 1,
autoDensity: true,
preference: 'webgl',
});
host.appendChild(this.app.canvas);
// Painter's order: land cover, then water, then the road casings their fills sit on, then buildings.
this.root.addChild(
this.background,
this.layers.areas,
this.layers.water,
this.layers.roadCasings,
this.layers.roads,
this.layers.buildings,
this.border,
);
this.app.stage.addChild(this.root);
this.attachInput(this.app.canvas);
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow.
new ResizeObserver(() => {
this.cameraDirty = true;
}).observe(host);
this.app.ticker.add(() => this.tick());
}
showWorld(map: WorldMap): void {
this.chunks.clear();
this.worldSizeMeters = map.sizeMeters;
const half = map.sizeMeters / 2;
this.background.clear().rect(-half, -half, map.sizeMeters, map.sizeMeters).fill({ color: LAND_COLOR });
this.camera.fit(map.sizeMeters, this.viewport);
this.chunks.setWorld(map.id, map.chunks);
this.cameraDirty = true;
this.lastChunkUpdate = 0;
}
clear(): void {
this.chunks.clear();
this.background.clear();
this.border.clear();
this.worldSizeMeters = 0;
}
destroy(): void {
this.chunks.clear();
this.app.destroy(true, { children: true });
}
private get viewport(): Viewport {
return {
width: this.host?.clientWidth || this.app.screen.width,
height: this.host?.clientHeight || this.app.screen.height,
};
}
private tick(): void {
if (this.worldSizeMeters === 0) return;
const viewport = this.viewport;
if (this.cameraDirty) {
this.camera.clampToWorld(this.worldSizeMeters);
const position = this.camera.containerPosition(viewport);
this.root.position.set(position.x, position.y);
this.root.scale.set(this.camera.zoom, -this.camera.zoom);
this.profile = profileForZoom(this.camera.zoom);
this.drawBorder();
this.cameraDirty = false;
}
const now = performance.now();
if (now - this.lastChunkUpdate >= CHUNK_UPDATE_INTERVAL_MS) {
this.lastChunkUpdate = now;
this.chunks.update(this.camera.visibleRect(viewport), this.profile);
this.publishStatus();
}
}
private drawBorder(): void {
const half = this.worldSizeMeters / 2;
this.border
.clear()
.rect(-half, -half, this.worldSizeMeters, this.worldSizeMeters)
.stroke({ width: 2 / this.camera.zoom, color: BORDER_COLOR, alignment: 1 });
}
private publishStatus(): void {
this.onStatusChange?.({
zoom: this.camera.zoom,
metersPerPixel: 1 / this.camera.zoom,
center: { x: this.camera.x, y: this.camera.y },
loadedChunks: this.chunks.loadedCount,
totalChunks: this.chunks.totalCount,
loading: this.chunks.isBusy,
});
}
private attachInput(canvas: HTMLCanvasElement): void {
canvas.style.touchAction = 'none';
canvas.addEventListener('pointerdown', (event) => {
canvas.setPointerCapture(event.pointerId);
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
this.pinchDistance = this.currentPinchDistance();
});
canvas.addEventListener('pointermove', (event) => {
const previous = this.activePointers.get(event.pointerId);
if (!previous) return;
this.activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (this.activePointers.size >= 2) {
this.handlePinch();
return;
}
this.camera.translateBy(event.clientX - previous.x, event.clientY - previous.y);
this.cameraDirty = true;
});
const release = (event: PointerEvent) => {
this.activePointers.delete(event.pointerId);
this.pinchDistance = this.currentPinchDistance();
};
canvas.addEventListener('pointerup', release);
canvas.addEventListener('pointercancel', release);
canvas.addEventListener('pointerleave', release);
canvas.addEventListener(
'wheel',
(event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
// A gentle exponential keeps trackpads and mouse wheels feeling alike.
this.camera.zoomAt(
Math.exp(-event.deltaY * 0.0015),
event.clientX - rect.left,
event.clientY - rect.top,
this.viewport,
);
this.cameraDirty = true;
},
{ passive: false },
);
}
private handlePinch(): void {
const distance = this.currentPinchDistance();
if (this.pinchDistance === 0 || distance === 0) {
this.pinchDistance = distance;
return;
}
const midpoint = this.pointerMidpoint();
const rect = this.app.canvas.getBoundingClientRect();
this.camera.zoomAt(distance / this.pinchDistance, midpoint.x - rect.left, midpoint.y - rect.top, this.viewport);
this.pinchDistance = distance;
this.cameraDirty = true;
}
private currentPinchDistance(): number {
if (this.activePointers.size < 2) return 0;
const [first, second] = [...this.activePointers.values()];
return Math.hypot(second!.x - first!.x, second!.y - first!.y);
}
private pointerMidpoint(): { x: number; y: number } {
const [first, second] = [...this.activePointers.values()];
return { x: (first!.x + second!.x) / 2, y: (first!.y + second!.y) / 2 };
}
}
+169
View File
@@ -0,0 +1,169 @@
import type { AreaKind, BuildingKind, RoadClass, WaterKind } from '../api/types';
/**
* A quiet daytime cartography palette: muted land cover, white roads, warm building fills. Everything the
* renderer draws gets its colour from here so the map reads as one coherent style.
*/
export const LAND_COLOR = 0xeceee7;
export const AREA_COLORS: Record<AreaKind, number> = {
unknown: 0xe6e6e0,
forest: 0xc6ddb8,
grass: 0xd8e9c6,
meadow: 0xdfeecd,
farmland: 0xece7c8,
orchard: 0xd9e7bd,
scrub: 0xd9e6c9,
heath: 0xdfe4cb,
sand: 0xf0e9cf,
bareRock: 0xdedcd5,
wetland: 0xd2e2d7,
park: 0xd2eac0,
garden: 0xd9edc6,
pitch: 0xc9e6b4,
cemetery: 0xd6e0cd,
residentialZone: 0xe6e3de,
industrialZone: 0xe3dee2,
commercialZone: 0xeae0dd,
retailZone: 0xeddfd7,
quarry: 0xdfdad2,
parking: 0xe7e4dd,
school: 0xeae3d7,
beach: 0xf4ead1,
};
export const WATER_COLOR = 0x9fc9e0;
export const WATER_EDGE_COLOR = 0x84b4cf;
export const WATER_LINE_COLORS: Record<WaterKind, number> = {
unknown: WATER_COLOR,
water: WATER_COLOR,
lake: WATER_COLOR,
pond: WATER_COLOR,
reservoir: WATER_COLOR,
riverbank: WATER_COLOR,
river: WATER_COLOR,
stream: 0xa9d0e4,
canal: WATER_COLOR,
ditch: 0xb5d7e8,
drain: 0xb5d7e8,
};
export const ROAD_COLORS: Record<RoadClass, number> = {
unknown: 0xf7f6f4,
motorway: 0xf3ae68,
trunk: 0xf7c489,
primary: 0xfad89b,
secondary: 0xfae9b8,
tertiary: 0xfdfaf0,
unclassified: 0xffffff,
residential: 0xffffff,
livingStreet: 0xf7f7f5,
service: 0xfbfbfa,
track: 0xe6d9bd,
pedestrian: 0xf1eee9,
footway: 0xecc9b3,
cycleway: 0xd2dcef,
steps: 0xe4b39a,
path: 0xe8cbb6,
railway: 0xb2aca6,
};
export const ROAD_CASING_COLORS: Record<RoadClass, number> = {
unknown: 0xd8d4cd,
motorway: 0xd48f45,
trunk: 0xd9a163,
primary: 0xdcb872,
secondary: 0xdfcb8c,
tertiary: 0xd2cec6,
unclassified: 0xd2cec6,
residential: 0xd2cec6,
livingStreet: 0xd2cec6,
service: 0xdad6cf,
track: 0xc7b795,
pedestrian: 0xd6d1c9,
footway: 0xd0a488,
cycleway: 0xafbcd6,
steps: 0xcb9077,
path: 0xcfab8f,
railway: 0x8d8781,
};
/** Draw order within the road layer: bigger roads sit on top of the network they feed. */
export const ROAD_RANK: Record<RoadClass, number> = {
path: 0,
steps: 0,
footway: 1,
cycleway: 1,
track: 2,
service: 3,
pedestrian: 3,
railway: 4,
livingStreet: 5,
residential: 6,
unclassified: 6,
unknown: 6,
tertiary: 7,
secondary: 8,
primary: 9,
trunk: 10,
motorway: 11,
};
/** Minor paths vanish first as the camera pulls back. */
export const ROAD_MIN_DETAIL: Record<RoadClass, 0 | 1 | 2> = {
path: 2,
steps: 2,
footway: 2,
cycleway: 2,
track: 1,
service: 1,
pedestrian: 1,
railway: 0,
livingStreet: 0,
residential: 0,
unclassified: 0,
unknown: 1,
tertiary: 0,
secondary: 0,
primary: 0,
trunk: 0,
motorway: 0,
};
const BUILDING_BASE = 0xdfd7cc;
const BUILDING_TALL = 0xc4b8a8;
export const BUILDING_EDGE_COLOR = 0xbdb2a3;
const BUILDING_TINTS: Partial<Record<BuildingKind, number>> = {
church: 0xd9cfc4,
school: 0xdcd6c4,
civic: 0xd8d2c6,
industrial: 0xd7d1cd,
retail: 0xe2d5cc,
commercial: 0xe0d6cd,
shed: 0xe3ddd4,
garage: 0xe3ddd4,
};
/**
* Shades a footprint by height so a town reads at a glance: sheds stay pale, blocks of flats go dark.
* Anything above ~30 m is already at the darkest end of the ramp.
*/
export function buildingColor(kind: BuildingKind, height: number): number {
const base = BUILDING_TINTS[kind] ?? BUILDING_BASE;
return mix(base, BUILDING_TALL, clamp01((height - 3) / 27));
}
function clamp01(value: number): number {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
function mix(from: number, to: number, t: number): number {
const r = Math.round(((from >> 16) & 0xff) * (1 - t) + ((to >> 16) & 0xff) * t);
const g = Math.round(((from >> 8) & 0xff) * (1 - t) + ((to >> 8) & 0xff) * t);
const b = Math.round((from & 0xff) * (1 - t) + (to & 0xff) * t);
return (r << 16) | (g << 8) | b;
}
+276
View File
@@ -0,0 +1,276 @@
:root {
--panel-bg: rgba(252, 252, 250, 0.94);
--panel-border: #d9d6cf;
--text: #2b2a27;
--text-muted: #75726b;
--accent: #3d6b4a;
--accent-hover: #325a3d;
--error: #a63d33;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
color: var(--text);
background: #d5d9d2;
}
#stage {
position: fixed;
inset: 0;
}
#stage canvas {
display: block;
cursor: grab;
}
#stage canvas:active {
cursor: grabbing;
}
.panel {
position: fixed;
top: 16px;
left: 16px;
width: 320px;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
gap: 18px;
padding: 18px;
overflow-y: auto;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
backdrop-filter: blur(6px);
}
.panel__header h1 {
margin: 0;
font-size: 17px;
letter-spacing: 0.01em;
}
.panel__subtitle {
margin: 4px 0 0;
font-size: 12px;
color: var(--text-muted);
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--text-muted);
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.field input[type='text'],
.field input[type='number'] {
padding: 7px 9px;
font: inherit;
font-size: 13px;
color: var(--text);
background: #fff;
border: 1px solid var(--panel-border);
border-radius: 7px;
}
.field input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.field input[type='range'] {
accent-color: var(--accent);
}
.field output {
float: right;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.button {
padding: 9px 12px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: #fff;
background: var(--accent);
border: none;
border-radius: 7px;
cursor: pointer;
}
.button:hover:not(:disabled) {
background: var(--accent-hover);
}
.button:disabled {
opacity: 0.55;
cursor: progress;
}
.worlds h2 {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.world-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.world {
display: flex;
align-items: stretch;
gap: 4px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: #fff;
overflow: hidden;
}
.world[data-active='true'] {
border-color: var(--accent);
box-shadow: inset 2px 0 0 var(--accent);
}
.world[data-status='failed'] .world__detail {
color: var(--error);
}
.world__open {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
font: inherit;
text-align: left;
background: none;
border: none;
cursor: pointer;
}
.world__open:disabled {
cursor: default;
opacity: 0.75;
}
.world__open:hover:not(:disabled) {
background: #f3f5f1;
}
.world__name {
font-size: 13px;
font-weight: 600;
}
.world__detail {
font-size: 11px;
color: var(--text-muted);
}
.world__delete {
padding: 0 10px;
font-size: 16px;
line-height: 1;
color: var(--text-muted);
background: none;
border: none;
border-left: 1px solid var(--panel-border);
cursor: pointer;
}
.world__delete:hover {
color: var(--error);
background: #faf1f0;
}
.status {
min-height: 16px;
font-size: 12px;
color: var(--text-muted);
}
.status[data-tone='error'] {
color: var(--error);
}
.status[data-tone='busy']::after {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1.1s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.25;
}
50% {
opacity: 1;
}
}
.hud {
position: fixed;
right: 16px;
bottom: 16px;
padding: 6px 10px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 7px;
pointer-events: none;
}
@media (max-width: 640px) {
.panel {
top: auto;
bottom: 12px;
left: 12px;
right: 12px;
width: auto;
max-height: 55vh;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "node"]
},
"include": ["src", "vite.config.ts"]
}
+40
View File
@@ -0,0 +1,40 @@
import { defineConfig } from 'vite';
/**
* Resolves the API base URL that Aspire injected. `WithReference(api)` publishes each endpoint as
* `services__api__<scheme>__<index>`. Outside Aspire the fallback lets a plain `npm run dev` still work.
*/
function resolveApiUrl(): string {
const fromAspire = Object.keys(process.env)
.filter((key) => key.startsWith('services__api__'))
// Ascending order puts `http` ahead of `https`; plain HTTP is the simpler hop for a local proxy.
.sort()
.map((key) => process.env[key])
.find((value): value is string => !!value);
// Matches the `http` launch profile of TheLivingWorld.Api.
return fromAspire ?? process.env.API_URL ?? 'http://localhost:5195';
}
export default defineConfig(() => {
const apiUrl = resolveApiUrl();
return {
server: {
port: process.env.PORT ? Number(process.env.PORT) : 5173,
strictPort: true,
proxy: {
'/api': {
target: apiUrl,
changeOrigin: true,
// Aspire issues a development certificate the proxy has no reason to distrust.
secure: false,
},
},
},
build: {
target: 'es2022',
sourcemap: true,
},
};
});