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

This commit is contained in:
Leonid Pershin
2026-08-16 23:05:11 +03:00
parent 3610ee8051
commit 2a8b7b49b3
36 changed files with 3650 additions and 31 deletions
+9
View File
@@ -78,6 +78,14 @@
/>
</label>
<label class="field">
<span>Climate</span>
<select id="field-climate">
<option value="">From the location</option>
</select>
<small id="climate-hint" class="field__hint"></small>
</label>
<p id="form-hint" class="form__hint" hidden></p>
<button id="generate-button" type="submit" class="button">Generate world</button>
</form>
@@ -97,6 +105,7 @@
<span id="world-title" class="game-bar__title"></span>
<div id="sim-controls" class="sim-controls" hidden>
<span id="game-clock" class="sim-controls__clock" aria-live="polite"></span>
<span id="game-weather" class="sim-controls__weather" aria-live="polite"></span>
<button
id="play-pause"
type="button"
+6
View File
@@ -1,7 +1,9 @@
import type {
ClimateOption,
CreateWorldRequest,
MapChunk,
UpdateClockRequest,
WeatherField,
WorldClock,
WorldList,
WorldMap,
@@ -55,6 +57,10 @@ export const api = {
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
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',
+51
View File
@@ -22,6 +22,55 @@ export interface WorldSummary {
createdAt: string;
stats?: WorldStats;
clock?: WorldClock;
climate?: ClimateKind;
/** Live weather at the middle of the map. Only present while the world is running. */
weather?: Weather;
}
/** Köppen-lite climate presets. Mirrors ClimateKind on the server. */
export type ClimateKind =
| 'equatorial' | 'tropicalMonsoon' | 'savanna' | 'hotDesert' | 'coldSteppe' | 'mediterranean'
| 'humidSubtropical' | 'oceanic' | 'centralEuropean' | 'siberian' | 'tundra' | 'highland';
export type WeatherCondition =
| 'clear' | 'fewClouds' | 'cloudy' | 'overcast' | 'fog' | 'drizzle' | 'rain' | 'heavyRain'
| 'thunderstorm' | 'sleet' | 'snow' | 'heavySnow' | 'blizzard' | 'sandstorm';
export interface Weather {
condition: WeatherCondition;
temperatureC: number;
feelsLikeC: number;
pressureHpa: number;
/** 0..1 */
humidity: number;
/** 0..1 */
cloudCover: number;
precipitationMmH: number;
windSpeedMs: number;
/** Compass bearing the wind blows from, 0..360. */
windDirectionDeg: number;
/** Snow lying on the ground. World-wide rather than per point, so every node carries the same value. */
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;
label: string;
koppenCode: string;
example: string;
/**
* Absolute latitude below which this preset is the server's default, or absent when the latitude rule
* never picks it. Entries arrive equator-first.
*/
bandLimit?: number;
}
/** In-world calendar. gameTime is a naive local datetime string (no Z). */
@@ -50,6 +99,8 @@ export interface CreateWorldRequest {
forceRefresh?: boolean;
/** Naive local game calendar start, e.g. `2012-04-12T06:00:00`. Omits → server default. */
startGameTime?: string;
/** Omit to let the server guess from the latitude. */
climate?: ClimateKind;
}
/** [minX, minY, maxX, maxY] in world metres. */
+113 -3
View File
@@ -1,21 +1,26 @@
import './styles.css';
import { api, waitForWorld } from './api/client';
import type { WorldClock, WorldSummary } from './api/types';
import type { ClimateKind, ClimateOption, Weather, WorldClock, WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView';
import { THEMES, type ThemeName } from './map/theme';
import { formatCoordinates, parseCoordinates } from './ui/coordinates';
import {
formatGameTime,
formatGameTimeRaw,
formatWeekday,
interpolateGameTime,
startGameTimeFromInput,
} from './ui/gameTime';
import { climateFromLatitude, describeClimate, findClimate } from './ui/climate';
import { describeWeather, formatWeather } from './ui/weather';
const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme';
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'),
@@ -27,6 +32,8 @@ const elements = {
size: required<HTMLInputElement>('field-size'),
sizeValue: required<HTMLOutputElement>('field-size-value'),
start: required<HTMLInputElement>('field-start'),
climate: required<HTMLSelectElement>('field-climate'),
climateHint: required<HTMLElement>('climate-hint'),
generate: required<HTMLButtonElement>('generate-button'),
formHint: required<HTMLElement>('form-hint'),
useLocation: required<HTMLButtonElement>('use-location'),
@@ -43,6 +50,7 @@ const elements = {
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
simControls: required<HTMLDivElement>('sim-controls'),
gameClock: required<HTMLElement>('game-clock'),
gameWeather: required<HTMLElement>('game-weather'),
playPause: required<HTMLButtonElement>('play-pause'),
speedButtons: [...document.querySelectorAll<HTMLButtonElement>('.speed-button')],
};
@@ -62,9 +70,12 @@ let menuPollTimer: number | null = null;
let menuPaintTimer: number | null = null;
let gameClock: WorldClock | null = null;
let gameWeather: Weather | null = null;
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 {
@@ -309,13 +320,32 @@ function applyClockToControls(clock: WorldClock): void {
function paintGameClock(): void {
if (!gameClock) {
elements.gameClock.textContent = '';
elements.gameClock.title = '';
view.setGameTime(null);
return;
}
const date = interpolateGameTime(gameClock, performance.now() - gameSnapshotAt);
elements.gameClock.textContent = date
? formatGameTime(date)
: formatGameTimeRaw(gameClock.gameTime);
? formatGameTime(date, { weekday: true })
: formatGameTimeRaw(gameClock.gameTime, { weekday: true });
elements.gameClock.title = date ? formatWeekday(date) : '';
// The renderer lights the sky from the same interpolated instant the HUD shows, so the two never disagree.
view.setGameTime(date);
}
function applyWeatherToControls(weather: Weather | undefined): void {
gameWeather = weather ?? null;
if (!gameWeather) {
elements.gameWeather.textContent = '';
elements.gameWeather.title = '';
return;
}
elements.gameWeather.textContent = formatWeather(gameWeather);
elements.gameWeather.title = describeWeather(gameWeather);
}
function startGameClockLoop(worldId: string): void {
@@ -325,6 +355,11 @@ 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 {
@@ -336,9 +371,28 @@ function stopGameClockLoop(): void {
window.clearInterval(gamePaintTimer);
gamePaintTimer = null;
}
if (weatherPollTimer !== null) {
window.clearInterval(weatherPollTimer);
weatherPollTimer = null;
}
gameClock = null;
gameWeather = null;
view.setWeatherField(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> {
@@ -348,6 +402,9 @@ 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.
applyWeatherToControls(summary.weather);
} catch {
// Keep interpolating from the last good snapshot.
}
@@ -367,6 +424,53 @@ async function patchClock(body: { paused?: boolean; timeScale?: number }): Promi
}
}
async function loadClimates(): Promise<void> {
try {
climateOptions = await api.listClimates();
} catch {
// The picker is a convenience: without it the server still guesses from the location.
climateOptions = [];
}
for (const option of climateOptions) {
const element = document.createElement('option');
element.value = option.kind;
element.textContent = describeClimate(option);
elements.climate.append(element);
}
elements.climate.disabled = climateOptions.length === 0;
paintClimateHint();
}
/** Explains what "From the location" will actually pick, and warns when a manual choice fights the latitude. */
function paintClimateHint(): void {
if (climateOptions.length === 0) {
elements.climateHint.textContent = '';
return;
}
const location = parseCoordinates(elements.coords.value);
const inferred = location ? findClimate(climateOptions, climateFromLatitude(climateOptions, location.latitude)) : null;
if (!elements.climate.value) {
elements.climateHint.textContent = inferred
? `This location suggests ${inferred.label} (${inferred.koppenCode}).`
: 'Enter a location to see what the latitude suggests.';
return;
}
const chosen = findClimate(climateOptions, elements.climate.value as ClimateKind);
if (!chosen) {
elements.climateHint.textContent = '';
return;
}
elements.climateHint.textContent = inferred && inferred.kind !== chosen.kind
? `${chosen.label} — the latitude would have suggested ${inferred.label}.`
: `${chosen.label}, like ${chosen.example}.`;
}
async function ensureMap(): Promise<void> {
if (mapReady) return;
await view.init(elements.stage);
@@ -393,6 +497,7 @@ async function openWorld(id: string): Promise<void> {
setStatus('');
if (summary.clock) applyClockToControls(summary.clock);
applyWeatherToControls(summary.weather);
startGameClockLoop(id);
await refreshWorldList();
@@ -471,6 +576,8 @@ async function generate(event: SubmitEvent): Promise<void> {
longitude: location.longitude,
sizeKm,
startGameTime,
// Empty means "from the location": leave it out and let the server apply its own rule.
climate: (elements.climate.value as ClimateKind) || undefined,
});
await refreshWorldList();
@@ -550,6 +657,8 @@ async function start(): Promise<void> {
void generate(event);
});
elements.useLocation.addEventListener('click', useMyLocation);
elements.coords.addEventListener('input', paintClimateHint);
elements.climate.addEventListener('change', paintClimateHint);
elements.continue.addEventListener('click', () => {
if (continueWorldId) void openWorld(continueWorldId);
});
@@ -572,6 +681,7 @@ async function start(): Promise<void> {
applyTheme(readStoredTheme());
showMenu();
await loadClimates();
try {
const worlds = await refreshWorldList();
+63 -1
View File
@@ -1,11 +1,14 @@
import { Application, Container, Graphics } from 'pixi.js';
import type { WorldMap } from '../api/types';
import type { WeatherField, WorldMap } from '../api/types';
import { Camera, type Viewport } from './camera';
import { ChunkManager } from './chunkManager';
import { profileForZoom, type RenderProfile } from './chunkRenderer';
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, sampleWeatherField, type LocalWeather } from './weatherField';
import { WeatherLayer } from './weatherLayer';
/**
* Builds the container per layer. This lives here rather than in `layers.ts` so that module stays free of
@@ -49,6 +52,11 @@ export class MapView {
private theme: Theme = THEMES.day;
private readonly labels = new LabelLayer(this.theme);
private readonly weather = new WeatherLayer();
private weatherField: WeatherField | null = null;
private gameTime: Date | null = null;
private latitude = 0;
private host: HTMLElement | null = null;
private worldSizeMeters = 0;
@@ -80,9 +88,15 @@ export class MapView {
this.app.stage.addChild(this.root);
// The wash goes over the map but under the place names, so a town stays readable at midnight.
this.app.stage.addChild(this.weather.sky);
// Labels sit outside the scaled container so they keep a constant size on screen.
this.app.stage.addChild(this.labels.container);
// Rain falls in front of everything, labels included.
this.app.stage.addChild(this.weather.precipitation);
this.attachInput(this.app.canvas);
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow.
@@ -96,7 +110,10 @@ export class MapView {
showWorld(map: WorldMap): void {
this.chunks.clear();
this.labels.clear();
this.weather.clear();
this.weatherField = null;
this.worldSizeMeters = map.sizeMeters;
this.latitude = map.latitude;
// Host may have just become visible after a menu → game switch; sync the renderer before fitting.
this.app.resize();
@@ -128,17 +145,34 @@ 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 in-world instant the sky should be lit for. Pushed from the clock loop rather than read here, so
* there is one interpolated game clock in the app instead of two that can disagree.
*/
setGameTime(gameTime: Date | null): void {
this.gameTime = gameTime;
}
clear(): void {
this.chunks.clear();
this.labels.clear();
this.weather.clear();
this.background.clear();
this.border.clear();
this.weatherField = null;
this.gameTime = null;
this.worldSizeMeters = 0;
}
destroy(): void {
this.chunks.clear();
this.labels.clear();
this.weather.destroy();
this.app.destroy(true, { children: true });
}
@@ -180,6 +214,8 @@ export class MapView {
this.chunks.processDrawQueue(this.profile);
this.chunks.advanceFades(deltaMs);
this.updateWeather(viewport, deltaMs);
// Labels live in screen space, so they have to follow the camera every frame. Choosing them is the
// expensive half and stays on the timer; without this split they lag a fast pan and then snap back.
this.labels.reposition(this.camera, viewport);
@@ -195,6 +231,32 @@ export class MapView {
}
}
private updateWeather(viewport: Viewport, deltaMs: number): void {
this.weather.resize(viewport.width, viewport.height);
if (this.gameTime) {
const local = this.localWeather();
this.weather.apply(
skyState(this.gameTime, this.latitude, local, this.theme.dark),
precipitationSpec(local),
);
}
this.weather.advance(deltaMs);
}
/** The field read under the middle of the screen, so what falls is what is overhead right now. */
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,
);
}
private drawBorder(): void {
const half = this.worldSizeMeters / 2;
this.border
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import { FULL_SNOW_COVER_MM, precipitationSpec, skyState, sunElevationDeg } from './sky';
import { CALM, type LocalWeather } from './weatherField';
const WARSAW = 52.23;
const SYDNEY = -33.87;
function at(year: number, month: number, day: number, hour: number, minute = 0): Date {
return new Date(year, month - 1, day, hour, minute, 0);
}
function weather(overrides: Partial<LocalWeather> = {}): LocalWeather {
return { ...CALM, ...overrides };
}
describe('sunElevationDeg', () => {
it('peaks at local noon and bottoms out at midnight', () => {
const noon = sunElevationDeg(at(2012, 6, 21, 12), WARSAW);
const midnight = sunElevationDeg(at(2012, 6, 21, 0), WARSAW);
expect(noon).toBeGreaterThan(55);
expect(midnight).toBeLessThan(0);
});
it('is higher at midsummer than midwinter, and the other way round below the equator', () => {
expect(sunElevationDeg(at(2012, 6, 21, 12), WARSAW))
.toBeGreaterThan(sunElevationDeg(at(2012, 12, 21, 12), WARSAW));
expect(sunElevationDeg(at(2012, 6, 21, 12), SYDNEY))
.toBeLessThan(sunElevationDeg(at(2012, 12, 21, 12), SYDNEY));
});
it('keeps the sun up all night inside the arctic circle at midsummer', () => {
expect(sunElevationDeg(at(2012, 6, 21, 0), 78.22)).toBeGreaterThan(0);
// ...and down all day at midwinter.
expect(sunElevationDeg(at(2012, 12, 21, 12), 78.22)).toBeLessThan(0);
});
it('stays within the physically possible range everywhere, all year', () => {
for (const latitude of [-89, -45, 0, 45, 89]) {
for (let day = 1; day <= 365; day += 7) {
for (let hour = 0; hour < 24; hour += 3) {
const date = new Date(2012, 0, day, hour);
const elevation = sunElevationDeg(date, latitude);
expect(elevation).toBeGreaterThanOrEqual(-90);
expect(elevation).toBeLessThanOrEqual(90);
}
}
}
});
});
describe('skyState', () => {
it('leaves a clear midday alone', () => {
const state = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
expect(state.tintAlpha).toBeLessThan(0.02);
});
it('darkens through dusk into night', () => {
const noon = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false).tintAlpha;
const dusk = skyState(at(2012, 6, 21, 21), WARSAW, weather(), false).tintAlpha;
const night = skyState(at(2012, 12, 21, 0), WARSAW, weather(), false).tintAlpha;
expect(dusk).toBeGreaterThan(noon);
expect(night).toBeGreaterThan(dusk);
});
it('pulls the wash back when the map is already drawn dark', () => {
const time = at(2012, 12, 21, 0);
const onLight = skyState(time, WARSAW, weather(), false).tintAlpha;
const onDark = skyState(time, WARSAW, weather(), true).tintAlpha;
expect(onDark).toBeGreaterThan(0);
expect(onDark).toBeLessThan(onLight);
});
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);
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', () => {
expect(skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: 0 }), false).snowCover).toBe(0);
expect(
skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: FULL_SNOW_COVER_MM / 2 }), false).snowCover,
).toBeCloseTo(0.5, 5);
expect(
skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: 900 }), false).snowCover,
).toBe(1);
});
it('hazes over for fog and a blizzard, but not for plain rain', () => {
const time = at(2012, 4, 12, 6);
expect(skyState(time, WARSAW, weather({ condition: 'fog' }), false).hazeAlpha).toBeGreaterThan(0.3);
expect(skyState(time, WARSAW, weather({ condition: 'blizzard' }), false).hazeAlpha).toBeGreaterThan(0.3);
expect(skyState(time, WARSAW, weather({ condition: 'rain' }), false).hazeAlpha).toBe(0);
});
it('keeps every value inside its range across a whole year', () => {
for (let day = 1; day <= 365; day += 5) {
for (let hour = 0; hour < 24; hour += 2) {
const state = skyState(
new Date(2012, 0, day, hour),
WARSAW,
weather({ cloudCover: 1, snowDepthMm: 400, condition: 'blizzard' }),
false,
);
expect(state.tintAlpha).toBeGreaterThanOrEqual(0);
expect(state.tintAlpha).toBeLessThanOrEqual(1);
expect(state.snowCover).toBeLessThanOrEqual(1);
}
}
});
});
describe('precipitationSpec', () => {
it('reports nothing falling under a dry sky', () => {
expect(precipitationSpec(weather()).kind).toBe('none');
expect(precipitationSpec(weather({ precipitationMmH: 0.01 })).kind).toBe('none');
});
it('falls as rain above freezing and snow below it', () => {
expect(precipitationSpec(weather({ precipitationMmH: 3, temperatureC: 9 })).kind).toBe('rain');
expect(precipitationSpec(weather({ precipitationMmH: 3, temperatureC: -4 })).kind).toBe('snow');
});
it('sends more particles as the rain gets heavier, up to a ceiling', () => {
const light = precipitationSpec(weather({ precipitationMmH: 1, temperatureC: 9 }));
const heavy = precipitationSpec(weather({ precipitationMmH: 8, temperatureC: 9 }));
const absurd = precipitationSpec(weather({ precipitationMmH: 500, temperatureC: 9 }));
expect(heavy.density).toBeGreaterThan(light.density);
expect(absurd.density).toBeLessThanOrEqual(520);
});
it('leans the fall downwind, and the other way for the opposite wind', () => {
const fromWest = precipitationSpec(
weather({ precipitationMmH: 3, temperatureC: 9, windDirectionDeg: 270, windSpeedMs: 10 }),
);
const fromEast = precipitationSpec(
weather({ precipitationMmH: 3, temperatureC: 9, windDirectionDeg: 90, windSpeedMs: 10 }),
);
// A westerly blows towards the east, which is to the right of a north-up screen.
expect(fromWest.slantDeg).toBeGreaterThan(0);
expect(fromEast.slantDeg).toBeLessThan(0);
expect(fromWest.slantDeg).toBeCloseTo(-fromEast.slantDeg, 5);
});
it('never leans past the clamp, however hard it blows', () => {
const gale = precipitationSpec(
weather({ precipitationMmH: 5, temperatureC: 9, windDirectionDeg: 270, windSpeedMs: 90 }),
);
expect(gale.slantDeg).toBeLessThanOrEqual(62);
});
it('drops snow far more slowly than rain', () => {
const rain = precipitationSpec(weather({ precipitationMmH: 3, temperatureC: 9 }));
const snow = precipitationSpec(weather({ precipitationMmH: 3, temperatureC: -4 }));
expect(snow.speedPxPerSecond).toBeLessThan(rain.speedPxPerSecond / 4);
});
});
+189
View File
@@ -0,0 +1,189 @@
import type { LocalWeather } from './weatherField';
/**
* 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.
*/
export interface SkyState {
/** Degrees above the horizon; negative once the sun has set. */
sunElevationDeg: number;
/** Colour of the wash over the map. */
tint: number;
/** How strongly that wash is applied, 0..1. */
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;
}
export type PrecipitationKind = 'none' | 'rain' | 'snow';
export interface PrecipitationSpec {
kind: PrecipitationKind;
/** Particles across a 1280×720 viewport; the layer scales this by actual area. */
density: number;
/** Fall angle in degrees away from vertical. Positive drifts to the right of the screen. */
slantDeg: number;
/** Screen pixels per second. */
speedPxPerSecond: number;
}
/** Matches WeatherModel.FullCoverDepthMm: the depth at which the ground reads as fully covered. */
export const FULL_SNOW_COVER_MM = 120;
/** Below this the sky is clear enough that nothing is really falling. */
const PRECIPITATION_FLOOR_MMH = 0.05;
/** Rain below this is sleet or snow. Matches the server's classification threshold. */
const FREEZING_C = 0.5;
const DAYS_PER_YEAR = 365.2425;
/**
* The wash at a given sun elevation, warm through sunset and cold through the night. Ordered from high sun
* to deep night; anything between two stops is interpolated.
*/
const STOPS: readonly { elevation: number; tint: number; alpha: number }[] = [
{ elevation: 12, tint: 0xfff4e0, alpha: 0.0 },
{ elevation: 3, tint: 0xffb877, alpha: 0.16 },
{ elevation: 0, tint: 0xff9152, alpha: 0.26 },
{ elevation: -6, tint: 0x4a4f8c, alpha: 0.46 },
{ elevation: -18, tint: 0x101c38, alpha: 0.66 },
];
/** Day of the year, 1 for 1 January, counting the fraction elapsed so the sun moves smoothly. */
function dayOfYear(date: Date): number {
const startOfYear = new Date(date.getFullYear(), 0, 1);
return (date.getTime() - startOfYear.getTime()) / 86_400_000 + 1;
}
/**
* Solar elevation from the standard declination and hour-angle formulae. Game time is treated as local
* solar time, which is what the in-world calendar already pretends to be.
*/
export function sunElevationDeg(gameTime: Date, latitude: number): number {
const declination = 23.44 * Math.sin((2 * Math.PI * (dayOfYear(gameTime) - 81)) / DAYS_PER_YEAR);
const hours = gameTime.getHours() + gameTime.getMinutes() / 60 + gameTime.getSeconds() / 3600;
const hourAngle = 15 * (hours - 12);
const toRadians = Math.PI / 180;
const phi = latitude * toRadians;
const delta = declination * toRadians;
const angle = hourAngle * toRadians;
const sine = Math.sin(phi) * Math.sin(delta) + Math.cos(phi) * Math.cos(delta) * Math.cos(angle);
return (Math.asin(clamp(sine, -1, 1)) * 180) / Math.PI;
}
/**
* 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
* still has to feel like dusk.
*/
export function skyState(
gameTime: Date,
latitude: number,
weather: LocalWeather,
alreadyDark: boolean,
): SkyState {
const elevation = sunElevationDeg(gameTime, latitude);
const base = interpolateStops(elevation);
// 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);
const cloudAlpha = cloud * 0.16 * daylight;
const alpha = (base.alpha + cloudAlpha) * (alreadyDark ? 0.45 : 1);
return {
sunElevationDeg: elevation,
tint,
tintAlpha: clamp01(alpha),
hazeAlpha: hazeFor(weather),
snowCover: clamp01(weather.snowDepthMm / FULL_SNOW_COVER_MM),
};
}
/** Fog and heavy snow both wash the scene out; rain barely does. */
function hazeFor(weather: LocalWeather): number {
if (weather.condition === 'fog') return 0.5;
if (weather.condition === 'blizzard') return 0.42;
if (weather.condition === 'sandstorm') return 0.38;
if (weather.condition === 'heavySnow') return 0.24;
return 0;
}
/** What is falling and how hard, ready for the particle layer. */
export function precipitationSpec(weather: LocalWeather): PrecipitationSpec {
if (weather.precipitationMmH < PRECIPITATION_FLOOR_MMH) {
return { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
}
const snowing = weather.temperatureC < FREEZING_C;
// 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);
const drift = Math.sin(towards) * weather.windSpeedMs;
const slantDeg = clamp(drift * (snowing ? 1.8 : 3.2), -62, 62);
if (snowing) {
return {
kind: 'snow',
density: Math.min(weather.precipitationMmH * 70, 420),
slantDeg,
speedPxPerSecond: 70 + (weather.windSpeedMs * 9),
};
}
return {
kind: 'rain',
density: Math.min(weather.precipitationMmH * 55, 520),
slantDeg,
speedPxPerSecond: 780 + (weather.precipitationMmH * 55),
};
}
function interpolateStops(elevation: number): { tint: number; alpha: number } {
const first = STOPS[0]!;
if (elevation >= first.elevation) return { tint: first.tint, alpha: first.alpha };
const last = STOPS[STOPS.length - 1]!;
if (elevation <= last.elevation) return { tint: last.tint, alpha: last.alpha };
for (let i = 1; i < STOPS.length; i++) {
const upper = STOPS[i - 1]!;
const lower = STOPS[i]!;
if (elevation > lower.elevation) {
const t = (upper.elevation - elevation) / (upper.elevation - lower.elevation);
return {
tint: mix(upper.tint, lower.tint, t),
alpha: upper.alpha + ((lower.alpha - upper.alpha) * t),
};
}
}
return { tint: last.tint, alpha: last.alpha };
}
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
function clamp01(value: number): number {
return clamp(value, 0, 1);
}
function mix(from: number, to: number, t: number): number {
const amount = clamp01(t);
const r = Math.round((((from >> 16) & 0xff) * (1 - amount)) + (((to >> 16) & 0xff) * amount));
const g = Math.round((((from >> 8) & 0xff) * (1 - amount)) + (((to >> 8) & 0xff) * amount));
const b = Math.round(((from & 0xff) * (1 - amount)) + ((to & 0xff) * amount));
return (r << 16) | (g << 8) | b;
}
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import type { Weather, WeatherCondition, WeatherField } from '../api/types';
import { 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('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);
});
});
@@ -0,0 +1,107 @@
import type { 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,
};
}
/**
* 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;
}
@@ -0,0 +1,193 @@
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;
/** A hard ceiling on particles, whatever the screen size — the whole layer redraws every frame. */
const MAX_PARTICLES = 600;
interface Particle {
x: number;
y: number;
/** 0.6..1.4, so the fall has depth instead of moving as one sheet. */
scale: number;
/** Phase for the sideways sway that makes snow drift rather than fall straight. */
sway: number;
}
/**
* 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, so panning does not drag the weather along.
*/
export class WeatherLayer {
/** Sits above the map but below the place names, which stay readable through it. */
readonly sky = new Container();
/** Sits above everything — rain falls in front of the labels too. */
readonly precipitation = new Container();
private readonly wash = new Graphics();
private readonly drops = new Graphics();
private readonly particles: Particle[] = [];
private width = 0;
private height = 0;
private state: SkyState = { sunElevationDeg: 90, tint: 0xffffff, tintAlpha: 0, hazeAlpha: 0, snowCover: 0 };
private spec: PrecipitationSpec = { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
private washDirty = true;
constructor() {
this.sky.addChild(this.wash);
this.precipitation.addChild(this.drops);
this.sky.eventMode = 'none';
this.precipitation.eventMode = 'none';
}
resize(width: number, height: number): void {
if (this.width === width && this.height === height) return;
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): 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.resizePool();
}
/** Steps the falling particles and repaints. Called once per frame. */
advance(deltaMs: number): void {
if (this.washDirty) {
this.paintWash();
this.washDirty = false;
}
this.stepParticles(deltaMs);
this.paintParticles();
}
clear(): void {
this.particles.length = 0;
this.wash.clear();
this.drops.clear();
this.state = { sunElevationDeg: 90, tint: 0xffffff, tintAlpha: 0, hazeAlpha: 0, snowCover: 0 };
this.spec = { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
}
destroy(): void {
this.sky.destroy({ children: true });
this.precipitation.destroy({ children: true });
}
private paintWash(): void {
this.wash.clear();
if (this.width === 0 || this.height === 0) return;
const { tint, tintAlpha, hazeAlpha, snowCover } = this.state;
if (tintAlpha > 0.001) {
this.wash.rect(0, 0, this.width, this.height).fill({ color: tint, alpha: tintAlpha });
}
// 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 });
}
if (hazeAlpha > 0.001) {
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xd7dce2, alpha: hazeAlpha });
}
}
/** Grows or trims the pool to the density the current weather asks for. */
private resizePool(): void {
const target = this.targetCount();
while (this.particles.length > target) this.particles.pop();
while (this.particles.length < target) this.particles.push(this.spawn(true));
}
private targetCount(): number {
if (this.spec.kind === 'none' || this.width === 0 || this.height === 0) return 0;
const scaled = (this.spec.density * this.width * this.height) / REFERENCE_AREA;
return Math.min(Math.round(scaled), MAX_PARTICLES);
}
/** `anywhere` seeds a new pool across the screen; otherwise the particle re-enters from the top. */
private spawn(anywhere: boolean): Particle {
return {
x: Math.random() * this.width,
y: anywhere ? Math.random() * this.height : -20,
scale: 0.6 + (Math.random() * 0.8),
sway: Math.random() * Math.PI * 2,
};
}
private stepParticles(deltaMs: number): void {
if (this.particles.length === 0) return;
const seconds = deltaMs / 1000;
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
const snowing = this.spec.kind === 'snow';
for (const particle of this.particles) {
const fall = this.spec.speedPxPerSecond * particle.scale * seconds;
particle.y += fall;
particle.x += fall * slant;
if (snowing) {
// Snow wanders as it comes down; rain is too heavy to bother.
particle.sway += seconds * 1.6;
particle.x += Math.sin(particle.sway) * 18 * seconds;
}
if (particle.y > this.height + 20) {
Object.assign(particle, this.spawn(false));
} else if (particle.x < -40) {
particle.x += this.width + 80;
} else if (particle.x > this.width + 40) {
particle.x -= this.width + 80;
}
}
}
private paintParticles(): void {
this.drops.clear();
if (this.particles.length === 0) return;
if (this.spec.kind === 'snow') {
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;
}
// One path for every drop, stroked once: Pixi batches the whole thing into a single draw.
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
const length = 9 + (this.spec.speedPxPerSecond / 90);
for (const particle of this.particles) {
const drop = length * particle.scale;
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (drop * slant), particle.y + drop);
}
this.drops.stroke({ width: 1.1, color: 0xaec6dd, alpha: 0.55 });
}
}
+32 -2
View File
@@ -164,6 +164,20 @@ body {
white-space: nowrap;
}
.sim-controls__weather {
padding-left: 10px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
white-space: nowrap;
border-left: 1px solid var(--panel-border);
}
.sim-controls__weather:empty {
padding-left: 0;
border-left: none;
}
.sim-controls__speeds {
display: flex;
gap: 2px;
@@ -324,7 +338,8 @@ body {
.field input[type='text'],
.field input[type='number'],
.field input[type='datetime-local'] {
.field input[type='datetime-local'],
.field select {
padding: 7px 9px;
font: inherit;
font-size: 13px;
@@ -334,11 +349,26 @@ body {
border-radius: 7px;
}
.field input:focus-visible {
.field select:disabled {
opacity: 0.6;
}
.field input:focus-visible,
.field select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.field__hint {
font-size: 11px;
line-height: 1.4;
color: var(--text-muted);
}
.field__hint:empty {
display: none;
}
.field input[type='range'] {
accent-color: var(--accent);
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import type { ClimateOption } from '../api/types';
import { climateFromLatitude, describeClimate, findClimate } from './climate';
/** Shaped like the real GET /api/climates payload: equator first, unbanded presets mixed in. */
const OPTIONS: ClimateOption[] = [
{ kind: 'equatorial', label: 'Equatorial', koppenCode: 'Af', example: 'Singapore', bandLimit: 10 },
{ kind: 'tropicalMonsoon', label: 'Tropical monsoon', koppenCode: 'Am', example: 'Mumbai' },
{ kind: 'savanna', label: 'Savanna', koppenCode: 'Aw', example: 'Nairobi', bandLimit: 20 },
{ kind: 'hotDesert', label: 'Hot desert', koppenCode: 'BWh', example: 'Cairo', bandLimit: 33 },
{ kind: 'coldSteppe', label: 'Cold steppe', koppenCode: 'BSk', example: 'Astana' },
{ kind: 'mediterranean', label: 'Mediterranean', koppenCode: 'Csa', example: 'Barcelona', bandLimit: 41 },
{ kind: 'humidSubtropical', label: 'Humid subtropical', koppenCode: 'Cfa', example: 'Tokyo', bandLimit: 48 },
{ kind: 'oceanic', label: 'Oceanic', koppenCode: 'Cfb', example: 'London', bandLimit: 54 },
{ kind: 'centralEuropean', label: 'Central European', koppenCode: 'Dfb', example: 'Warsaw', bandLimit: 62 },
{ kind: 'siberian', label: 'Siberian', koppenCode: 'Dfc', example: 'Yakutsk', bandLimit: 70 },
{ kind: 'tundra', label: 'Tundra', koppenCode: 'ET', example: 'Murmansk', bandLimit: 90.1 },
{ kind: 'highland', label: 'Highland', koppenCode: 'H', example: 'La Paz' },
];
describe('climateFromLatitude', () => {
it('reproduces the server bands', () => {
expect(climateFromLatitude(OPTIONS, 1.35)).toBe('equatorial');
expect(climateFromLatitude(OPTIONS, 13.75)).toBe('savanna');
expect(climateFromLatitude(OPTIONS, 30.05)).toBe('hotDesert');
expect(climateFromLatitude(OPTIONS, 37.98)).toBe('mediterranean');
expect(climateFromLatitude(OPTIONS, 51.51)).toBe('oceanic');
expect(climateFromLatitude(OPTIONS, 55.75)).toBe('centralEuropean');
expect(climateFromLatitude(OPTIONS, 62.03)).toBe('siberian');
expect(climateFromLatitude(OPTIONS, 78.22)).toBe('tundra');
});
it('ignores the hemisphere', () => {
expect(climateFromLatitude(OPTIONS, -33.87)).toBe(climateFromLatitude(OPTIONS, 33.87));
expect(climateFromLatitude(OPTIONS, -78.22)).toBe('tundra');
});
it('skips the presets the server never guesses', () => {
const guessed = new Set<string | null>();
for (let degrees = 0; degrees <= 90; degrees += 0.5) {
guessed.add(climateFromLatitude(OPTIONS, degrees));
}
expect(guessed.has('tropicalMonsoon')).toBe(false);
expect(guessed.has('coldSteppe')).toBe(false);
expect(guessed.has('highland')).toBe(false);
});
it('falls back to the last band at the pole', () => {
expect(climateFromLatitude(OPTIONS, 90)).toBe('tundra');
});
it('returns null when it has nothing to work with', () => {
expect(climateFromLatitude(OPTIONS, Number.NaN)).toBeNull();
expect(climateFromLatitude([], 50)).toBeNull();
// A catalogue with no bands at all cannot guess anything.
expect(climateFromLatitude([OPTIONS[11]!], 50)).toBeNull();
});
});
describe('describeClimate', () => {
it('names the preset, its code and a place it feels like', () => {
expect(describeClimate(OPTIONS[8]!)).toBe('Central European (Dfb) · like Warsaw');
});
});
describe('findClimate', () => {
it('looks a preset up by kind', () => {
expect(findClimate(OPTIONS, 'siberian')?.example).toBe('Yakutsk');
expect(findClimate(OPTIONS, null)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { ClimateKind, ClimateOption } from '../api/types';
/**
* Reproduces the server's latitude guess from the band limits it sent us, so the create form can preview the
* default without keeping a second copy of the table that would quietly drift out of step.
*
* Options must arrive equator-first, which is the order `GET /api/climates` uses.
*/
export function climateFromLatitude(
options: readonly ClimateOption[],
latitude: number,
): ClimateKind | null {
if (!Number.isFinite(latitude)) return null;
const band = Math.abs(latitude);
const banded = options.filter((option) => option.bandLimit !== undefined && option.bandLimit !== null);
if (banded.length === 0) return null;
for (const option of banded) {
if (band < option.bandLimit!) return option.kind;
}
return banded[banded.length - 1]!.kind;
}
/** `Central European (Dfb) · like Warsaw` — enough to pick from without reading a table of numbers. */
export function describeClimate(option: ClimateOption): string {
return `${option.label} (${option.koppenCode}) · like ${option.example}`;
}
export function findClimate(
options: readonly ClimateOption[],
kind: ClimateKind | null,
): ClimateOption | null {
if (!kind) return null;
return options.find((option) => option.kind === kind) ?? null;
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
formatGameTime,
formatGameTimeRaw,
formatWeekday,
GAME_MINUTES_PER_REAL_SECOND,
interpolateGameTime,
parseGameTime,
@@ -41,6 +42,22 @@ describe('formatGameTime', () => {
it('formats a raw API string', () => {
expect(formatGameTimeRaw('2012-04-12T06:00:00')).toBe('12 April 2012 · 06:00');
});
it('prefixes the short weekday on request', () => {
// 12 April 2012 was a Thursday.
const date = new Date(2012, 3, 12, 6, 0, 0);
expect(formatGameTime(date, { weekday: true })).toBe('Thu 12 April 2012 · 06:00');
expect(formatGameTimeRaw('2012-04-12T06:00:00', { weekday: true })).toBe('Thu 12 April 2012 · 06:00');
expect(formatWeekday(date)).toBe('Thursday');
});
it('names every day of the week', () => {
// A full week starting on Sunday 8 April 2012.
const names = Array.from({ length: 7 }, (_, offset) => formatWeekday(new Date(2012, 3, 8 + offset)));
expect(names).toEqual([
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
]);
});
});
describe('interpolateGameTime', () => {
+22 -5
View File
@@ -12,6 +12,13 @@ const MONTHS = [
'July', 'August', 'September', 'October', 'November', 'December',
] as const;
/** Indexed by `Date.getDay()`, which counts from Sunday. */
const WEEKDAYS = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
] as const;
const SHORT_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
/**
* Parses a naive game datetime from the API (`2012-04-12T06:00:00` or with fractional seconds).
* Treats the value as a local calendar instant, not UTC.
@@ -42,19 +49,29 @@ export function parseGameTime(raw: string): Date | null {
return date;
}
/** Formats as `12 April 2012 · 06:00` (no seconds — they are meaningless at 5 min/s). */
export function formatGameTime(date: Date): string {
/**
* Formats as `12 April 2012 · 06:00` (no seconds — they are meaningless at 5 min/s).
* Pass `weekday` for `Thu 12 April 2012 · 06:00`; the day of the week matters in game but only clutters
* the world list, so it is opt-in rather than always on.
*/
export function formatGameTime(date: Date, options?: { weekday?: boolean }): string {
const day = date.getDate();
const month = MONTHS[date.getMonth()]!;
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day} ${month} ${year} · ${hours}:${minutes}`;
const prefix = options?.weekday ? `${SHORT_WEEKDAYS[date.getDay()]!} ` : '';
return `${prefix}${day} ${month} ${year} · ${hours}:${minutes}`;
}
export function formatGameTimeRaw(raw: string): string {
/** Full weekday name, for the tooltip where there is room for it. */
export function formatWeekday(date: Date): string {
return WEEKDAYS[date.getDay()]!;
}
export function formatGameTimeRaw(raw: string, options?: { weekday?: boolean }): string {
const date = parseGameTime(raw);
return date ? formatGameTime(date) : raw;
return date ? formatGameTime(date, options) : raw;
}
/**
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import type { Weather } from '../api/types';
import {
conditionIcon,
conditionLabel,
describeWeather,
formatTemperature,
formatWeather,
formatWind,
windCompass,
} from './weather';
function weather(overrides: Partial<Weather> = {}): Weather {
return {
condition: 'cloudy',
temperatureC: 12.4,
feelsLikeC: 12.4,
pressureHpa: 1008.2,
humidity: 0.71,
cloudCover: 0.62,
precipitationMmH: 0,
windSpeedMs: 5.2,
windDirectionDeg: 270,
snowDepthMm: 0,
...overrides,
};
}
describe('formatTemperature', () => {
it('rounds to whole degrees', () => {
expect(formatTemperature(12.4)).toBe('12 °C');
expect(formatTemperature(-7.6)).toBe('-8 °C');
});
it('never renders a negative zero', () => {
// Math.round(-0.4) is -0, which stringifies as "-0" and looks like a bug just below freezing.
expect(formatTemperature(-0.4)).toBe('0 °C');
});
it('copes with a missing reading', () => {
expect(formatTemperature(Number.NaN)).toBe('—');
});
});
describe('windCompass', () => {
it('maps bearings to the sixteen-point compass', () => {
expect(windCompass(0)).toBe('N');
expect(windCompass(90)).toBe('E');
expect(windCompass(180)).toBe('S');
expect(windCompass(270)).toBe('W');
expect(windCompass(45)).toBe('NE');
expect(windCompass(22.5)).toBe('NNE');
});
it('wraps past a full turn and back through zero', () => {
expect(windCompass(360)).toBe('N');
expect(windCompass(359)).toBe('N');
expect(windCompass(-90)).toBe('W');
});
});
describe('conditionLabel and conditionIcon', () => {
it('reads every condition the server can send', () => {
const conditions: Weather['condition'][] = [
'clear', 'fewClouds', 'cloudy', 'overcast', 'fog', 'drizzle', 'rain', 'heavyRain',
'thunderstorm', 'sleet', 'snow', 'heavySnow', 'blizzard', 'sandstorm',
];
for (const condition of conditions) {
expect(conditionLabel(condition)).not.toBe('');
expect(conditionIcon(condition)).not.toBe('');
}
});
});
describe('formatWeather', () => {
it('leaves out the apparent temperature when it matches the real one', () => {
const line = formatWeather(weather());
expect(line).toContain('Cloudy');
expect(line).toContain('12 °C');
expect(line).not.toContain('feels');
});
it('shows the apparent temperature once it diverges', () => {
const line = formatWeather(weather({ temperatureC: -6, feelsLikeC: -14, windSpeedMs: 12 }));
expect(line).toContain('feels -14 °C');
});
it('reports the wind as a compass bearing and a speed', () => {
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('W 5 m/s');
});
});
describe('describeWeather', () => {
it('mentions precipitation only when something is falling', () => {
expect(describeWeather(weather())).not.toContain('Precipitation');
expect(describeWeather(weather({ precipitationMmH: 2.4 }))).toContain('Precipitation 2.4 mm/h');
});
it('renders the fractions as percentages', () => {
const detail = describeWeather(weather({ humidity: 0.71, cloudCover: 0.62 }));
expect(detail).toContain('Humidity 71%');
expect(detail).toContain('Cloud 62%');
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { Weather, WeatherCondition } from '../api/types';
const CONDITION_LABELS: Record<WeatherCondition, string> = {
clear: 'Clear',
fewClouds: 'Few clouds',
cloudy: 'Cloudy',
overcast: 'Overcast',
fog: 'Fog',
drizzle: 'Drizzle',
rain: 'Rain',
heavyRain: 'Heavy rain',
thunderstorm: 'Thunderstorm',
sleet: 'Sleet',
snow: 'Snow',
heavySnow: 'Heavy snow',
blizzard: 'Blizzard',
sandstorm: 'Sandstorm',
};
const CONDITION_ICONS: Record<WeatherCondition, string> = {
clear: '☀',
fewClouds: '🌤',
cloudy: '⛅',
overcast: '☁',
fog: '🌫',
drizzle: '🌦',
rain: '🌧',
heavyRain: '🌧',
thunderstorm: '⛈',
sleet: '🌨',
snow: '❄',
heavySnow: '❄',
blizzard: '🌬',
sandstorm: '🌪',
};
const COMPASS = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW',
] as const;
export function conditionLabel(condition: WeatherCondition): string {
return CONDITION_LABELS[condition] ?? condition;
}
export function conditionIcon(condition: WeatherCondition): string {
return CONDITION_ICONS[condition] ?? '';
}
/** Whole degrees Celsius. Never renders `-0`, which is what plain rounding produces just below freezing. */
export function formatTemperature(celsius: number): string {
if (!Number.isFinite(celsius)) return '—';
const rounded = Math.round(celsius);
return `${Object.is(rounded, -0) ? 0 : rounded} °C`;
}
/** Turns a bearing into the sixteen-point compass name of where the wind comes from. */
export function windCompass(degrees: number): string {
if (!Number.isFinite(degrees)) return '—';
const normalised = ((degrees % 360) + 360) % 360;
return COMPASS[Math.round(normalised / 22.5) % 16]!;
}
export function formatWind(weather: Weather): string {
return `${windCompass(weather.windDirectionDeg)} ${Math.round(weather.windSpeedMs)} m/s`;
}
/**
* The one-line summary for the HUD: icon, condition and temperature, with the apparent temperature only
* when it actually differs from the real one.
*/
export function formatWeather(weather: Weather): string {
const icon = conditionIcon(weather.condition);
const parts = [`${icon} ${conditionLabel(weather.condition)}`, formatTemperature(weather.temperatureC)];
if (Math.abs(weather.feelsLikeC - weather.temperatureC) >= 1.5) {
parts.push(`feels ${formatTemperature(weather.feelsLikeC)}`);
}
parts.push(formatWind(weather));
return parts.join(' · ');
}
/** Tooltip detail, for the numbers that do not earn a place in the HUD line. */
export function describeWeather(weather: Weather): string {
return [
`${conditionLabel(weather.condition)} ${formatTemperature(weather.temperatureC)}`,
`Feels like ${formatTemperature(weather.feelsLikeC)}`,
`Wind ${formatWind(weather)}`,
`Humidity ${Math.round(weather.humidity * 100)}%`,
`Cloud ${Math.round(weather.cloudCover * 100)}%`,
`Pressure ${Math.round(weather.pressureHpa)} hPa`,
weather.precipitationMmH > 0 ? `Precipitation ${weather.precipitationMmH.toFixed(1)} mm/h` : null,
]
.filter((line): line is string => line !== null)
.join('\n');
}