diff --git a/AGENTS.md b/AGENTS.md index 715863c..fd99805 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index f2991b7..e0ea03c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs index 1b6b407..1989957 100644 --- a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs +++ b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs @@ -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 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 - { - ["id"] = ["Weather is only available when the world is ready."], - }); - } - private static async Task UpdateClock( string id, UpdateClockRequest request, diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs index bd7dc9d..0bc107b 100644 --- a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs @@ -18,6 +18,9 @@ public sealed class WorldSimulation : IDisposable /// private static readonly TimeSpan MaxWeatherStep = TimeSpan.FromHours(6); + /// The pressure field is only ever read here - one point speaks for the whole map. + 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 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 } /// - /// 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. /// - public const int WeatherGridSize = 12; - - /// Weather at the middle of the map - what the HUD and the world list show. public WeatherDto SnapshotWeather() { lock (_gate) @@ -322,54 +322,19 @@ public sealed class WorldSimulation : IDisposable Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems]; var count = WeatherSystem.CopySystems(_ecs, systems); - return SampleUnlocked(systems[..count], 0.5f, 0.5f); + return SampleUnlocked(systems[..count]); } } - /// - /// 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. - /// - public WeatherFieldDto SnapshotWeatherField() - { - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - Span 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 systems, float x, float y) + private WeatherDto SampleUnlocked(ReadOnlySpan systems) { var gameTime = new DateTime(_ecs.Get(_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]), }; } } diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs index 940917e..1410c81 100644 --- a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs @@ -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)) diff --git a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs index 2cc7873..73ebc6f 100644 --- a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs +++ b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs @@ -81,8 +81,8 @@ public sealed record WorldSummaryDto public ClimateKind? Climate { get; init; } /// - /// Live weather at the centre of the map, for the HUD and the world list. The full field lives behind - /// GET /api/worlds/{id}/weather; 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. /// public WeatherDto? Weather { get; init; } } @@ -220,27 +220,12 @@ public sealed record WeatherDto public required double WindDirectionDeg { get; init; } /// - /// 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. /// public required double SnowDepthMm { get; init; } } -/// -/// Response body for GET /api/worlds/{id}/weather: 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. -/// -public sealed record WeatherFieldDto -{ - public required ClimateKind Climate { get; init; } - - /// Nodes per side. Nodes holds Size * Size entries. - public required int Size { get; init; } - - public required IReadOnlyList Nodes { get; init; } -} - /// One entry of GET /api/climates, so the create form never drifts from the server's list. public sealed record ClimateDto { diff --git a/src/TheLivingWorld.Core/Simulation/WeatherModel.cs b/src/TheLivingWorld.Core/Simulation/WeatherModel.cs index a4aed80..3d69f50 100644 --- a/src/TheLivingWorld.Core/Simulation/WeatherModel.cs +++ b/src/TheLivingWorld.Core/Simulation/WeatherModel.cs @@ -287,19 +287,6 @@ public static class WeatherModel return Math.Clamp(current - melted, 0f, MaxSnowDepthMm); } - /// - /// 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. - /// - 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)); - } - /// /// 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: diff --git a/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs b/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs index 5aeef9d..9065f8b 100644 --- a/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs +++ b/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs @@ -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)); diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index 74efe90..9033167 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -120,6 +120,14 @@ + diff --git a/src/TheLivingWorld.Web/src/api/client.ts b/src/TheLivingWorld.Web/src/api/client.ts index ebc787c..917b6d1 100644 --- a/src/TheLivingWorld.Web/src/api/client.ts +++ b/src/TheLivingWorld.Web/src/api/client.ts @@ -3,7 +3,6 @@ import type { CreateWorldRequest, MapChunk, UpdateClockRequest, - WeatherField, WorldClock, WorldList, WorldMap, @@ -59,8 +58,6 @@ export const api = { listClimates: () => request('/api/climates'), - getWeather: (id: string) => request(`${BASE}/${id}/weather`), - updateClock: (id: string, body: UpdateClockRequest) => request(`${BASE}/${id}/clock`, { method: 'PATCH', diff --git a/src/TheLivingWorld.Web/src/api/types.ts b/src/TheLivingWorld.Web/src/api/types.ts index 21b35d7..623a980 100644 --- a/src/TheLivingWorld.Web/src/api/types.ts +++ b/src/TheLivingWorld.Web/src/api/types.ts @@ -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; diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts index 0f41bea..14191ee 100644 --- a/src/TheLivingWorld.Web/src/main.ts +++ b/src/TheLivingWorld.Web/src/main.ts @@ -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('menu'), @@ -48,6 +47,7 @@ const elements = { back: required('back-button'), menuThemeToggle: required('menu-theme-toggle'), gameThemeToggle: required('game-theme-toggle'), + weatherToggle: required('weather-toggle'), simControls: required('sim-controls'), gameClock: required('game-clock'), gameWeather: required('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(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 { - 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 { if (activeWorldId !== worldId || clockUpdating) return; @@ -420,8 +400,7 @@ async function pollGameClock(worldId: string): Promise { 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 { 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 { } applyTheme(readStoredTheme()); + applyWeatherEffects(readWeatherEffects()); showMenu(); await loadClimates(); diff --git a/src/TheLivingWorld.Web/src/map/mapView.ts b/src/TheLivingWorld.Web/src/map/mapView.ts index f313d80..9f82381 100644 --- a/src/TheLivingWorld.Web/src/map/mapView.ts +++ b/src/TheLivingWorld.Web/src/map/mapView.ts @@ -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 diff --git a/src/TheLivingWorld.Web/src/map/sky.test.ts b/src/TheLivingWorld.Web/src/map/sky.test.ts index ca59666..c7dfffc 100644 --- a/src/TheLivingWorld.Web/src/map/sky.test.ts +++ b/src/TheLivingWorld.Web/src/map/sky.test.ts @@ -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 { +function weather(overrides: Partial = {}): 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); } } }); diff --git a/src/TheLivingWorld.Web/src/map/sky.ts b/src/TheLivingWorld.Web/src/map/sky.ts index 17081e8..3f14163 100644 --- a/src/TheLivingWorld.Web/src/map/sky.ts +++ b/src/TheLivingWorld.Web/src/map/sky.ts @@ -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: base.tint, - tintAlpha: clamp01(base.alpha * (alreadyDark ? 0.45 : 1)), + 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); diff --git a/src/TheLivingWorld.Web/src/map/weatherField.test.ts b/src/TheLivingWorld.Web/src/map/weatherField.test.ts deleted file mode 100644 index 906afa7..0000000 --- a/src/TheLivingWorld.Web/src/map/weatherField.test.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/src/TheLivingWorld.Web/src/map/weatherField.ts b/src/TheLivingWorld.Web/src/map/weatherField.ts deleted file mode 100644 index 544a444..0000000 --- a/src/TheLivingWorld.Web/src/map/weatherField.ts +++ /dev/null @@ -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; -} diff --git a/src/TheLivingWorld.Web/src/map/weatherLayer.ts b/src/TheLivingWorld.Web/src/map/weatherLayer.ts index b9d1f73..6d55ffb 100644 --- a/src/TheLivingWorld.Web/src/map/weatherLayer.ts +++ b/src/TheLivingWorld.Web/src/map/weatherLayer.ts @@ -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); + if (tintAlpha > 0.001) { + this.wash.rect(0, 0, this.width, this.height).fill({ color: tint, alpha: tintAlpha }); + } - 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; + // 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 }); + } - 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 }); - } - - // 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 }); - } - - // 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 }); + for (const particle of this.particles) { + this.drops.circle(particle.x, particle.y, 1.1 * particle.scale); } + 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); - - 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); + const length = dust ? 4 : 9 + (this.spec.speedPxPerSecond / 90); for (const particle of this.particles) { - const streak = 4 * particle.scale; + const streak = length * 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 }, + ); } } diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css index daa53c4..1a45163 100644 --- a/src/TheLivingWorld.Web/src/styles.css +++ b/src/TheLivingWorld.Web/src/styles.css @@ -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; diff --git a/tests/TheLivingWorld.Tests/WeatherModelTests.cs b/tests/TheLivingWorld.Tests/WeatherModelTests.cs index 0b216f2..5d9ac19 100644 --- a/tests/TheLivingWorld.Tests/WeatherModelTests.cs +++ b/tests/TheLivingWorld.Tests/WeatherModelTests.cs @@ -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() { diff --git a/tests/TheLivingWorld.Tests/WeatherSystemTests.cs b/tests/TheLivingWorld.Tests/WeatherSystemTests.cs index d324dc8..9b0c45f 100644 --- a/tests/TheLivingWorld.Tests/WeatherSystemTests.cs +++ b/tests/TheLivingWorld.Tests/WeatherSystemTests.cs @@ -37,6 +37,34 @@ public sealed class WeatherSystemTests Assert.NotEqual(0f, anomaly); } + /// + /// 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. + /// + [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 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() { diff --git a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs index 3f63d81..3be9728 100644 --- a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs +++ b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs @@ -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."); - } - /// - /// 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. /// [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(); + 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."); - } - } + Assert.True( + readings.Max() - readings.Min() > 0.5, + $"Pressure barely moved over sixteen game hours: {string.Join(", ", readings)}"); } private static StoredWorldDto ReadySummary() => new()