Implement climate and weather features in world simulation; enhance API with weather retrieval and climate selection options, update UI to support climate selection during world creation, and improve weather display in the game interface.

This commit is contained in:
Leonid Pershin
2026-08-16 23:05:11 +03:00
parent 3610ee8051
commit 2a8b7b49b3
36 changed files with 3650 additions and 31 deletions
@@ -6,28 +6,46 @@ using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Api.Simulation;
/// <summary>
/// Lightweight live runtime for one world: an Arch world holding a single <see cref="GameClock"/> entity.
/// Map geometry stays on disk; only the clock (and later sim state) lives here.
/// Lightweight live runtime for one world: an Arch world holding the <see cref="GameClock"/> entity and the
/// pressure systems that drive its weather. Map geometry stays on disk; only simulation state lives here.
/// </summary>
public sealed class WorldSimulation : IDisposable
{
/// <summary>
/// A gap longer than this is reseeded rather than stepped. The pressure systems that were drifting when
/// the host went down are long gone by then, and replaying days of them would cost more than it is worth.
/// </summary>
private static readonly TimeSpan MaxWeatherCatchUp = TimeSpan.FromHours(24);
private readonly object _gate = new();
private readonly World _ecs;
private readonly Entity _clockEntity;
private readonly ClimatePreset _climate;
private readonly double _latitude;
private DateTimeOffset _lastTickedAt;
private bool _dirty;
private bool _disposed;
private WorldSimulation(string worldId, World ecs, Entity clockEntity, DateTimeOffset lastTickedAt)
private WorldSimulation(
string worldId,
World ecs,
Entity clockEntity,
ClimatePreset climate,
double latitude,
DateTimeOffset lastTickedAt)
{
WorldId = worldId;
_ecs = ecs;
_clockEntity = clockEntity;
_climate = climate;
_latitude = latitude;
_lastTickedAt = lastTickedAt;
}
public string WorldId { get; }
public ClimateKind Climate => _climate.Kind;
public bool IsDirty
{
get
@@ -43,16 +61,20 @@ public sealed class WorldSimulation : IDisposable
public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true)
{
ArgumentNullException.ThrowIfNull(summary);
SimulationComponents.EnsureRegistered();
var clock = summary.Clock ?? DefaultClock();
var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale;
var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified);
var climate = ClimateCatalog.Get(summary.Climate ?? ClimateCatalog.FromLatitude(summary.Latitude));
var ecs = World.Create();
var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused));
var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
RestoreWeather(ecs, summary, climate, gameTime);
var simulation = new WorldSimulation(summary.Id, ecs, entity, lastTickedAt);
var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
var simulation = new WorldSimulation(summary.Id, ecs, entity, climate, summary.Latitude, lastTickedAt);
if (catchUp && !clock.Paused)
{
@@ -68,6 +90,75 @@ public sealed class WorldSimulation : IDisposable
return simulation;
}
private static void RestoreWeather(
World ecs,
WorldSummaryDto summary,
ClimatePreset climate,
DateTime gameTime)
{
var fallbackSeed = DeterministicRandom.SeedFrom(summary.Id);
var stored = summary.WeatherState;
if (stored is null || stored.Systems.Count == 0)
{
WeatherSystem.Seed(ecs, climate, summary.Latitude, fallbackSeed, gameTime);
return;
}
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = Math.Min(stored.Systems.Count, WeatherSystem.MaxSystems);
for (var i = 0; i < count; i++)
{
var dto = stored.Systems[i];
systems[i] = new PressureSystem(
dto.X, dto.Y,
dto.VelocityX, dto.VelocityY,
dto.IntensityHpa, dto.Radius,
dto.AgeHours, dto.LifetimeHours);
}
WeatherSystem.Restore(
ecs,
climate,
summary.Latitude,
fallbackSeed,
gameTime,
stored.RngState,
stored.SnowDepthMm,
systems[..count]);
}
private WeatherStateDto SnapshotWeatherStateUnlocked()
{
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
var stored = new PressureSystemDto[count];
for (var i = 0; i < count; i++)
{
var system = systems[i];
stored[i] = new PressureSystemDto
{
X = system.X,
Y = system.Y,
VelocityX = system.VelocityX,
VelocityY = system.VelocityY,
IntensityHpa = system.IntensityHpa,
Radius = system.Radius,
AgeHours = system.AgeHours,
LifetimeHours = system.LifetimeHours,
};
}
return new WeatherStateDto
{
RngState = WeatherSystem.RngState(_ecs),
SnowDepthMm = WeatherSystem.SnowDepthMm(_ecs),
Systems = stored,
};
}
public static WorldClockDto DefaultClock(DateTime? startGameTime = null) => new()
{
GameTime = GameTime.ResolveStart(startGameTime),
@@ -81,19 +172,55 @@ public sealed class WorldSimulation : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (realElapsed > TimeSpan.Zero)
{
// Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
// allocate for nothing.
var before = _ecs.Get<GameClock>(_clockEntity).Ticks;
ClockSystem.Execute(_ecs, realElapsed);
if (_ecs.Get<GameClock>(_clockEntity).Ticks != before) _dirty = true;
}
if (realElapsed > TimeSpan.Zero && AdvanceUnlocked(realElapsed)) _dirty = true;
_lastTickedAt = DateTimeOffset.UtcNow;
}
}
/// <summary>
/// Runs the clock and then the weather for one slice of wall time. Returns whether game time actually
/// moved, which is false whenever the world is paused.
/// </summary>
private bool AdvanceUnlocked(TimeSpan realElapsed)
{
// Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
// allocate for nothing.
var before = _ecs.Get<GameClock>(_clockEntity).Ticks;
ClockSystem.Execute(_ecs, realElapsed);
var elapsedGameTicks = _ecs.Get<GameClock>(_clockEntity).Ticks - before;
if (elapsedGameTicks <= 0) return false;
var elapsedGame = TimeSpan.FromTicks(elapsedGameTicks);
var gameTime = new DateTime(_ecs.Get<GameClock>(_clockEntity).Ticks, DateTimeKind.Unspecified);
if (elapsedGame > MaxWeatherCatchUp)
{
// One giant step is not a simulation: the systems that were drifting would have blown through
// and been replaced many times over. Roll a fresh sky for the season we landed in instead.
WeatherSystem.Reseed(_ecs, _climate, _latitude, gameTime);
return true;
}
var hours = (float)elapsedGame.TotalHours;
WeatherSystem.Execute(_ecs, _climate, _latitude, hours);
// Snow lies on the ground, so it has to be integrated as the sky moves rather than derived from the
// instant. The middle of the map speaks for all of it; over ten kilometres that is no lie worth care.
var overhead = SampleRawUnlocked(0.5f, 0.5f, gameTime);
WeatherSystem.AccumulateSnow(_ecs, overhead.TemperatureC, overhead.PrecipitationMmH, hours);
return true;
}
private WeatherSample SampleRawUnlocked(float x, float y, DateTime gameTime)
{
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems[..count], x, y);
return WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY);
}
public WorldClockDto SnapshotClock()
{
lock (_gate)
@@ -125,7 +252,7 @@ public sealed class WorldSimulation : IDisposable
var now = DateTimeOffset.UtcNow;
var gap = now - _lastTickedAt;
if (gap > TimeSpan.Zero) ClockSystem.Execute(_ecs, gap);
if (gap > TimeSpan.Zero) AdvanceUnlocked(gap);
ref var clock = ref _ecs.Get<GameClock>(_clockEntity);
@@ -145,6 +272,77 @@ public sealed class WorldSimulation : IDisposable
}
}
/// <summary>Nodes per side of the weather grid served to the renderer.</summary>
public const int WeatherGridSize = 8;
/// <summary>Weather at the middle of the map - what the HUD and the world list show.</summary>
public WeatherDto SnapshotWeather()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
return SampleUnlocked(systems[..count], 0.5f, 0.5f);
}
}
/// <summary>
/// The whole weather field as a square grid, row-major from the south-west corner. Sampled in one pass so
/// every node sees the same instant and the same pressure systems.
/// </summary>
public WeatherFieldDto SnapshotWeatherField()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
var live = systems[..count];
var nodes = new WeatherDto[WeatherGridSize * WeatherGridSize];
for (var row = 0; row < WeatherGridSize; row++)
{
for (var column = 0; column < WeatherGridSize; column++)
{
var x = column / (float)(WeatherGridSize - 1);
var y = row / (float)(WeatherGridSize - 1);
nodes[(row * WeatherGridSize) + column] = SampleUnlocked(live, x, y);
}
}
return new WeatherFieldDto
{
Climate = _climate.Kind,
Size = WeatherGridSize,
Nodes = nodes,
};
}
}
private WeatherDto SampleUnlocked(ReadOnlySpan<PressureSystem> systems, float x, float y)
{
var gameTime = new DateTime(_ecs.Get<GameClock>(_clockEntity).Ticks, DateTimeKind.Unspecified);
var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems, x, y);
var sample = WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY);
return new WeatherDto
{
SnowDepthMm = Math.Round(WeatherSystem.SnowDepthMm(_ecs), 1),
Condition = sample.Condition,
TemperatureC = Math.Round(sample.TemperatureC, 1),
FeelsLikeC = Math.Round(sample.FeelsLikeC, 1),
PressureHpa = Math.Round(sample.PressureHpa, 1),
Humidity = Math.Round(sample.Humidity, 3),
CloudCover = Math.Round(sample.CloudCover, 3),
PrecipitationMmH = Math.Round(sample.PrecipitationMmH, 2),
WindSpeedMs = Math.Round(sample.WindSpeedMs, 1),
WindDirectionDeg = Math.Round(sample.WindDirectionDeg, 0),
};
}
public WorldSummaryDto ApplyTo(WorldSummaryDto summary)
{
lock (_gate)
@@ -153,21 +351,30 @@ public sealed class WorldSimulation : IDisposable
return summary with
{
Clock = SnapshotClockUnlocked(),
Climate = _climate.Kind,
LastTickedAt = _lastTickedAt,
WeatherState = SnapshotWeatherStateUnlocked(),
};
}
}
/// <summary>Wire-facing snapshot: live clock, no internal last-tick stamp.</summary>
/// <summary>Wire-facing snapshot: live clock and weather, none of the storage-only bookkeeping.</summary>
public WorldSummaryDto OverlayForApi(WorldSummaryDto summary)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
return summary with
{
Clock = SnapshotClockUnlocked(),
Climate = _climate.Kind,
Weather = SampleUnlocked(systems[..count], 0.5f, 0.5f),
LastTickedAt = null,
WeatherState = null,
};
}
}