Refactor weather system to consolidate weather data handling; remove redundant weather API endpoint and related components, ensuring a single weather reading represents the entire world. Update UI to toggle weather effects and enhance rendering logic for improved visual consistency. Adjust documentation to reflect changes in weather data structure and API responses.
This commit is contained in:
@@ -69,7 +69,8 @@ Ready worlds run a live game clock on the server (5 game minutes per real second
|
||||
12 April 2012 06:00) plus weather: a Köppen-lite climate preset per world, with drifting pressure systems as
|
||||
ECS entities on top of a deterministic seasonal/diurnal baseline. UI: main menu (list + create, with a climate
|
||||
picker) → map screen with pause, speed, clock and weather, and a renderer that washes the map for time of
|
||||
day, cloud, fog and lying snow and drops rain or snow through it.
|
||||
day, cloud, fog and lying snow and drops rain or snow through it. One weather reading covers a whole world;
|
||||
the overlay has an on/off button in the game bar.
|
||||
|
||||
New ECS components must be added to the probe entity in `SimulationComponents` — Arch assigns component type
|
||||
ids on first use without a lock, and two threads racing there hand out the same id.
|
||||
|
||||
@@ -108,7 +108,6 @@ them without reworking the data model.
|
||||
| `GET /api/worlds/{id}` | Status of one world |
|
||||
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
|
||||
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry |
|
||||
| `GET /api/worlds/{id}/weather` | The weather field over the map: a 12×12 grid of samples, row-major from the south-west corner |
|
||||
| `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 1–4). Body: `{ paused?, timeScale? }` |
|
||||
| `DELETE /api/worlds/{id}` | Remove a world and its chunks |
|
||||
| `GET /api/climates` | The climate catalogue for the create form, with the latitude band each preset is the default for |
|
||||
@@ -132,10 +131,10 @@ continentality or altitude rather than latitude, so they are never guessed and h
|
||||
|
||||
Weather is a hybrid: the climate gives a deterministic baseline (seasonal curve, daily curve, wet season),
|
||||
and a handful of pressure systems drift across the map on top of it as ECS entities, fading in and out. Cloud,
|
||||
rain, wind and the apparent temperature all fall out of that field, which is why a front visibly crosses the
|
||||
map instead of the whole world flipping from sunny to wet at once. Systems drift at a fixed rate in normalised
|
||||
world space rather than a real one: a genuine front crosses ten kilometres in minutes, which at five game
|
||||
minutes per real second would be a flicker.
|
||||
rain, wind and the apparent temperature all fall out of that field, sampled at the middle of the map — one
|
||||
reading is the world's weather. Systems drift at a fixed rate in normalised world space rather than a real
|
||||
one: a genuine front crosses ten kilometres in minutes, which at five game minutes per real second would be a
|
||||
flicker, whereas this gives a sky that turns over across a game day.
|
||||
|
||||
The drifting systems are persisted in `state.json` so a restart resumes the sky it had. Come back after more
|
||||
than a game day away and the model rolls a fresh sky for the season instead — stepping days of drift in one
|
||||
@@ -153,8 +152,7 @@ you cannot tell how deep the snow lies without knowing what the sky did for the
|
||||
integrated as the world ticks and stored alongside the pressure systems. A world created in a Siberian
|
||||
January starts under snow rather than waiting for the first fall.
|
||||
|
||||
`GET /api/worlds/{id}` carries the weather at the middle of the map for the HUD; the full grid is a separate
|
||||
call, because the world list would otherwise haul sixty-four samples per world on every poll.
|
||||
`GET /api/worlds/{id}` carries the weather along with the clock, so the client needs no second poll for it.
|
||||
|
||||
Geometry travels as flat `[x0, y0, x1, y1, …]` arrays of world metres, which is exactly what PixiJS
|
||||
`Graphics.poly()` accepts, so the client never reshapes it. Responses are compressed; chunk files are written
|
||||
@@ -191,25 +189,20 @@ floor so hairlines stay visible. At street level the map picks up the things tha
|
||||
- gentle bends in roads and watercourses are rounded off by Chaikin corner cutting; corners sharper than 50°
|
||||
are left alone, because a gridded town is full of genuine right angles
|
||||
|
||||
`WeatherLayer` sits over the map. **What it draws is about the ground, not about the viewport.** Only the
|
||||
light of the time of day covers the screen evenly — the sun sets on a whole town at once — and that is the
|
||||
one flat rectangle: a colour interpolated from the sun's elevation through golden hour, dusk and night.
|
||||
`WeatherLayer` sits over the map in screen space. One reading covers the whole world — a generated world is
|
||||
a town, not a continent, and a shower does not fall on half of one — so the wash covers the view evenly.
|
||||
Below the place names goes a colour for the time of day, interpolated from the sun's elevation through golden
|
||||
hour, dusk and night and greyed down by cloud while the sun is up; then white for lying snow; then a pale haze
|
||||
for fog, blizzards and sandstorms. Above the names falls the precipitation — slanted streaks for rain,
|
||||
drifting dots for snow, blown dust for a sandstorm — leaning downwind at a slant taken from the wind and
|
||||
capped so a gale still looks like weather rather than a barcode. Thunderstorms flash.
|
||||
|
||||
Everything else is read from the field under each patch of screen and painted where it is actually
|
||||
happening: lying snow, the dimming under cloud, and the precipitation map itself, which darkens the ground a
|
||||
shower is standing over so the shape of a front is legible from any zoom. The patches are a coarse grid,
|
||||
drawn oversized and blurred, which turns ten steps into a gradient. Falling rain and snow are drawn above
|
||||
the place names, and each drop is only drawn if the ground beneath it is wet, so the fall thins out across
|
||||
the edge of a front instead of the whole screen raining together.
|
||||
The whole overlay can be switched off from the game bar; the choice is remembered like the theme. The weather
|
||||
still happens either way — the button only decides whether it is drawn.
|
||||
|
||||
Pressure systems are sized to be cells on the map rather than the whole sky. A real depression spans a
|
||||
thousand kilometres and would sit over a town as one flat value with no edge at all — the same trade already
|
||||
made for drift speed. Their lower bound is set by the export grid: a system narrower than about two node
|
||||
spacings aliases into it and the client interpolates a lie.
|
||||
|
||||
The maths lives in `sky.ts` and `weatherField.ts`, which import no PixiJS and are unit-tested; `weatherLayer.ts`
|
||||
only knows how to paint the result. A dark theme pulls the night wash back rather than switching it off,
|
||||
because the map is already drawn dark and dusk still has to feel like dusk.
|
||||
The maths lives in `sky.ts`, which imports no PixiJS and is unit-tested; `weatherLayer.ts` only knows how to
|
||||
paint the result. A dark theme pulls the night wash back rather than switching it off, because the map is
|
||||
already drawn dark and dusk still has to feel like dusk.
|
||||
|
||||
Place names are drawn in screen space so text keeps a constant size at every zoom, and the work is split in
|
||||
two. `labelPlacement.ts` decides *which* names to show: candidates are ranked — water bodies first, then
|
||||
|
||||
@@ -17,7 +17,6 @@ public static class WorldEndpoints
|
||||
worlds.MapGet("/{id}", GetWorld);
|
||||
worlds.MapGet("/{id}/map", GetMap);
|
||||
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
|
||||
worlds.MapGet("/{id}/weather", GetWeather);
|
||||
worlds.MapPatch("/{id}/clock", UpdateClock);
|
||||
worlds.MapDelete("/{id}", DeleteWorld);
|
||||
|
||||
@@ -132,33 +131,6 @@ public static class WorldEndpoints
|
||||
return Results.Stream(stream, "application/json");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWeather(
|
||||
string id,
|
||||
WorldStore store,
|
||||
WorldSimulationHost simulation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||
|
||||
simulation.Touch(id);
|
||||
|
||||
// A world that exists but is not running has no weather to report - the field only means anything
|
||||
// while pressure systems are actually drifting.
|
||||
if (simulation.TryGetWeatherField(id) is { } field) return Results.Ok(field);
|
||||
|
||||
var summary = await store.GetSummaryAsync(id, cancellationToken);
|
||||
if (summary is null) return Results.NotFound();
|
||||
|
||||
if (!simulation.IsAttached(id)) simulation.Attach(summary);
|
||||
|
||||
return simulation.TryGetWeatherField(id) is { } attached
|
||||
? Results.Ok(attached)
|
||||
: Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["id"] = ["Weather is only available when the world is ready."],
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateClock(
|
||||
string id,
|
||||
UpdateClockRequest request,
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class WorldSimulation : IDisposable
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MaxWeatherStep = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>The pressure field is only ever read here - one point speaks for the whole map.</summary>
|
||||
private const float MapCentre = 0.5f;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly World _ecs;
|
||||
private readonly Entity _clockEntity;
|
||||
@@ -225,17 +228,17 @@ public sealed class WorldSimulation : IDisposable
|
||||
|
||||
// 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);
|
||||
var overhead = SampleRawUnlocked(gameTime);
|
||||
WeatherSystem.AccumulateSnow(_ecs, overhead.TemperatureC, overhead.PrecipitationMmH, hours);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private WeatherSample SampleRawUnlocked(float x, float y, DateTime gameTime)
|
||||
private WeatherSample SampleRawUnlocked(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);
|
||||
var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems[..count], MapCentre, MapCentre);
|
||||
return WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY);
|
||||
}
|
||||
|
||||
@@ -308,12 +311,9 @@ public sealed class WorldSimulation : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nodes per side of the weather grid served to the renderer. Fine enough to carry the shape of the
|
||||
/// smallest pressure system without aliasing, and still only a couple of hundred numbers on the wire.
|
||||
/// The world's weather. One reading covers the whole map: a generated world is a town, not a continent,
|
||||
/// and a shower does not fall on half of one.
|
||||
/// </summary>
|
||||
public const int WeatherGridSize = 12;
|
||||
|
||||
/// <summary>Weather at the middle of the map - what the HUD and the world list show.</summary>
|
||||
public WeatherDto SnapshotWeather()
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -322,54 +322,19 @@ public sealed class WorldSimulation : IDisposable
|
||||
|
||||
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
|
||||
var count = WeatherSystem.CopySystems(_ecs, systems);
|
||||
return SampleUnlocked(systems[..count], 0.5f, 0.5f);
|
||||
return SampleUnlocked(systems[..count]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
private WeatherDto SampleUnlocked(ReadOnlySpan<PressureSystem> systems)
|
||||
{
|
||||
var gameTime = new DateTime(_ecs.Get<GameClock>(_clockEntity).Ticks, DateTimeKind.Unspecified);
|
||||
var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems, x, y);
|
||||
var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems, MapCentre, MapCentre);
|
||||
var sample = WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY);
|
||||
|
||||
return new WeatherDto
|
||||
{
|
||||
SnowDepthMm = Math.Round(
|
||||
WeatherModel.LocalSnowDepth(WeatherSystem.SnowDepthMm(_ecs), sample.TemperatureC), 1),
|
||||
SnowDepthMm = Math.Round(WeatherSystem.SnowDepthMm(_ecs), 1),
|
||||
Condition = sample.Condition,
|
||||
TemperatureC = Math.Round(sample.TemperatureC, 1),
|
||||
FeelsLikeC = Math.Round(sample.FeelsLikeC, 1),
|
||||
@@ -414,7 +379,7 @@ public sealed class WorldSimulation : IDisposable
|
||||
{
|
||||
Clock = SnapshotClockUnlocked(),
|
||||
Climate = _climate.Kind,
|
||||
Weather = SampleUnlocked(systems[..count], 0.5f, 0.5f),
|
||||
Weather = SampleUnlocked(systems[..count]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ public sealed class WorldSimulationHost(
|
||||
? simulation.OverlayForApi(summary)
|
||||
: summary.ToSummary();
|
||||
|
||||
public WeatherFieldDto? TryGetWeatherField(string id) => Current(id)?.SnapshotWeatherField();
|
||||
|
||||
public WorldClockDto UpdateClock(string id, UpdateClockRequest request)
|
||||
{
|
||||
if (!_simulations.TryGetValue(id, out var simulation))
|
||||
|
||||
@@ -81,8 +81,8 @@ public sealed record WorldSummaryDto
|
||||
public ClimateKind? Climate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Live weather at the centre of the map, for the HUD and the world list. The full field lives behind
|
||||
/// <c>GET /api/worlds/{id}/weather</c>; only Ready worlds that are actually running carry this.
|
||||
/// Live weather over the world. One reading covers the whole map: these are towns, not continents, and
|
||||
/// a shower does not fall on half of one. Only Ready worlds that are actually running carry this.
|
||||
/// </summary>
|
||||
public WeatherDto? Weather { get; init; }
|
||||
}
|
||||
@@ -220,27 +220,12 @@ public sealed record WeatherDto
|
||||
public required double WindDirectionDeg { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Snow lying on the ground at this point. The pack is integrated for the map as a whole - it is the one
|
||||
/// weather value with memory - but what shows here is thinned by the local temperature, so cover goes
|
||||
/// patchy over the warmer parts of the field instead of switching the whole map white at once.
|
||||
/// Snow lying on the ground. The one weather value with memory, so it is integrated as the world ticks
|
||||
/// rather than derived from the instant.
|
||||
/// </summary>
|
||||
public required double SnowDepthMm { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response body for <c>GET /api/worlds/{id}/weather</c>: the weather field over the map as a square grid of
|
||||
/// samples, row-major from the south-west corner. Coarse on purpose - the client interpolates between nodes.
|
||||
/// </summary>
|
||||
public sealed record WeatherFieldDto
|
||||
{
|
||||
public required ClimateKind Climate { get; init; }
|
||||
|
||||
/// <summary>Nodes per side. <c>Nodes</c> holds <c>Size * Size</c> entries.</summary>
|
||||
public required int Size { get; init; }
|
||||
|
||||
public required IReadOnlyList<WeatherDto> Nodes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One entry of <c>GET /api/climates</c>, so the create form never drifts from the server's list.</summary>
|
||||
public sealed record ClimateDto
|
||||
{
|
||||
|
||||
@@ -287,19 +287,6 @@ public static class WeatherModel
|
||||
return Math.Clamp(current - melted, 0f, MaxSnowDepthMm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much of the world's lying snow actually shows at a point this warm. The pack is integrated for
|
||||
/// the map as a whole - over ten kilometres one snowfall really does cover all of it - but it goes patchy
|
||||
/// where the air is warmer, so a map never flips from bare to white in a single step.
|
||||
/// </summary>
|
||||
public static float LocalSnowDepth(float depthMm, float temperatureC)
|
||||
{
|
||||
if (depthMm <= 0f) return 0f;
|
||||
|
||||
var thaw = Math.Clamp((temperatureC - FreezingC) / 8f, 0f, 1f);
|
||||
return depthMm * (1f - (0.6f * thaw));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A plausible depth of lying snow for a climate at this point in the year, without simulating the
|
||||
/// winter that produced it. Used when a world is created and when one comes back from a long absence:
|
||||
|
||||
@@ -171,8 +171,12 @@ public static class WeatherSystem
|
||||
{
|
||||
var system = Spawn(climate, latitude, ref random);
|
||||
system.AgeHours = random.Range(0f, system.LifetimeHours);
|
||||
system.X += system.VelocityX * system.AgeHours;
|
||||
system.Y += system.VelocityY * system.AgeHours;
|
||||
|
||||
// Placed over the map rather than extrapolated forward from the edge. Drifting a fresh system by its
|
||||
// whole age scatters most of the pool off the far side, which leaves a newly opened world under a
|
||||
// flat sky with nothing overhead to give it shape.
|
||||
system.X = random.Range(-0.15f, 1.15f);
|
||||
system.Y = random.Range(-0.05f, 1.05f);
|
||||
return system;
|
||||
}
|
||||
|
||||
@@ -186,9 +190,11 @@ public static class WeatherSystem
|
||||
var velocityX = eastward ? drift : -drift;
|
||||
var velocityY = drift * random.Range(-0.35f, 0.35f);
|
||||
|
||||
// Enter from the upwind edge with enough margin that the system fades in off-map.
|
||||
// Enter from the upwind edge with enough margin that the system fades in off-map. The north-south
|
||||
// spread is kept tight to the map: now that a system is a cell rather than the whole sky, one
|
||||
// launched well off the top or bottom would drift past without ever being felt.
|
||||
var x = eastward ? -0.45f : 1.45f;
|
||||
var y = random.Range(-0.25f, 1.25f);
|
||||
var y = random.Range(-0.05f, 1.05f);
|
||||
|
||||
// Stormier climates dig deeper lows; calm ones mostly sit under gentle highs.
|
||||
var cyclone = random.Chance(0.35f + (climate.Storminess * 0.05f));
|
||||
|
||||
@@ -120,6 +120,14 @@
|
||||
<button type="button" class="speed-button" data-scale="4">x4</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="weather-toggle"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
title="Hide weather effects"
|
||||
aria-label="Weather effects"
|
||||
aria-pressed="true"
|
||||
>🌦</button>
|
||||
<button id="game-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
|
||||
☾
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
CreateWorldRequest,
|
||||
MapChunk,
|
||||
UpdateClockRequest,
|
||||
WeatherField,
|
||||
WorldClock,
|
||||
WorldList,
|
||||
WorldMap,
|
||||
@@ -59,8 +58,6 @@ export const api = {
|
||||
|
||||
listClimates: () => request<ClimateOption[]>('/api/climates'),
|
||||
|
||||
getWeather: (id: string) => request<WeatherField>(`${BASE}/${id}/weather`),
|
||||
|
||||
updateClock: (id: string, body: UpdateClockRequest) =>
|
||||
request<WorldClock>(`${BASE}/${id}/clock`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -53,13 +53,6 @@ export interface Weather {
|
||||
snowDepthMm: number;
|
||||
}
|
||||
|
||||
/** Response body for GET /api/worlds/{id}/weather: a square grid, row-major from the south-west corner. */
|
||||
export interface WeatherField {
|
||||
climate: ClimateKind;
|
||||
size: number;
|
||||
nodes: Weather[];
|
||||
}
|
||||
|
||||
/** One entry of GET /api/climates. */
|
||||
export interface ClimateOption {
|
||||
kind: ClimateKind;
|
||||
|
||||
@@ -16,11 +16,10 @@ import { conditionIcon, describeWeather, formatTemperature, formatWeather } from
|
||||
|
||||
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||
const THEME_KEY = 'the-living-world:theme';
|
||||
const WEATHER_EFFECTS_KEY = 'the-living-world:weather-effects';
|
||||
const MENU_POLL_MS = 2000;
|
||||
const GAME_POLL_MS = 1000;
|
||||
const CLOCK_PAINT_MS = 250;
|
||||
/** The weather field is 64 samples and drifts slowly, so it does not deserve the clock's cadence. */
|
||||
const WEATHER_POLL_MS = 3000;
|
||||
|
||||
const elements = {
|
||||
menu: required<HTMLDivElement>('menu'),
|
||||
@@ -48,6 +47,7 @@ const elements = {
|
||||
back: required<HTMLButtonElement>('back-button'),
|
||||
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
|
||||
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
|
||||
weatherToggle: required<HTMLButtonElement>('weather-toggle'),
|
||||
simControls: required<HTMLDivElement>('sim-controls'),
|
||||
gameClock: required<HTMLElement>('game-clock'),
|
||||
gameWeather: required<HTMLElement>('game-weather'),
|
||||
@@ -75,7 +75,6 @@ let climateOptions: ClimateOption[] = [];
|
||||
let gameSnapshotAt = 0;
|
||||
let gamePollTimer: number | null = null;
|
||||
let gamePaintTimer: number | null = null;
|
||||
let weatherPollTimer: number | null = null;
|
||||
let clockUpdating = false;
|
||||
|
||||
function required<T extends HTMLElement>(id: string): T {
|
||||
@@ -355,6 +354,7 @@ function paintGameClock(): void {
|
||||
|
||||
function applyWeatherToControls(weather: Weather | undefined): void {
|
||||
gameWeather = weather ?? null;
|
||||
view.setWeather(gameWeather);
|
||||
|
||||
if (!gameWeather) {
|
||||
elements.gameWeather.textContent = '';
|
||||
@@ -373,11 +373,6 @@ function startGameClockLoop(worldId: string): void {
|
||||
void pollGameClock(worldId);
|
||||
}, GAME_POLL_MS);
|
||||
gamePaintTimer = window.setInterval(paintGameClock, CLOCK_PAINT_MS);
|
||||
|
||||
void pollWeatherField(worldId);
|
||||
weatherPollTimer = window.setInterval(() => {
|
||||
void pollWeatherField(worldId);
|
||||
}, WEATHER_POLL_MS);
|
||||
}
|
||||
|
||||
function stopGameClockLoop(): void {
|
||||
@@ -389,30 +384,15 @@ function stopGameClockLoop(): void {
|
||||
window.clearInterval(gamePaintTimer);
|
||||
gamePaintTimer = null;
|
||||
}
|
||||
if (weatherPollTimer !== null) {
|
||||
window.clearInterval(weatherPollTimer);
|
||||
weatherPollTimer = null;
|
||||
}
|
||||
gameClock = null;
|
||||
gameWeather = null;
|
||||
view.setWeatherField(null);
|
||||
view.setWeather(null);
|
||||
view.setGameTime(null);
|
||||
elements.simControls.hidden = true;
|
||||
elements.gameClock.textContent = '';
|
||||
elements.gameWeather.textContent = '';
|
||||
}
|
||||
|
||||
async function pollWeatherField(worldId: string): Promise<void> {
|
||||
if (activeWorldId !== worldId) return;
|
||||
|
||||
try {
|
||||
const field = await api.getWeather(worldId);
|
||||
if (activeWorldId === worldId) view.setWeatherField(field);
|
||||
} catch {
|
||||
// Keep drawing the last field; a missed poll is not worth clearing the sky for.
|
||||
}
|
||||
}
|
||||
|
||||
async function pollGameClock(worldId: string): Promise<void> {
|
||||
if (activeWorldId !== worldId || clockUpdating) return;
|
||||
|
||||
@@ -420,8 +400,7 @@ async function pollGameClock(worldId: string): Promise<void> {
|
||||
const summary = await api.getWorld(worldId);
|
||||
if (activeWorldId !== worldId || !summary.clock) return;
|
||||
applyClockToControls(summary.clock);
|
||||
// Weather rides along with the clock poll: it moves far slower than the clock, so it needs no
|
||||
// interpolation of its own.
|
||||
// The weather rides along with the clock: one reading covers the world, and it changes far more slowly.
|
||||
applyWeatherToControls(summary.weather);
|
||||
} catch {
|
||||
// Keep interpolating from the last good snapshot.
|
||||
@@ -662,6 +641,19 @@ function readStoredTheme(): ThemeName {
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day';
|
||||
}
|
||||
|
||||
/** Weather effects are on unless the player has turned them off before. */
|
||||
function readWeatherEffects(): boolean {
|
||||
return localStorage.getItem(WEATHER_EFFECTS_KEY) !== 'off';
|
||||
}
|
||||
|
||||
function applyWeatherEffects(enabled: boolean): void {
|
||||
localStorage.setItem(WEATHER_EFFECTS_KEY, enabled ? 'on' : 'off');
|
||||
view.setWeatherEffectsEnabled(enabled);
|
||||
|
||||
elements.weatherToggle.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
||||
elements.weatherToggle.title = enabled ? 'Hide weather effects' : 'Show weather effects';
|
||||
}
|
||||
|
||||
function toggleTheme(): void {
|
||||
const current = (document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme();
|
||||
applyTheme(current === 'day' ? 'night' : 'day');
|
||||
@@ -683,6 +675,9 @@ async function start(): Promise<void> {
|
||||
elements.back.addEventListener('click', () => {
|
||||
void returnToMenu();
|
||||
});
|
||||
elements.weatherToggle.addEventListener('click', () => {
|
||||
applyWeatherEffects(!view.weatherEffectsEnabled);
|
||||
});
|
||||
elements.menuThemeToggle.addEventListener('click', toggleTheme);
|
||||
elements.gameThemeToggle.addEventListener('click', toggleTheme);
|
||||
elements.playPause.addEventListener('click', () => {
|
||||
@@ -698,6 +693,7 @@ async function start(): Promise<void> {
|
||||
}
|
||||
|
||||
applyTheme(readStoredTheme());
|
||||
applyWeatherEffects(readWeatherEffects());
|
||||
showMenu();
|
||||
await loadClimates();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Application, Container, Graphics } from 'pixi.js';
|
||||
import type { WeatherField, WorldMap } from '../api/types';
|
||||
import type { Weather, WorldMap } from '../api/types';
|
||||
import { Camera, type Viewport } from './camera';
|
||||
import { ChunkManager } from './chunkManager';
|
||||
import { profileForZoom, type RenderProfile } from './chunkRenderer';
|
||||
@@ -7,16 +7,7 @@ import { LabelLayer } from './labelLayer';
|
||||
import { LAYER_ORDER, type MapLayers } from './layers';
|
||||
import { precipitationSpec, skyState } from './sky';
|
||||
import { THEMES, type Theme, type ThemeName } from './theme';
|
||||
import {
|
||||
CALM,
|
||||
CLOUD_COVER,
|
||||
PRECIPITATION,
|
||||
SNOW_DEPTH,
|
||||
sampleValue,
|
||||
sampleWeatherField,
|
||||
type LocalWeather,
|
||||
} from './weatherField';
|
||||
import { NO_WEATHER, WeatherLayer, type WeatherProbe } from './weatherLayer';
|
||||
import { WeatherLayer } from './weatherLayer';
|
||||
|
||||
/**
|
||||
* Builds the container per layer. This lives here rather than in `layers.ts` so that module stays free of
|
||||
@@ -36,9 +27,6 @@ function createLayers(): MapLayers {
|
||||
/** Chunk bookkeeping runs on a timer rather than every frame; panning does not need 60 reconciliations a second. */
|
||||
const CHUNK_UPDATE_INTERVAL_MS = 90;
|
||||
|
||||
/** Screen fractions probed when sizing the particle pool: the four corners of the view. */
|
||||
const CORNERS = [[0.08, 0.08], [0.92, 0.08], [0.08, 0.92], [0.92, 0.92]] as const;
|
||||
|
||||
export interface MapStatus {
|
||||
zoom: number;
|
||||
metersPerPixel: number;
|
||||
@@ -65,7 +53,8 @@ export class MapView {
|
||||
private readonly labels = new LabelLayer(this.theme);
|
||||
private readonly weather = new WeatherLayer();
|
||||
|
||||
private weatherField: WeatherField | null = null;
|
||||
private weatherState: Weather | null = null;
|
||||
private weatherEnabled = true;
|
||||
private gameTime: Date | null = null;
|
||||
private latitude = 0;
|
||||
|
||||
@@ -122,7 +111,7 @@ export class MapView {
|
||||
this.chunks.clear();
|
||||
this.labels.clear();
|
||||
this.weather.clear();
|
||||
this.weatherField = null;
|
||||
this.weatherState = null;
|
||||
this.worldSizeMeters = map.sizeMeters;
|
||||
this.latitude = map.latitude;
|
||||
|
||||
@@ -156,9 +145,21 @@ export class MapView {
|
||||
this.lastChunkUpdate = 0;
|
||||
}
|
||||
|
||||
/** The weather field from the server. Sampled under the camera every frame, so a front crosses the map. */
|
||||
setWeatherField(field: WeatherField | null): void {
|
||||
this.weatherField = field;
|
||||
/** The world's current weather. One reading covers the whole map. */
|
||||
setWeather(weather: Weather | null): void {
|
||||
this.weatherState = weather;
|
||||
}
|
||||
|
||||
get weatherEffectsEnabled(): boolean {
|
||||
return this.weatherEnabled;
|
||||
}
|
||||
|
||||
/** Turns the overlay off without touching the simulation — the weather still happens, it just is not drawn. */
|
||||
setWeatherEffectsEnabled(enabled: boolean): void {
|
||||
if (this.weatherEnabled === enabled) return;
|
||||
|
||||
this.weatherEnabled = enabled;
|
||||
if (!enabled) this.weather.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +176,7 @@ export class MapView {
|
||||
this.weather.clear();
|
||||
this.background.clear();
|
||||
this.border.clear();
|
||||
this.weatherField = null;
|
||||
this.weatherState = null;
|
||||
this.gameTime = null;
|
||||
this.worldSizeMeters = 0;
|
||||
}
|
||||
@@ -243,79 +244,20 @@ export class MapView {
|
||||
}
|
||||
|
||||
private updateWeather(viewport: Viewport, deltaMs: number): void {
|
||||
if (!this.weatherEnabled) return;
|
||||
|
||||
this.weather.resize(viewport.width, viewport.height);
|
||||
|
||||
if (this.gameTime) {
|
||||
// The light of the day is read at the middle of the screen because it covers the map evenly; what is
|
||||
// falling is sized from the wettest ground in view, so a shower in one corner still gets its drops.
|
||||
const centre = this.localWeather();
|
||||
this.weather.apply(
|
||||
skyState(this.gameTime, this.latitude, centre, this.theme.dark),
|
||||
precipitationSpec(this.wettestInView(viewport, centre)),
|
||||
this.buildProbe(viewport),
|
||||
skyState(this.gameTime, this.latitude, this.weatherState, this.theme.dark),
|
||||
precipitationSpec(this.weatherState),
|
||||
);
|
||||
}
|
||||
|
||||
this.weather.advance(deltaMs);
|
||||
}
|
||||
|
||||
/** The field read under the middle of the screen. */
|
||||
private localWeather(): LocalWeather {
|
||||
if (!this.weatherField || this.worldSizeMeters === 0) return CALM;
|
||||
|
||||
const half = this.worldSizeMeters / 2;
|
||||
return sampleWeatherField(
|
||||
this.weatherField,
|
||||
(this.camera.x + half) / this.worldSizeMeters,
|
||||
(this.camera.y + half) / this.worldSizeMeters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the weather layer read the field under any pixel. The projection has no rotation, so a screen
|
||||
* position maps straight onto the map with two divisions - cheap enough to call per particle, per frame.
|
||||
*/
|
||||
private buildProbe(viewport: Viewport): WeatherProbe {
|
||||
const field = this.weatherField;
|
||||
if (!field || this.worldSizeMeters === 0) return NO_WEATHER;
|
||||
|
||||
const size = this.worldSizeMeters;
|
||||
const half = size / 2;
|
||||
const camera = this.camera;
|
||||
|
||||
const u = (screenX: number) =>
|
||||
(camera.x + ((screenX - (viewport.width / 2)) / camera.zoom) + half) / size;
|
||||
const v = (screenY: number) =>
|
||||
(camera.y - ((screenY - (viewport.height / 2)) / camera.zoom) + half) / size;
|
||||
|
||||
return {
|
||||
precipitation: (x, y) => sampleValue(field, u(x), v(y), PRECIPITATION),
|
||||
cloud: (x, y) => sampleValue(field, u(x), v(y), CLOUD_COVER),
|
||||
snow: (x, y) => sampleValue(field, u(x), v(y), SNOW_DEPTH),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The heaviest precipitation anywhere on screen, which is what the particle pool has to be sized for. Take
|
||||
* the middle instead and a front covering half the view would arrive with half the drops it needs.
|
||||
*/
|
||||
private wettestInView(viewport: Viewport, centre: LocalWeather): LocalWeather {
|
||||
const field = this.weatherField;
|
||||
if (!field || this.worldSizeMeters === 0) return centre;
|
||||
|
||||
const size = this.worldSizeMeters;
|
||||
const half = size / 2;
|
||||
let wettest = centre;
|
||||
|
||||
for (const [fx, fy] of CORNERS) {
|
||||
const world = this.camera.screenToWorld(fx * viewport.width, fy * viewport.height, viewport);
|
||||
const sample = sampleWeatherField(field, (world.x + half) / size, (world.y + half) / size);
|
||||
if (sample.precipitationMmH > wettest.precipitationMmH) wettest = sample;
|
||||
}
|
||||
|
||||
return wettest;
|
||||
}
|
||||
|
||||
private drawBorder(): void {
|
||||
const half = this.worldSizeMeters / 2;
|
||||
this.border
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Weather } from '../api/types';
|
||||
import { precipitationSpec, skyState, sunElevationDeg } from './sky';
|
||||
import { CALM, type LocalWeather } from './weatherField';
|
||||
|
||||
const CALM: Weather = {
|
||||
condition: 'clear',
|
||||
temperatureC: 15,
|
||||
feelsLikeC: 15,
|
||||
pressureHpa: 1013,
|
||||
humidity: 0.5,
|
||||
cloudCover: 0,
|
||||
precipitationMmH: 0,
|
||||
windSpeedMs: 0,
|
||||
windDirectionDeg: 0,
|
||||
snowDepthMm: 0,
|
||||
};
|
||||
|
||||
const WARSAW = 52.23;
|
||||
const SYDNEY = -33.87;
|
||||
@@ -9,7 +22,7 @@ function at(year: number, month: number, day: number, hour: number, minute = 0):
|
||||
return new Date(year, month - 1, day, hour, minute, 0);
|
||||
}
|
||||
|
||||
function weather(overrides: Partial<LocalWeather> = {}): LocalWeather {
|
||||
function weather(overrides: Partial<Weather> = {}): Weather {
|
||||
return { ...CALM, ...overrides };
|
||||
}
|
||||
|
||||
@@ -74,19 +87,29 @@ describe('skyState', () => {
|
||||
expect(onDark).toBeLessThan(onLight);
|
||||
});
|
||||
|
||||
it('leaves cloud, rain and snow out of the global wash', () => {
|
||||
// These belong to particular ground, so the layer paints them patch by patch from the field. Folding
|
||||
// them in here would smear a shower standing over one corner of the town across the whole of it.
|
||||
const clear = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
|
||||
const filthy = skyState(
|
||||
at(2012, 6, 21, 12),
|
||||
WARSAW,
|
||||
weather({ cloudCover: 1, precipitationMmH: 9, snowDepthMm: 400 }),
|
||||
false,
|
||||
);
|
||||
it('greys the light down under cloud, but only while the sun is up', () => {
|
||||
const clearNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
|
||||
const cloudyNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather({ cloudCover: 1 }), false);
|
||||
expect(cloudyNoon.tintAlpha).toBeGreaterThan(clearNoon.tintAlpha);
|
||||
|
||||
expect(filthy.tintAlpha).toBeCloseTo(clear.tintAlpha, 6);
|
||||
expect(filthy.tint).toBe(clear.tint);
|
||||
const clearNight = skyState(at(2012, 12, 21, 0), WARSAW, weather(), false);
|
||||
const cloudyNight = skyState(at(2012, 12, 21, 0), WARSAW, weather({ cloudCover: 1 }), false);
|
||||
expect(cloudyNight.tintAlpha).toBeCloseTo(clearNight.tintAlpha, 5);
|
||||
});
|
||||
|
||||
it('reports snow cover as a fraction that saturates', () => {
|
||||
const noon = at(2012, 1, 15, 12);
|
||||
expect(skyState(noon, WARSAW, weather({ snowDepthMm: 0 }), false).snowCover).toBe(0);
|
||||
expect(skyState(noon, WARSAW, weather({ snowDepthMm: 60 }), false).snowCover).toBeCloseTo(0.5, 5);
|
||||
expect(skyState(noon, WARSAW, weather({ snowDepthMm: 900 }), false).snowCover).toBe(1);
|
||||
});
|
||||
|
||||
it('copes with a world whose weather has not arrived yet', () => {
|
||||
const state = skyState(at(2012, 12, 21, 0), WARSAW, null, false);
|
||||
expect(state.tintAlpha).toBeGreaterThan(0);
|
||||
expect(state.snowCover).toBe(0);
|
||||
expect(state.lightning).toBe(false);
|
||||
expect(precipitationSpec(null).kind).toBe('none');
|
||||
});
|
||||
|
||||
it('hazes over for fog and a blizzard, but not for plain rain', () => {
|
||||
@@ -115,7 +138,7 @@ describe('skyState', () => {
|
||||
|
||||
expect(state.tintAlpha).toBeGreaterThanOrEqual(0);
|
||||
expect(state.tintAlpha).toBeLessThanOrEqual(1);
|
||||
expect(state.hazeAlpha).toBeLessThanOrEqual(1);
|
||||
expect(state.snowCover).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { LocalWeather } from './weatherField';
|
||||
import type { Weather } from '../api/types';
|
||||
|
||||
/**
|
||||
* How the sky looks over the map right now: the wash laid over the scene and what is falling through it.
|
||||
* Pure maths, no PixiJS — {@link WeatherLayer} is the only thing that knows how to paint it.
|
||||
* How the sky looks over the map right now. One reading covers the whole world — a generated world is a
|
||||
* town, not a continent, and a shower does not fall on half of one. Pure maths, no PixiJS: {@link WeatherLayer}
|
||||
* is the only thing that knows how to paint it.
|
||||
*/
|
||||
export interface SkyState {
|
||||
/** Degrees above the horizon; negative once the sun has set. */
|
||||
@@ -13,6 +14,8 @@ export interface SkyState {
|
||||
tintAlpha: number;
|
||||
/** A separate pale layer for fog and driving snow, which lighten rather than darken. */
|
||||
hazeAlpha: number;
|
||||
/** How thoroughly the ground is covered, 0..1. Drives the white over roofs and streets. */
|
||||
snowCover: number;
|
||||
/** True during a thunderstorm, which is the only thing that separates one from plain heavy rain. */
|
||||
lightning: boolean;
|
||||
}
|
||||
@@ -78,10 +81,7 @@ export function sunElevationDeg(gameTime: Date, latitude: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the part of the wash that covers the whole map evenly: the light of the time of day. Cloud, rain
|
||||
* and lying snow are deliberately NOT here - they sit over particular ground, so the layer paints them patch
|
||||
* by patch from the field instead. Mixing them in would both double-count them and smear a shower that is
|
||||
* over one corner of the town across all of it.
|
||||
* Builds the wash over the map from the sun, the cloud and what is on the ground.
|
||||
*
|
||||
* `alreadyDark` is the night theme: the map is drawn dark to begin with, so piling a full night wash on top
|
||||
* of it would leave the streets unreadable. The wash is pulled back rather than switched off, because dusk
|
||||
@@ -90,23 +90,44 @@ export function sunElevationDeg(gameTime: Date, latitude: number): number {
|
||||
export function skyState(
|
||||
gameTime: Date,
|
||||
latitude: number,
|
||||
weather: LocalWeather,
|
||||
weather: Weather | null,
|
||||
alreadyDark: boolean,
|
||||
): SkyState {
|
||||
const elevation = sunElevationDeg(gameTime, latitude);
|
||||
const base = interpolateStops(elevation);
|
||||
|
||||
if (!weather) {
|
||||
return {
|
||||
sunElevationDeg: elevation,
|
||||
tint: base.tint,
|
||||
tintAlpha: clamp01(base.alpha * (alreadyDark ? 0.45 : 1)),
|
||||
hazeAlpha: 0,
|
||||
snowCover: 0,
|
||||
lightning: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Cloud greys the light down by day and holds a little warmth in at night, so it never simply adds up.
|
||||
const daylight = clamp((elevation + 6) / 18, 0, 1);
|
||||
const cloud = clamp01(weather.cloudCover);
|
||||
const tint = mix(base.tint, 0x8d95a0, cloud * 0.55 * daylight);
|
||||
|
||||
// A downpour darkens the ground under it well past what its cloud alone would.
|
||||
const downpour = clamp01(weather.precipitationMmH / 6) * 0.1 * daylight;
|
||||
const alpha = (base.alpha + (cloud * 0.16 * daylight) + downpour) * (alreadyDark ? 0.45 : 1);
|
||||
|
||||
return {
|
||||
sunElevationDeg: elevation,
|
||||
tint,
|
||||
tintAlpha: clamp01(alpha),
|
||||
hazeAlpha: hazeFor(weather),
|
||||
snowCover: clamp01(weather.snowDepthMm / FULL_SNOW_COVER_MM),
|
||||
lightning: weather.condition === 'thunderstorm',
|
||||
};
|
||||
}
|
||||
|
||||
/** Fog and heavy snow both wash the scene out; rain barely does. */
|
||||
function hazeFor(weather: LocalWeather): number {
|
||||
function hazeFor(weather: Weather): number {
|
||||
if (weather.condition === 'fog') return 0.5;
|
||||
if (weather.condition === 'blizzard') return 0.42;
|
||||
if (weather.condition === 'sandstorm') return 0.38;
|
||||
@@ -115,7 +136,9 @@ function hazeFor(weather: LocalWeather): number {
|
||||
}
|
||||
|
||||
/** What is falling and how hard, ready for the particle layer. */
|
||||
export function precipitationSpec(weather: LocalWeather): PrecipitationSpec {
|
||||
export function precipitationSpec(weather: Weather | null): PrecipitationSpec {
|
||||
if (!weather) return { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
|
||||
|
||||
// The wind blows towards the reverse of the bearing it comes from; on screen, north is up, so the
|
||||
// east-west part of that is what tips the fall off vertical.
|
||||
const towards = (weather.windDirectionDeg + 180) * (Math.PI / 180);
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Weather, WeatherCondition, WeatherField } from '../api/types';
|
||||
import {
|
||||
CLOUD_COVER,
|
||||
PRECIPITATION,
|
||||
SNOW_DEPTH,
|
||||
sampleValue,
|
||||
sampleWeatherField,
|
||||
} from './weatherField';
|
||||
|
||||
function node(overrides: Partial<Weather> = {}): Weather {
|
||||
return {
|
||||
condition: 'clear',
|
||||
temperatureC: 10,
|
||||
feelsLikeC: 10,
|
||||
pressureHpa: 1013,
|
||||
humidity: 0.5,
|
||||
cloudCover: 0,
|
||||
precipitationMmH: 0,
|
||||
windSpeedMs: 0,
|
||||
windDirectionDeg: 0,
|
||||
snowDepthMm: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A 2×2 field, row-major from the south-west corner, so index 0 is (west, south). */
|
||||
function field(nodes: Weather[], size = 2): WeatherField {
|
||||
return { climate: 'centralEuropean', size, nodes };
|
||||
}
|
||||
|
||||
describe('sampleWeatherField', () => {
|
||||
it('reads the corners back exactly', () => {
|
||||
const grid = field([
|
||||
node({ temperatureC: 0 }), // south-west
|
||||
node({ temperatureC: 10 }), // south-east
|
||||
node({ temperatureC: 20 }), // north-west
|
||||
node({ temperatureC: 30 }), // north-east
|
||||
]);
|
||||
|
||||
expect(sampleWeatherField(grid, 0, 0).temperatureC).toBe(0);
|
||||
expect(sampleWeatherField(grid, 1, 0).temperatureC).toBe(10);
|
||||
expect(sampleWeatherField(grid, 0, 1).temperatureC).toBe(20);
|
||||
expect(sampleWeatherField(grid, 1, 1).temperatureC).toBe(30);
|
||||
});
|
||||
|
||||
it('interpolates between them', () => {
|
||||
const grid = field([
|
||||
node({ temperatureC: 0 }),
|
||||
node({ temperatureC: 10 }),
|
||||
node({ temperatureC: 20 }),
|
||||
node({ temperatureC: 30 }),
|
||||
]);
|
||||
|
||||
expect(sampleWeatherField(grid, 0.5, 0).temperatureC).toBeCloseTo(5, 5);
|
||||
expect(sampleWeatherField(grid, 0, 0.5).temperatureC).toBeCloseTo(10, 5);
|
||||
expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBeCloseTo(15, 5);
|
||||
});
|
||||
|
||||
it('clamps a sample taken outside the map', () => {
|
||||
const grid = field([
|
||||
node({ precipitationMmH: 1 }),
|
||||
node({ precipitationMmH: 1 }),
|
||||
node({ precipitationMmH: 5 }),
|
||||
node({ precipitationMmH: 5 }),
|
||||
]);
|
||||
|
||||
expect(sampleWeatherField(grid, -3, -3).precipitationMmH).toBe(1);
|
||||
expect(sampleWeatherField(grid, 9, 9).precipitationMmH).toBe(5);
|
||||
});
|
||||
|
||||
it('takes the condition from the nearest node rather than blending it', () => {
|
||||
const conditions: WeatherCondition[] = ['clear', 'clear', 'clear', 'thunderstorm'];
|
||||
const grid = field(conditions.map((condition) => node({ condition })));
|
||||
|
||||
expect(sampleWeatherField(grid, 0.1, 0.1).condition).toBe('clear');
|
||||
expect(sampleWeatherField(grid, 0.9, 0.9).condition).toBe('thunderstorm');
|
||||
// Just past halfway is already the storm's corner; there is no halfway condition to invent.
|
||||
expect(sampleWeatherField(grid, 0.6, 0.6).condition).toBe('thunderstorm');
|
||||
});
|
||||
|
||||
it('averages bearings the short way round the compass', () => {
|
||||
const grid = field([
|
||||
node({ windDirectionDeg: 350 }),
|
||||
node({ windDirectionDeg: 10 }),
|
||||
node({ windDirectionDeg: 350 }),
|
||||
node({ windDirectionDeg: 10 }),
|
||||
]);
|
||||
|
||||
// Averaging 350 and 10 as plain numbers gives 180 — exactly backwards.
|
||||
const middle = sampleWeatherField(grid, 0.5, 0.5).windDirectionDeg;
|
||||
expect(Math.min(middle, 360 - middle)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('always returns a bearing in range', () => {
|
||||
const grid = field([
|
||||
node({ windDirectionDeg: 300 }),
|
||||
node({ windDirectionDeg: 40 }),
|
||||
node({ windDirectionDeg: 190 }),
|
||||
node({ windDirectionDeg: 95 }),
|
||||
]);
|
||||
|
||||
for (let u = 0; u <= 1; u += 0.1) {
|
||||
for (let v = 0; v <= 1; v += 0.1) {
|
||||
const bearing = sampleWeatherField(grid, u, v).windDirectionDeg;
|
||||
expect(bearing).toBeGreaterThanOrEqual(0);
|
||||
expect(bearing).toBeLessThan(360);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to calm weather when the field is malformed', () => {
|
||||
expect(sampleWeatherField(field([], 0), 0.5, 0.5).condition).toBe('clear');
|
||||
// A grid that claims to be 8×8 but arrived short must not read off the end of the array.
|
||||
expect(sampleWeatherField(field([node()], 8), 0.5, 0.5).precipitationMmH).toBe(0);
|
||||
});
|
||||
|
||||
it('reads a single value without building a whole reading', () => {
|
||||
const grid = field([
|
||||
node({ precipitationMmH: 0 }),
|
||||
node({ precipitationMmH: 0 }),
|
||||
node({ precipitationMmH: 8 }),
|
||||
node({ precipitationMmH: 8 }),
|
||||
]);
|
||||
|
||||
// Dry along the southern edge, pouring along the northern one, with the front in between.
|
||||
expect(sampleValue(grid, 0.5, 0, PRECIPITATION)).toBe(0);
|
||||
expect(sampleValue(grid, 0.5, 1, PRECIPITATION)).toBe(8);
|
||||
expect(sampleValue(grid, 0.5, 0.5, PRECIPITATION)).toBeCloseTo(4, 5);
|
||||
});
|
||||
|
||||
it('agrees with the full reading it is a shortcut for', () => {
|
||||
const grid = field([
|
||||
node({ precipitationMmH: 1, cloudCover: 0.1, snowDepthMm: 5 }),
|
||||
node({ precipitationMmH: 4, cloudCover: 0.4, snowDepthMm: 15 }),
|
||||
node({ precipitationMmH: 7, cloudCover: 0.7, snowDepthMm: 25 }),
|
||||
node({ precipitationMmH: 9, cloudCover: 0.9, snowDepthMm: 40 }),
|
||||
]);
|
||||
|
||||
for (const u of [0, 0.3, 0.75, 1]) {
|
||||
for (const v of [0, 0.4, 1]) {
|
||||
const full = sampleWeatherField(grid, u, v);
|
||||
expect(sampleValue(grid, u, v, PRECIPITATION)).toBeCloseTo(full.precipitationMmH, 6);
|
||||
expect(sampleValue(grid, u, v, CLOUD_COVER)).toBeCloseTo(full.cloudCover, 6);
|
||||
expect(sampleValue(grid, u, v, SNOW_DEPTH)).toBeCloseTo(full.snowDepthMm, 6);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns zero from a malformed field rather than reading off the end', () => {
|
||||
expect(sampleValue(field([], 0), 0.5, 0.5, PRECIPITATION)).toBe(0);
|
||||
expect(sampleValue(field([node()], 8), 0.5, 0.5, PRECIPITATION)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles a one-node field without dividing by zero', () => {
|
||||
const grid = field([node({ temperatureC: 7 })], 1);
|
||||
expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { Weather, WeatherCondition, WeatherField } from '../api/types';
|
||||
|
||||
/** What the weather is doing at one point of the map, read out of the coarse server grid. */
|
||||
export interface LocalWeather {
|
||||
temperatureC: number;
|
||||
cloudCover: number;
|
||||
precipitationMmH: number;
|
||||
windSpeedMs: number;
|
||||
/** Compass bearing the wind blows from, 0..360. */
|
||||
windDirectionDeg: number;
|
||||
snowDepthMm: number;
|
||||
condition: WeatherCondition;
|
||||
}
|
||||
|
||||
export const CALM: LocalWeather = {
|
||||
temperatureC: 15,
|
||||
cloudCover: 0,
|
||||
precipitationMmH: 0,
|
||||
windSpeedMs: 0,
|
||||
windDirectionDeg: 0,
|
||||
snowDepthMm: 0,
|
||||
condition: 'clear',
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the field at a point, with `u` running west to east and `v` south to north, both 0..1 over the map.
|
||||
*
|
||||
* The numbers are interpolated between the four surrounding nodes — the server's pressure systems are smooth
|
||||
* Gaussians, so a coarse grid loses nothing by being read this way. The condition is a label rather than a
|
||||
* quantity, so it comes from the nearest node instead: there is no halfway between fog and a thunderstorm.
|
||||
*/
|
||||
export function sampleWeatherField(field: WeatherField, u: number, v: number): LocalWeather {
|
||||
const size = field.size;
|
||||
if (size < 1 || field.nodes.length < size * size) return CALM;
|
||||
|
||||
const x = clamp01(u) * (size - 1);
|
||||
const y = clamp01(v) * (size - 1);
|
||||
|
||||
const x0 = Math.min(Math.floor(x), size - 1);
|
||||
const y0 = Math.min(Math.floor(y), size - 1);
|
||||
const x1 = Math.min(x0 + 1, size - 1);
|
||||
const y1 = Math.min(y0 + 1, size - 1);
|
||||
const fx = x - x0;
|
||||
const fy = y - y0;
|
||||
|
||||
const at = (column: number, row: number) => field.nodes[(row * size) + column]!;
|
||||
const topLeft = at(x0, y0);
|
||||
const topRight = at(x1, y0);
|
||||
const bottomLeft = at(x0, y1);
|
||||
const bottomRight = at(x1, y1);
|
||||
|
||||
const blend = (pick: (node: (typeof topLeft)) => number): number => {
|
||||
const top = lerp(pick(topLeft), pick(topRight), fx);
|
||||
const bottom = lerp(pick(bottomLeft), pick(bottomRight), fx);
|
||||
return lerp(top, bottom, fy);
|
||||
};
|
||||
|
||||
return {
|
||||
temperatureC: blend((node) => node.temperatureC),
|
||||
cloudCover: blend((node) => node.cloudCover),
|
||||
precipitationMmH: blend((node) => node.precipitationMmH),
|
||||
windSpeedMs: blend((node) => node.windSpeedMs),
|
||||
windDirectionDeg: blendBearing(
|
||||
[topLeft, topRight, bottomLeft, bottomRight].map((node) => node.windDirectionDeg),
|
||||
fx,
|
||||
fy,
|
||||
),
|
||||
snowDepthMm: blend((node) => node.snowDepthMm),
|
||||
condition: at(fx < 0.5 ? x0 : x1, fy < 0.5 ? y0 : y1).condition,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one number out of the field, without building a whole {@link LocalWeather} for it. The renderer
|
||||
* samples per particle and per patch of screen every frame, and at those rates the garbage from a full
|
||||
* reading is what would cost, not the arithmetic.
|
||||
*/
|
||||
export function sampleValue(
|
||||
field: WeatherField,
|
||||
u: number,
|
||||
v: number,
|
||||
pick: (node: Weather) => number,
|
||||
): number {
|
||||
const size = field.size;
|
||||
if (size < 1 || field.nodes.length < size * size) return 0;
|
||||
|
||||
const x = clamp01(u) * (size - 1);
|
||||
const y = clamp01(v) * (size - 1);
|
||||
|
||||
const x0 = Math.min(Math.floor(x), size - 1);
|
||||
const y0 = Math.min(Math.floor(y), size - 1);
|
||||
const x1 = Math.min(x0 + 1, size - 1);
|
||||
const y1 = Math.min(y0 + 1, size - 1);
|
||||
const fx = x - x0;
|
||||
const fy = y - y0;
|
||||
|
||||
const at = (column: number, row: number) => pick(field.nodes[(row * size) + column]!);
|
||||
const top = lerp(at(x0, y0), at(x1, y0), fx);
|
||||
const bottom = lerp(at(x0, y1), at(x1, y1), fx);
|
||||
return lerp(top, bottom, fy);
|
||||
}
|
||||
|
||||
export const PRECIPITATION = (node: Weather): number => node.precipitationMmH;
|
||||
export const CLOUD_COVER = (node: Weather): number => node.cloudCover;
|
||||
export const SNOW_DEPTH = (node: Weather): number => node.snowDepthMm;
|
||||
|
||||
/**
|
||||
* Bearings wrap, so averaging them as plain numbers puts the midpoint of 350° and 10° at 180° — pointing
|
||||
* exactly backwards. Interpolating the unit vectors instead gives 0°, which is the answer.
|
||||
*/
|
||||
function blendBearing(bearings: number[], fx: number, fy: number): number {
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = bearings as [number, number, number, number];
|
||||
|
||||
const weights = [
|
||||
(1 - fx) * (1 - fy),
|
||||
fx * (1 - fy),
|
||||
(1 - fx) * fy,
|
||||
fx * fy,
|
||||
];
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const [index, bearing] of [topLeft, topRight, bottomLeft, bottomRight].entries()) {
|
||||
const radians = (bearing * Math.PI) / 180;
|
||||
x += Math.sin(radians) * weights[index]!;
|
||||
y += Math.cos(radians) * weights[index]!;
|
||||
}
|
||||
|
||||
if (x === 0 && y === 0) return topLeft;
|
||||
|
||||
const degrees = (Math.atan2(x, y) * 180) / Math.PI;
|
||||
return ((degrees % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
function lerp(from: number, to: number, t: number): number {
|
||||
return from + ((to - from) * t);
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BlurFilter, Container, Graphics } from 'pixi.js';
|
||||
import { FULL_SNOW_COVER_MM, type PrecipitationSpec, type SkyState } from './sky';
|
||||
import { Container, Graphics } from 'pixi.js';
|
||||
import type { PrecipitationSpec, SkyState } from './sky';
|
||||
|
||||
/** Densities in {@link PrecipitationSpec} are quoted for this viewport and scaled by area from here. */
|
||||
const REFERENCE_AREA = 1280 * 720;
|
||||
@@ -7,37 +7,12 @@ const REFERENCE_AREA = 1280 * 720;
|
||||
/** A hard ceiling on particles, whatever the screen size — the whole layer redraws every frame. */
|
||||
const MAX_PARTICLES = 600;
|
||||
|
||||
/**
|
||||
* Patches across the screen used to paint cloud, fog and lying snow. The server field is only 8×8 over the
|
||||
* whole map, so this is plenty to carry its shape; a blur smooths the seams between patches into a gradient.
|
||||
*/
|
||||
const WASH_PATCHES = 10;
|
||||
|
||||
/**
|
||||
* Reads what the weather is doing over the ground under a screen position. The renderer never asks the field
|
||||
* directly — this is how it stays about the territory rather than about the viewport.
|
||||
*/
|
||||
export interface WeatherProbe {
|
||||
/** Precipitation in mm/h under this pixel. */
|
||||
precipitation(screenX: number, screenY: number): number;
|
||||
/** Cloud cover 0..1 under this pixel. */
|
||||
cloud(screenX: number, screenY: number): number;
|
||||
/** Lying snow in mm under this pixel. */
|
||||
snow(screenX: number, screenY: number): number;
|
||||
}
|
||||
|
||||
/** Used before any field has arrived: nothing anywhere. */
|
||||
export const NO_WEATHER: WeatherProbe = {
|
||||
precipitation: () => 0,
|
||||
cloud: () => 0,
|
||||
snow: () => 0,
|
||||
};
|
||||
|
||||
const CLEAR_SKY: SkyState = {
|
||||
sunElevationDeg: 90,
|
||||
tint: 0xffffff,
|
||||
tintAlpha: 0,
|
||||
hazeAlpha: 0,
|
||||
snowCover: 0,
|
||||
lightning: false,
|
||||
};
|
||||
|
||||
@@ -48,12 +23,6 @@ const NOTHING_FALLING: PrecipitationSpec = {
|
||||
speedPxPerSecond: 0,
|
||||
};
|
||||
|
||||
/** Below this the drop is over dry ground and simply is not drawn. */
|
||||
const PRECIPITATION_FLOOR_MMH = 0.05;
|
||||
|
||||
/** Precipitation that counts as a downpour, for scaling a drop's opacity between edge and core. */
|
||||
const HEAVY_RAIN_MMH = 6;
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -64,10 +33,10 @@ interface Particle {
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the weather draws over the map. The wash for time of day is one flat layer — the sun sets on a
|
||||
* whole town at once — but cloud, fog and lying snow are painted patch by patch from the field under the
|
||||
* ground, and each drop is drawn only if it is over ground that is actually wet. That is what lets the edge
|
||||
* of a front sit across the map instead of the whole screen raining together.
|
||||
* Everything the weather draws over the map: the wash for time of day, cloud, fog and lying snow, plus the
|
||||
* rain or snow falling through it. Both live in screen space, and the weather is one reading for the whole
|
||||
* world, so the wash covers the view evenly — over a town-sized map it genuinely is the same weather
|
||||
* everywhere.
|
||||
*/
|
||||
export class WeatherLayer {
|
||||
/** Sits above the map but below the place names, which stay readable through it. */
|
||||
@@ -76,9 +45,7 @@ export class WeatherLayer {
|
||||
/** Sits above everything — rain falls in front of the labels too. */
|
||||
readonly precipitation = new Container();
|
||||
|
||||
private readonly tint = new Graphics();
|
||||
private readonly patches = new Graphics();
|
||||
private readonly patchBlur = new BlurFilter({ strength: 24, quality: 3 });
|
||||
private readonly wash = new Graphics();
|
||||
private readonly flash = new Graphics();
|
||||
private readonly drops = new Graphics();
|
||||
private readonly particles: Particle[] = [];
|
||||
@@ -87,7 +54,7 @@ export class WeatherLayer {
|
||||
private height = 0;
|
||||
private state: SkyState = CLEAR_SKY;
|
||||
private spec: PrecipitationSpec = NOTHING_FALLING;
|
||||
private probe: WeatherProbe = NO_WEATHER;
|
||||
private washDirty = true;
|
||||
|
||||
/** Seconds until the next strike, and how much of the current flash is left to burn off. */
|
||||
private nextStrikeIn = 0;
|
||||
@@ -95,11 +62,7 @@ export class WeatherLayer {
|
||||
private flashPeak = 0;
|
||||
|
||||
constructor() {
|
||||
// The patches are deliberately coarse; blurring them turns the grid into a smooth field.
|
||||
this.patches.filters = [this.patchBlur];
|
||||
|
||||
this.sky.addChild(this.tint);
|
||||
this.sky.addChild(this.patches);
|
||||
this.sky.addChild(this.wash);
|
||||
this.sky.addChild(this.flash);
|
||||
this.precipitation.addChild(this.drops);
|
||||
this.sky.eventMode = 'none';
|
||||
@@ -111,21 +74,33 @@ export class WeatherLayer {
|
||||
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.washDirty = true;
|
||||
this.resizePool();
|
||||
}
|
||||
|
||||
/** Nothing is drawn until this is called; a world with no weather yet stays untouched. */
|
||||
apply(state: SkyState, spec: PrecipitationSpec, probe: WeatherProbe): void {
|
||||
apply(state: SkyState, spec: PrecipitationSpec): void {
|
||||
if (
|
||||
state.tint !== this.state.tint
|
||||
|| state.tintAlpha !== this.state.tintAlpha
|
||||
|| state.hazeAlpha !== this.state.hazeAlpha
|
||||
|| state.snowCover !== this.state.snowCover
|
||||
) {
|
||||
this.washDirty = true;
|
||||
}
|
||||
|
||||
this.state = state;
|
||||
this.spec = spec;
|
||||
this.probe = probe;
|
||||
this.resizePool();
|
||||
}
|
||||
|
||||
/** Steps the falling particles and repaints. Called once per frame. */
|
||||
advance(deltaMs: number): void {
|
||||
this.paintTint();
|
||||
this.paintPatches();
|
||||
if (this.washDirty) {
|
||||
this.paintWash();
|
||||
this.washDirty = false;
|
||||
}
|
||||
|
||||
this.stepLightning(deltaMs);
|
||||
this.stepParticles(deltaMs);
|
||||
this.paintParticles();
|
||||
@@ -133,15 +108,14 @@ export class WeatherLayer {
|
||||
|
||||
clear(): void {
|
||||
this.particles.length = 0;
|
||||
this.tint.clear();
|
||||
this.patches.clear();
|
||||
this.wash.clear();
|
||||
this.flash.clear();
|
||||
this.drops.clear();
|
||||
this.state = CLEAR_SKY;
|
||||
this.spec = NOTHING_FALLING;
|
||||
this.probe = NO_WEATHER;
|
||||
this.nextStrikeIn = 0;
|
||||
this.flashRemaining = 0;
|
||||
this.washDirty = true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
@@ -149,68 +123,23 @@ export class WeatherLayer {
|
||||
this.precipitation.destroy({ children: true });
|
||||
}
|
||||
|
||||
/** Time of day covers the whole map evenly, so it stays one rectangle. */
|
||||
private paintTint(): void {
|
||||
this.tint.clear();
|
||||
if (this.width === 0 || this.state.tintAlpha <= 0.001) return;
|
||||
|
||||
this.tint.rect(0, 0, this.width, this.height).fill({
|
||||
color: this.state.tint,
|
||||
alpha: this.state.tintAlpha,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud, fog and snow follow the ground, so they are painted as a grid read from the field under each
|
||||
* patch. The patches are drawn oversized and blurred, which is what turns ten steps into a gradient.
|
||||
*/
|
||||
private paintPatches(): void {
|
||||
this.patches.clear();
|
||||
private paintWash(): void {
|
||||
this.wash.clear();
|
||||
if (this.width === 0 || this.height === 0) return;
|
||||
|
||||
const patchWidth = this.width / WASH_PATCHES;
|
||||
const patchHeight = this.height / WASH_PATCHES;
|
||||
this.patchBlur.strength = Math.max(patchWidth, patchHeight) * 0.9;
|
||||
const { tint, tintAlpha, hazeAlpha, snowCover } = this.state;
|
||||
|
||||
// Bleed past the edges so the blur does not fade the wash out at the border of the screen.
|
||||
const bleed = Math.max(patchWidth, patchHeight);
|
||||
|
||||
for (let row = 0; row < WASH_PATCHES; row++) {
|
||||
for (let column = 0; column < WASH_PATCHES; column++) {
|
||||
const centreX = (column + 0.5) * patchWidth;
|
||||
const centreY = (row + 0.5) * patchHeight;
|
||||
|
||||
const snow = Math.min(this.probe.snow(centreX, centreY) / FULL_SNOW_COVER_MM, 1);
|
||||
const overcast = this.probe.cloud(centreX, centreY);
|
||||
const falling = Math.min(this.probe.precipitation(centreX, centreY) / HEAVY_RAIN_MMH, 1);
|
||||
|
||||
const x = column === 0 ? -bleed : column * patchWidth;
|
||||
const y = row === 0 ? -bleed : row * patchHeight;
|
||||
const w = patchWidth + (column === 0 || column === WASH_PATCHES - 1 ? bleed : 0);
|
||||
const h = patchHeight + (row === 0 || row === WASH_PATCHES - 1 ? bleed : 0);
|
||||
|
||||
if (snow > 0.002) {
|
||||
this.patches.rect(x, y, w, h).fill({ color: 0xeef3f8, alpha: snow * 0.5 });
|
||||
if (tintAlpha > 0.001) {
|
||||
this.wash.rect(0, 0, this.width, this.height).fill({ color: tint, alpha: tintAlpha });
|
||||
}
|
||||
|
||||
// Cloud dims the ground under it, which is what makes a cloud shadow read as a shadow.
|
||||
if (overcast > 0.002 && this.state.sunElevationDeg > -6) {
|
||||
this.patches.rect(x, y, w, h).fill({ color: 0x8d95a0, alpha: overcast * 0.22 });
|
||||
// Lying snow goes on before the haze so fog still reads as fog over a white landscape.
|
||||
if (snowCover > 0.001) {
|
||||
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xeef3f8, alpha: snowCover * 0.5 });
|
||||
}
|
||||
|
||||
// The precipitation map proper: the shower darkens the ground it is standing over, so the shape of
|
||||
// a front is legible from any zoom - individual drops are far too small to read from across a map.
|
||||
if (falling > 0.01) {
|
||||
this.patches.rect(x, y, w, h).fill({
|
||||
color: this.spec.kind === 'snow' ? 0xdce6f2 : 0x5f7b98,
|
||||
alpha: falling * 0.34,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.hazeAlpha > 0.002) {
|
||||
this.patches.rect(x, y, w, h).fill({ color: 0xd7dce2, alpha: this.state.hazeAlpha });
|
||||
}
|
||||
}
|
||||
if (hazeAlpha > 0.001) {
|
||||
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xd7dce2, alpha: hazeAlpha });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +178,7 @@ export class WeatherLayer {
|
||||
this.flash.rect(0, 0, this.width, this.height).fill({ color: 0xf2f6ff, alpha });
|
||||
}
|
||||
|
||||
/** Grows or trims the pool to the density the wettest part of the screen asks for. */
|
||||
/** Grows or trims the pool to the density the current weather asks for. */
|
||||
private resizePool(): void {
|
||||
const target = this.targetCount();
|
||||
|
||||
@@ -302,73 +231,34 @@ export class WeatherLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws each particle at the strength of the ground beneath it, so the pool thins out to nothing across
|
||||
* the edge of a front instead of raining evenly over the whole viewport.
|
||||
*/
|
||||
private paintParticles(): void {
|
||||
this.drops.clear();
|
||||
if (this.particles.length === 0) return;
|
||||
|
||||
if (this.spec.kind === 'dust') {
|
||||
this.paintDust();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.spec.kind === 'snow') {
|
||||
// Opacity carries the intensity, so the flakes are grouped into bands and filled once per band.
|
||||
for (const band of [0.3, 0.6, 1]) {
|
||||
let drawn = false;
|
||||
for (const particle of this.particles) {
|
||||
if (this.bandOf(particle) !== band) continue;
|
||||
this.drops.circle(particle.x, particle.y, 1.1 * particle.scale);
|
||||
drawn = true;
|
||||
}
|
||||
|
||||
if (drawn) this.drops.fill({ color: 0xffffff, alpha: 0.85 * band });
|
||||
}
|
||||
|
||||
this.drops.fill({ color: 0xffffff, alpha: 0.85 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Rain and dust are both streaks; only their length and colour differ. One path for the lot, stroked
|
||||
// once, so Pixi batches the whole thing into a single draw.
|
||||
const dust = this.spec.kind === 'dust';
|
||||
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
|
||||
const length = 9 + (this.spec.speedPxPerSecond / 90);
|
||||
const length = dust ? 4 : 9 + (this.spec.speedPxPerSecond / 90);
|
||||
|
||||
for (const band of [0.3, 0.6, 1]) {
|
||||
let drawn = false;
|
||||
for (const particle of this.particles) {
|
||||
if (this.bandOf(particle) !== band) continue;
|
||||
const streak = length * particle.scale;
|
||||
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (streak * slant), particle.y + streak);
|
||||
drawn = true;
|
||||
}
|
||||
|
||||
if (drawn) this.drops.stroke({ width: 1.1, color: 0xaec6dd, alpha: 0.55 * band });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets a particle's local intensity into one of three opacities. Stroking once per bucket keeps the
|
||||
* whole fall to three draws however many drops there are, which a per-drop alpha would not.
|
||||
*/
|
||||
private bandOf(particle: Particle): number {
|
||||
const local = this.probe.precipitation(particle.x, particle.y);
|
||||
if (local < PRECIPITATION_FLOOR_MMH) return 0;
|
||||
|
||||
const strength = Math.min(local / HEAVY_RAIN_MMH, 1);
|
||||
if (strength < 0.25) return 0.3;
|
||||
return strength < 0.6 ? 0.6 : 1;
|
||||
}
|
||||
|
||||
/** Dust blows over the whole storm rather than over wet ground, so it ignores the precipitation probe. */
|
||||
private paintDust(): void {
|
||||
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
|
||||
|
||||
for (const particle of this.particles) {
|
||||
const streak = 4 * particle.scale;
|
||||
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (streak * slant), particle.y + streak);
|
||||
}
|
||||
|
||||
this.drops.stroke({ width: 1.4, color: 0xc9a86a, alpha: 0.4 });
|
||||
this.drops.stroke(
|
||||
dust
|
||||
? { width: 1.4, color: 0xc9a86a, alpha: 0.4 }
|
||||
: { width: 1.1, color: 0xaec6dd, alpha: 0.55 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Off reads as muted rather than hidden, so the control does not vanish when it is doing nothing. */
|
||||
.icon-button[aria-pressed='false'] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sim-controls__weather {
|
||||
padding-left: 10px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -236,21 +236,6 @@ public sealed class WeatherModelTests
|
||||
Assert.Equal(0f, WeatherModel.UpdateSnowDepth(-5f, -10f, 0f, 0f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lying_snow_thins_out_over_the_warmer_parts_of_the_map()
|
||||
{
|
||||
const float pack = 200f;
|
||||
|
||||
// Well below freezing the whole pack shows; the warmer corners of the field go patchy.
|
||||
Assert.Equal(pack, WeatherModel.LocalSnowDepth(pack, -10f), 1);
|
||||
Assert.True(WeatherModel.LocalSnowDepth(pack, 4f) < pack);
|
||||
Assert.True(WeatherModel.LocalSnowDepth(pack, 12f) < WeatherModel.LocalSnowDepth(pack, 4f));
|
||||
|
||||
// It thins rather than vanishing - melting is the integral's job, not the renderer's.
|
||||
Assert.True(WeatherModel.LocalSnowDepth(pack, 30f) > 0f);
|
||||
Assert.Equal(0f, WeatherModel.LocalSnowDepth(0f, -10f));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_world_opened_in_deep_winter_already_has_snow_on_the_ground()
|
||||
{
|
||||
|
||||
@@ -37,6 +37,34 @@ public sealed class WeatherSystemTests
|
||||
Assert.NotEqual(0f, anomaly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A system is now a cell on the map rather than the whole sky, so one launched off the edge can drift
|
||||
/// past without ever being felt. A freshly seeded pool has to be standing over the map, not beside it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_seeded_pool_is_actually_over_the_map()
|
||||
{
|
||||
var overhead = 0;
|
||||
|
||||
for (var seed = 0; seed < 30; seed++)
|
||||
{
|
||||
using var world = new EcsWorld();
|
||||
WeatherSystem.Seed(world.Ecs, ClimateCatalog.HotDesert, 30.05, (ulong)seed, Summer);
|
||||
|
||||
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
|
||||
var count = WeatherSystem.CopySystems(world.Ecs, systems);
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var system = systems[i];
|
||||
if (MathF.Abs(system.X - 0.5f) < 0.8f && MathF.Abs(system.Y - 0.5f) < 0.8f) overhead++;
|
||||
}
|
||||
}
|
||||
|
||||
// Two systems per desert world over thirty worlds: the great majority must be within reach.
|
||||
Assert.True(overhead > 45, $"Only {overhead}/60 seeded systems were anywhere near the map.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_same_seed_produces_the_same_sky()
|
||||
{
|
||||
|
||||
@@ -242,66 +242,27 @@ public sealed class WorldSimulationTests
|
||||
Assert.Contains(stored.WeatherState.Systems, static system => system.AgeHours > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_weather_field_covers_the_whole_map_and_varies_across_it()
|
||||
{
|
||||
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
|
||||
|
||||
var field = simulation.SnapshotWeatherField();
|
||||
|
||||
Assert.Equal(ClimateKind.CentralEuropean, field.Climate);
|
||||
Assert.Equal(WorldSimulation.WeatherGridSize, field.Size);
|
||||
Assert.Equal(field.Size * field.Size, field.Nodes.Count);
|
||||
|
||||
// A drifting pressure system means the corners cannot all read the same pressure.
|
||||
var pressures = field.Nodes.Select(static node => node.PressureHpa).Distinct().Count();
|
||||
Assert.True(pressures > 1, "The field is uniform - the pressure systems are not being sampled.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The whole point of a field is that it has shape. A system wide enough to cover the map evenly reads
|
||||
/// as one flat value with no edge, which is what makes a front invisible however carefully it is drawn.
|
||||
/// The weather has to actually move. The pressure systems drift, so a world sampled hours apart must not
|
||||
/// keep reporting the same sky - that was the failure that hid behind a field which never varied.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_stormy_world_has_real_structure_across_the_map()
|
||||
public void The_weather_changes_as_the_systems_drift_over()
|
||||
{
|
||||
var summary = ReadySummary() with { Climate = ClimateKind.Oceanic, Latitude = 51.51 };
|
||||
using var simulation = WorldSimulation.Create(summary, catchUp: false);
|
||||
|
||||
// Sample a few independent skies: any one roll can happen to be flat, a dozen cannot.
|
||||
var structured = 0;
|
||||
for (var attempt = 0; attempt < 12; attempt++)
|
||||
var readings = new List<double>();
|
||||
for (var step = 0; step < 8; step++)
|
||||
{
|
||||
using var simulation = WorldSimulation.Create(
|
||||
summary with { Id = $"storm-{attempt:00000000}" }, catchUp: false);
|
||||
|
||||
var pressures = simulation.SnapshotWeatherField().Nodes
|
||||
.Select(static node => node.PressureHpa)
|
||||
.ToArray();
|
||||
|
||||
if (pressures.Max() - pressures.Min() > 2.0) structured++;
|
||||
// Two game hours a step, which is a few real seconds of play at x1.
|
||||
simulation.Tick(TimeSpan.FromSeconds(24));
|
||||
readings.Add(simulation.SnapshotWeather().PressureHpa);
|
||||
}
|
||||
|
||||
Assert.True(structured >= 8, $"Only {structured}/12 skies had any shape across the map.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Neighbouring_field_nodes_stay_close_together()
|
||||
{
|
||||
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
|
||||
var field = simulation.SnapshotWeatherField();
|
||||
|
||||
// Gaussian bumps are smooth, so a coarse grid is safe to interpolate between on the client.
|
||||
for (var row = 0; row < field.Size; row++)
|
||||
{
|
||||
for (var column = 1; column < field.Size; column++)
|
||||
{
|
||||
var left = field.Nodes[(row * field.Size) + column - 1];
|
||||
var right = field.Nodes[(row * field.Size) + column];
|
||||
Assert.True(
|
||||
Math.Abs(left.TemperatureC - right.TemperatureC) < 6,
|
||||
$"Nodes {column - 1} and {column} of row {row} jump by more than six degrees.");
|
||||
}
|
||||
}
|
||||
readings.Max() - readings.Min() > 0.5,
|
||||
$"Pressure barely moved over sixteen game hours: {string.Join(", ", readings)}");
|
||||
}
|
||||
|
||||
private static StoredWorldDto ReadySummary() => new()
|
||||
|
||||
Reference in New Issue
Block a user