Implement live game clock and simulation controls; enhance API with clock update functionality and improve world summary with clock data; update UI to display simulation controls and integrate clock features into the game experience.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
using Arch.Core;
|
||||
using TheLivingWorld.Core.Contracts;
|
||||
using TheLivingWorld.Core.Ecs;
|
||||
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.
|
||||
/// </summary>
|
||||
public sealed class WorldSimulation : IDisposable
|
||||
{
|
||||
private static readonly ClockSystem ClockSystem = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly World _ecs;
|
||||
private readonly Entity _clockEntity;
|
||||
private DateTimeOffset _lastTickedAt;
|
||||
private bool _dirty;
|
||||
private bool _disposed;
|
||||
|
||||
private WorldSimulation(string worldId, World ecs, Entity clockEntity, DateTimeOffset lastTickedAt)
|
||||
{
|
||||
WorldId = worldId;
|
||||
_ecs = ecs;
|
||||
_clockEntity = clockEntity;
|
||||
_lastTickedAt = lastTickedAt;
|
||||
}
|
||||
|
||||
public string WorldId { get; }
|
||||
|
||||
public bool IsDirty
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate) return _dirty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a simulation from persisted summary state. When <paramref name="catchUp"/> is true and the
|
||||
/// clock is not paused, advances for the wall-clock gap since <see cref="WorldSummaryDto.LastTickedAt"/>.
|
||||
/// </summary>
|
||||
public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(summary);
|
||||
|
||||
var clock = summary.Clock ?? DefaultClock();
|
||||
var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale;
|
||||
var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified);
|
||||
|
||||
var ecs = World.Create();
|
||||
var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused));
|
||||
var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var simulation = new WorldSimulation(summary.Id, ecs, entity, lastTickedAt);
|
||||
|
||||
if (catchUp && !clock.Paused)
|
||||
{
|
||||
var gap = DateTimeOffset.UtcNow - lastTickedAt;
|
||||
if (gap > TimeSpan.Zero) simulation.Tick(gap);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Align wall-clock so a later unpause does not replay time spent paused offline.
|
||||
simulation._lastTickedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
return simulation;
|
||||
}
|
||||
|
||||
public static WorldClockDto DefaultClock() => new()
|
||||
{
|
||||
GameTime = GameTime.DefaultStart,
|
||||
TimeScale = GameTime.MinTimeScale,
|
||||
Paused = false,
|
||||
};
|
||||
|
||||
public void Tick(TimeSpan realElapsed)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (realElapsed > TimeSpan.Zero)
|
||||
{
|
||||
var before = SnapshotClockUnlocked();
|
||||
ClockSystem.Execute(_ecs, realElapsed);
|
||||
var after = SnapshotClockUnlocked();
|
||||
if (after.GameTime != before.GameTime) _dirty = true;
|
||||
}
|
||||
|
||||
_lastTickedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
public WorldClockDto SnapshotClock()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return SnapshotClockUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset LastTickedAt
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate) return _lastTickedAt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies pause / time-scale changes. Elapsed time on the previous settings is baked in first so the
|
||||
/// switch is instantaneous from the player's point of view.
|
||||
/// </summary>
|
||||
public WorldClockDto Update(UpdateClockRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var gap = now - _lastTickedAt;
|
||||
if (gap > TimeSpan.Zero) ClockSystem.Execute(_ecs, gap);
|
||||
|
||||
ref var clock = ref _ecs.Get<GameClock>(_clockEntity);
|
||||
|
||||
if (request.TimeScale is { } scale)
|
||||
{
|
||||
if (!GameTime.IsValidTimeScale(scale))
|
||||
throw new ArgumentOutOfRangeException(nameof(request), $"TimeScale must be {GameTime.MinTimeScale}..{GameTime.MaxTimeScale}.");
|
||||
clock.TimeScale = scale;
|
||||
}
|
||||
|
||||
if (request.Paused is { } paused)
|
||||
clock.Paused = paused;
|
||||
|
||||
_lastTickedAt = now;
|
||||
_dirty = true;
|
||||
return SnapshotClockUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
public WorldSummaryDto ApplyTo(WorldSummaryDto summary)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return summary with
|
||||
{
|
||||
Clock = SnapshotClockUnlocked(),
|
||||
LastTickedAt = _lastTickedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wire-facing snapshot: live clock, no internal last-tick stamp.</summary>
|
||||
public WorldSummaryDto OverlayForApi(WorldSummaryDto summary)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
return summary with
|
||||
{
|
||||
Clock = SnapshotClockUnlocked(),
|
||||
LastTickedAt = null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearDirty()
|
||||
{
|
||||
lock (_gate) _dirty = false;
|
||||
}
|
||||
|
||||
private WorldClockDto SnapshotClockUnlocked()
|
||||
{
|
||||
ref var clock = ref _ecs.Get<GameClock>(_clockEntity);
|
||||
return new WorldClockDto
|
||||
{
|
||||
GameTime = new DateTime(clock.Ticks, DateTimeKind.Unspecified),
|
||||
TimeScale = clock.TimeScale,
|
||||
Paused = clock.Paused,
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
World.Destroy(_ecs);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user