Enhance internationalization support across UI components; integrate i18n for dynamic text rendering in menus, forms, and HUD elements. Update climate and weather modules to utilize localized labels and descriptions, ensuring a consistent user experience in multiple languages. Refactor related tests to validate translations and locale settings.

This commit is contained in:
Leonid Pershin
2026-08-17 10:58:02 +03:00
parent 062ab497db
commit 0926ec879c
18 changed files with 1112 additions and 128 deletions
+3
View File
@@ -80,6 +80,9 @@ ids on first use without a lock, and two threads racing there hand out the same
Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`. Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
UI copy lives in `src/TheLivingWorld.Web/src/i18n/locales/`. `en.ts` is the schema; add a language by
copying it and registering the file in `locales.ts`. The language switcher is built from that registry.
## Working conventions ## Working conventions
- Prefer small, focused diffs. Do not rewrite unrelated code or add docs the user did not ask for. - Prefer small, focused diffs. Do not rewrite unrelated code or add docs the user did not ask for.
+22 -17
View File
@@ -11,16 +11,19 @@
<div class="menu"> <div class="menu">
<header class="menu__header"> <header class="menu__header">
<div class="menu__titles"> <div class="menu__titles">
<h1>The Living World</h1> <h1 data-i18n="app.title">The Living World</h1>
<p class="menu__subtitle">Generate a world from OpenStreetMap</p> <p class="menu__subtitle" data-i18n="app.subtitle">Generate a world from OpenStreetMap</p>
</div> </div>
<div class="menu__actions">
<div id="locale-switch" class="locale-switch"></div>
<button id="menu-theme-toggle" type="button" class="theme-button" title="Switch theme"> <button id="menu-theme-toggle" type="button" class="theme-button" title="Switch theme">
Night Night
</button> </button>
</div>
</header> </header>
<button id="continue-button" type="button" class="continue" hidden> <button id="continue-button" type="button" class="continue" hidden>
<span class="continue__kicker">Continue</span> <span class="continue__kicker" data-i18n="menu.continue">Continue</span>
<span id="continue-name" class="continue__name"></span> <span id="continue-name" class="continue__name"></span>
<span class="continue__arrow" aria-hidden="true"></span> <span class="continue__arrow" aria-hidden="true"></span>
</button> </button>
@@ -28,27 +31,27 @@
<div class="menu__body"> <div class="menu__body">
<section class="worlds"> <section class="worlds">
<div class="worlds__header"> <div class="worlds__header">
<h2>Worlds</h2> <h2 data-i18n="menu.worlds">Worlds</h2>
<span id="slot-count" class="slot-count"></span> <span id="slot-count" class="slot-count"></span>
</div> </div>
<p id="worlds-empty" class="worlds__empty" hidden> <p id="worlds-empty" class="worlds__empty" hidden data-i18n="menu.worldsEmpty">
Worlds you generate will show up here. A small town takes a few seconds. Worlds you generate will show up here. A small town takes a few seconds.
</p> </p>
<ul id="world-list" class="world-list"></ul> <ul id="world-list" class="world-list"></ul>
</section> </section>
<form id="generate-form" class="form"> <form id="generate-form" class="form">
<h2 class="form__title">New world</h2> <h2 class="form__title" data-i18n="form.newWorld">New world</h2>
<label class="field"> <label class="field">
<span>Name</span> <span data-i18n="form.name">Name</span>
<input id="field-name" type="text" placeholder="Optional — defaults to the coordinates" autocomplete="off" /> <input id="field-name" type="text" data-i18n-placeholder="form.namePlaceholder" placeholder="Optional — defaults to the coordinates" autocomplete="off" />
</label> </label>
<label class="field"> <label class="field">
<span class="field__label"> <span class="field__label">
Location <span data-i18n="form.location">Location</span>
<button id="use-location" type="button" class="link-button">Use my location</button> <button id="use-location" type="button" class="link-button" data-i18n="form.useMyLocation">Use my location</button>
</span> </span>
<input <input
id="field-coords" id="field-coords"
@@ -58,18 +61,19 @@
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
value="31.8966010, -100.4858591" value="31.8966010, -100.4858591"
data-i18n-placeholder="form.coordsPlaceholder"
placeholder="Latitude, longitude" placeholder="Latitude, longitude"
required required
/> />
</label> </label>
<label class="field"> <label class="field">
<span>Size <output id="field-size-value">10 km</output></span> <span><span data-i18n="form.size">Size</span> <output id="field-size-value">10 km</output></span>
<input id="field-size" type="range" min="1" max="20" step="1" value="10" /> <input id="field-size" type="range" min="1" max="20" step="1" value="10" />
</label> </label>
<label class="field"> <label class="field">
<span>Start date &amp; time</span> <span data-i18n="form.start">Start date &amp; time</span>
<input <input
id="field-start" id="field-start"
type="datetime-local" type="datetime-local"
@@ -79,15 +83,15 @@
</label> </label>
<label class="field"> <label class="field">
<span>Climate</span> <span data-i18n="form.climate">Climate</span>
<select id="field-climate"> <select id="field-climate">
<option value="">From the location</option> <option value="" data-i18n="form.climateFromLocation">From the location</option>
</select> </select>
<small id="climate-hint" class="field__hint"></small> <small id="climate-hint" class="field__hint"></small>
</label> </label>
<p id="form-hint" class="form__hint" hidden></p> <p id="form-hint" class="form__hint" hidden></p>
<button id="generate-button" type="submit" class="button">Generate world</button> <button id="generate-button" type="submit" class="button" data-i18n="form.generate">Generate world</button>
</form> </form>
</div> </div>
@@ -99,7 +103,7 @@
<div id="stage"></div> <div id="stage"></div>
<header class="game-bar"> <header class="game-bar">
<button id="back-button" type="button" class="icon-button" title="Back to menu" aria-label="Back to menu"> <button id="back-button" type="button" class="icon-button" data-i18n-title="game.back" data-i18n-aria="game.back" title="Back to menu" aria-label="Back to menu">
</button> </button>
<span id="world-title" class="game-bar__title"></span> <span id="world-title" class="game-bar__title"></span>
@@ -113,7 +117,7 @@
title="Pause" title="Pause"
aria-label="Pause" aria-label="Pause"
></button> ></button>
<div class="sim-controls__speeds" role="group" aria-label="Simulation speed"> <div class="sim-controls__speeds" role="group" data-i18n-aria="game.speed" aria-label="Simulation speed">
<button type="button" class="speed-button" data-scale="1">x1</button> <button type="button" class="speed-button" data-scale="1">x1</button>
<button type="button" class="speed-button" data-scale="2">x2</button> <button type="button" class="speed-button" data-scale="2">x2</button>
<button type="button" class="speed-button" data-scale="3">x3</button> <button type="button" class="speed-button" data-scale="3">x3</button>
@@ -125,6 +129,7 @@
type="button" type="button"
class="icon-button" class="icon-button"
title="Hide weather effects" title="Hide weather effects"
data-i18n-aria="game.weatherAria"
aria-label="Weather effects" aria-label="Weather effects"
aria-pressed="true" aria-pressed="true"
>🌦</button> >🌦</button>
+62
View File
@@ -0,0 +1,62 @@
import { getLocale, LOCALES, localeList, setLocale, t } from './index';
/**
* Fills `[data-i18n]`, `[data-i18n-placeholder]`, `[data-i18n-title]` and `[data-i18n-aria]`
* from the active catalog. Put those attributes on leaf nodes only — `textContent`
* would wipe nested markup.
*/
export function applyDomTranslations(): void {
if (typeof document === 'undefined') return;
document.title = t('app.title');
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n]')) {
const key = element.dataset.i18n;
if (key) element.textContent = t(key as Parameters<typeof t>[0]);
}
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n-placeholder]')) {
const key = element.dataset.i18nPlaceholder;
if (key) element.setAttribute('placeholder', t(key as Parameters<typeof t>[0]));
}
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n-title]')) {
const key = element.dataset.i18nTitle;
if (key) element.title = t(key as Parameters<typeof t>[0]);
}
for (const element of document.querySelectorAll<HTMLElement>('[data-i18n-aria]')) {
const key = element.dataset.i18nAria;
if (key) element.setAttribute('aria-label', t(key as Parameters<typeof t>[0]));
}
}
/** Builds EN/RU (and any later locale) buttons from the registry. */
export function mountLocaleSwitch(container: HTMLElement): void {
container.replaceChildren();
container.setAttribute('role', 'group');
for (const locale of localeList()) {
const meta = LOCALES[locale];
const button = document.createElement('button');
button.type = 'button';
button.className = 'locale-button';
button.dataset.locale = locale;
button.textContent = meta.code;
button.title = meta.name;
button.addEventListener('click', () => {
setLocale(locale);
});
container.append(button);
}
paintLocaleSwitch(container);
}
export function paintLocaleSwitch(container: HTMLElement): void {
container.setAttribute('aria-label', t('locale.switch'));
const active = getLocale();
for (const button of container.querySelectorAll<HTMLButtonElement>('.locale-button')) {
button.setAttribute('aria-pressed', button.dataset.locale === active ? 'true' : 'false');
}
}
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'vitest';
import { catalog, detectLocale, getLocale, setLocale, t } from './index';
import { translateStage } from './stage';
import { localeList } from './locales';
afterEach(() => {
setLocale('en');
});
describe('detectLocale', () => {
it('picks the first registered language the browser offers', () => {
expect(detectLocale(['ru-RU', 'en-US'])).toBe('ru');
expect(detectLocale(['en-GB'])).toBe('en');
expect(detectLocale(['de-DE', 'ru'])).toBe('ru');
expect(detectLocale(['de-DE', 'fr'])).toBe('en');
expect(detectLocale([])).toBe('en');
});
});
describe('t', () => {
it('returns English by default', () => {
expect(getLocale()).toBe('en');
expect(t('menu.continue')).toBe('Continue');
expect(t('menu.openWorld', { name: 'Austin' })).toBe('Open Austin');
});
it('switches the whole catalog', () => {
setLocale('ru');
expect(t('menu.continue')).toBe('Продолжить');
expect(t('menu.openWorld', { name: 'Austin' })).toBe('Открыть Austin');
expect(t('weather.condition.cloudy')).toBe('Облачно');
});
it('selects English and Russian plural forms', () => {
expect(t('world.buildings', { n: 1 })).toBe('1 building');
expect(t('world.buildings', { n: 2 })).toBe('2 buildings');
setLocale('ru');
expect(t('world.buildings', { n: 1 })).toBe('1 здание');
expect(t('world.buildings', { n: 2 })).toBe('2 здания');
expect(t('world.buildings', { n: 5 })).toBe('5 зданий');
expect(t('world.buildings', { n: 21 })).toBe('21 здание');
});
it('leaves unknown keys as the key', () => {
expect(t('menu.continue'.replace('continue', 'missing') as 'menu.continue')).toBe('menu.missing');
});
});
describe('catalogs', () => {
it('registers en and ru', () => {
expect(localeList()).toEqual(['en', 'ru']);
});
it('keeps climate kinds aligned with the English labels the API still sends', () => {
expect(catalog().climate.kind.centralEuropean).toBe('Central European');
setLocale('ru');
expect(catalog().climate.kind.centralEuropean).toBe('Центральноевропейский');
});
});
describe('translateStage', () => {
it('translates the fixed progress lines', () => {
expect(translateStage('Queued')).toBe('Queued');
expect(translateStage('Slicing into chunks')).toBe('Slicing into chunks');
setLocale('ru');
expect(translateStage('Queued')).toBe('В очереди');
expect(translateStage('Starting')).toBe('Запуск');
expect(translateStage('Using cached OpenStreetMap data')).toBe('Кэш OpenStreetMap');
});
it('rewrites the counted lines in the active locale', () => {
expect(translateStage('Writing 12 chunks')).toBe('Writing 12 chunks');
setLocale('ru');
expect(translateStage('Writing 12 chunks')).toBe('Запись 12 чанков');
expect(translateStage('Querying OpenStreetMap (overpass.kumi.systems, attempt 2)')).toBe(
'Запрос OpenStreetMap (overpass.kumi.systems, попытка 2)',
);
const imported = translateStage('Importing 1,234 elements');
expect(imported).toContain('Импорт');
expect(imported).toContain('объектов');
expect(imported).toMatch(/1\s?234/);
});
it('passes through stages it does not know', () => {
expect(translateStage('Reticulating splines')).toBe('Reticulating splines');
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { MessageKey, Messages, Plural } from './types';
import { DEFAULT_LOCALE, isLocale, LOCALES, localeList, type Locale } from './locales';
export type { Locale, MessageKey, Messages, Plural };
export { isLocale, LOCALES, localeList };
export const LOCALE_STORAGE_KEY = 'the-living-world:locale';
let current: Locale = DEFAULT_LOCALE;
const listeners = new Set<(locale: Locale) => void>();
export function getLocale(): Locale {
return current;
}
export function localeTag(): string {
return LOCALES[current].tag;
}
export function catalog(): Messages {
return LOCALES[current].messages;
}
export function subscribe(listener: (locale: Locale) => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Picks the first browser language that matches a registered locale, otherwise English. */
export function detectLocale(languages: readonly string[]): Locale {
const available = localeList();
for (const raw of languages) {
const lower = raw.toLowerCase();
for (const locale of available) {
if (lower === locale || lower.startsWith(`${locale}-`)) return locale;
}
}
return DEFAULT_LOCALE;
}
function navigatorLanguages(): string[] {
if (typeof navigator === 'undefined') return [];
if (navigator.languages?.length) return [...navigator.languages];
if (navigator.language) return [navigator.language];
return [];
}
function readStoredLocale(): Locale | null {
try {
if (typeof localStorage === 'undefined') return null;
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
return isLocale(stored) ? stored : null;
} catch {
return null;
}
}
function persist(locale: Locale): void {
try {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
} catch {
// Private mode / node tests: keep the in-memory locale.
}
}
function applyHtmlLang(locale: Locale): void {
if (typeof document === 'undefined') return;
document.documentElement.lang = LOCALES[locale].tag;
}
/**
* Resolves the locale from storage, then the browser, then English.
* Call once at startup, before painting the UI. Does not notify subscribers.
*/
export function initI18n(): Locale {
current = readStoredLocale() ?? detectLocale(navigatorLanguages());
persist(current);
applyHtmlLang(current);
return current;
}
export function setLocale(locale: Locale): void {
if (locale === current) return;
current = locale;
persist(locale);
applyHtmlLang(locale);
for (const listener of listeners) listener(locale);
}
function isPlural(value: unknown): value is Plural {
return typeof value === 'object' && value !== null && 'other' in value && 'one' in value;
}
function lookup(key: string): unknown {
let currentValue: unknown = catalog();
for (const part of key.split('.')) {
if (typeof currentValue !== 'object' || currentValue === null || !(part in currentValue)) {
return undefined;
}
currentValue = (currentValue as Record<string, unknown>)[part];
}
return currentValue;
}
function interpolate(template: string, params?: Record<string, string | number>): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
const value = params[name];
if (value === undefined) return `{${name}}`;
if (typeof value === 'number') {
return name === 'n' ? value.toLocaleString(localeTag()) : String(value);
}
return value;
});
}
/**
* Looks up a catalog string. Pass `{ n }` for plural leaves; the count `n` is
* formatted with the active locale, other numbers stringify as-is.
*/
export function t(key: MessageKey, params?: Record<string, string | number>): string {
const value = lookup(key);
if (typeof value === 'string') return interpolate(value, params);
if (isPlural(value)) {
const count = typeof params?.n === 'number' ? params.n : 0;
const category = new Intl.PluralRules(localeTag()).select(count);
const template = value[category as keyof Plural] ?? value.other;
return interpolate(template, params);
}
return key;
}
@@ -0,0 +1,23 @@
import { en } from './locales/en';
import { ru } from './locales/ru';
/**
* Registered UI languages. To add one: copy `locales/en.ts`, translate, and add an entry here.
* The language switcher is built from this map, so no HTML change is required.
*/
export const LOCALES = {
en: { messages: en, code: 'EN', name: 'English', tag: 'en' },
ru: { messages: ru, code: 'RU', name: 'Русский', tag: 'ru' },
} as const;
export type Locale = keyof typeof LOCALES;
export const DEFAULT_LOCALE: Locale = 'en';
export function isLocale(value: string | null | undefined): value is Locale {
return value !== null && value !== undefined && value in LOCALES;
}
export function localeList(): Locale[] {
return Object.keys(LOCALES) as Locale[];
}
@@ -0,0 +1,175 @@
import type { Messages } from '../types';
export const en: Messages = {
app: {
title: 'The Living World',
subtitle: 'Generate a world from OpenStreetMap',
},
locale: {
switch: 'Language',
},
theme: {
day: 'Day',
night: 'Night',
switchTo: 'Switch to {theme} theme',
},
menu: {
continue: 'Continue',
worlds: 'Worlds',
worldsEmpty: 'Worlds you generate will show up here. A small town takes a few seconds.',
lastPlayed: 'Last played',
openWorld: 'Open {name}',
deleteWorld: 'Delete {name}',
deleteConfirm: 'Delete "{name}"?',
slotsFull: 'All {n} slots are in use. Delete a world to create another.',
},
form: {
newWorld: 'New world',
name: 'Name',
namePlaceholder: 'Optional — defaults to the coordinates',
location: 'Location',
useMyLocation: 'Use my location',
coordsPlaceholder: 'Latitude, longitude',
size: 'Size',
sizeValue: '{n} km',
start: 'Start date & time',
climate: 'Climate',
climateFromLocation: 'From the location',
generate: 'Generate world',
},
climate: {
kind: {
equatorial: 'Equatorial',
tropicalMonsoon: 'Tropical monsoon',
savanna: 'Savanna',
hotDesert: 'Hot desert',
coldSteppe: 'Cold steppe',
mediterranean: 'Mediterranean',
humidSubtropical: 'Humid subtropical',
oceanic: 'Oceanic',
centralEuropean: 'Central European',
siberian: 'Siberian',
tundra: 'Tundra',
highland: 'Highland',
},
example: {
equatorial: 'Singapore',
tropicalMonsoon: 'Mumbai',
savanna: 'Nairobi',
hotDesert: 'Cairo',
coldSteppe: 'Astana',
mediterranean: 'Barcelona',
humidSubtropical: 'Tokyo',
oceanic: 'London',
centralEuropean: 'Warsaw',
siberian: 'Yakutsk',
tundra: 'Murmansk',
highland: 'La Paz',
},
describe: '{label} ({code}) · like {example}',
hintInferred: 'This location suggests {label} ({code}).',
hintNeedLocation: 'Enter a location to see what the latitude suggests.',
hintMismatch: '{label} — the latitude would have suggested {inferred}.',
hintChosen: '{label}, like {example}.',
},
status: {
pickPlace: 'Pick a place and generate a world to get started.',
continueHint: 'Continue where you left off, or open another world.',
chooseWorld: 'Choose a world or create a new one.',
loadingMap: 'Loading map…',
requestingWorld: 'Requesting world…',
findingLocation: 'Finding your location…',
locationFilled: 'Location filled in — adjust the size and generate.',
geolocationUnavailable: 'Geolocation is not available in this browser.',
invalidCoords: 'Enter a location as latitude, longitude — for example 31.90, -100.49.',
invalidStart: 'Pick a start date and time for the in-world calendar.',
generationFailed: 'Generation failed: {error}',
generationFailedUnknown: 'unknown error',
deleted: 'Deleted {name}',
couldNotOpen: 'Could not open world: {error}',
couldNotDelete: 'Could not delete world: {error}',
couldNotReachApi: 'Could not reach the API: {error}',
couldNotUpdateClock: 'Could not update clock: {error}',
couldNotGetLocation: 'Could not get location: {error}',
worldProgress: '{name}: {stage}',
generating: 'Generating',
failed: 'Failed',
},
worldStatus: {
pending: 'Pending',
generating: 'Generating',
ready: 'Ready',
failed: 'Failed',
},
world: {
buildings: { one: '{n} building', other: '{n} buildings' },
roads: { one: '{n} road', other: '{n} roads' },
sizeKm: '{n} km',
},
game: {
back: 'Back to menu',
play: 'Play',
pause: 'Pause',
speed: 'Simulation speed',
weatherOn: 'Hide weather effects',
weatherOff: 'Show weather effects',
weatherAria: 'Weather effects',
},
hud: {
metersPerPixel: '{n} m/px',
chunks: '{loaded}/{total} chunks',
position: '{x}, {y} m',
loading: 'loading…',
},
weather: {
condition: {
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',
},
compass: [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW',
],
feels: 'feels {temp}',
feelsLike: 'Feels like {temp}',
wind: 'Wind {wind}',
humidity: 'Humidity {n}%',
cloud: 'Cloud {n}%',
pressure: 'Pressure {n} hPa',
precipitation: 'Precipitation {n} mm/h',
windSpeed: '{dir} {n} m/s',
},
time: {
months: [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
],
weekdays: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
shortWeekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
format: '{day} {month} {year} · {time}',
formatWithWeekday: '{weekday} {day} {month} {year} · {time}',
},
stage: {
queued: 'Queued',
starting: 'Starting',
cachedOsm: 'Using cached OpenStreetMap data',
queryingOsm: 'Querying OpenStreetMap ({host}, attempt {n})',
parsingOsm: 'Parsing OpenStreetMap response',
importing: 'Importing {n} elements',
computingBounds: 'Computing bounds and chunks',
slicing: 'Slicing into chunks',
writing: 'Writing {n} chunks',
},
};
@@ -0,0 +1,175 @@
import type { Messages } from '../types';
export const ru: Messages = {
app: {
title: 'The Living World',
subtitle: 'Мир по данным OpenStreetMap',
},
locale: {
switch: 'Язык',
},
theme: {
day: 'День',
night: 'Ночь',
switchTo: 'Переключить на тему «{theme}»',
},
menu: {
continue: 'Продолжить',
worlds: 'Миры',
worldsEmpty: 'Созданные миры появятся здесь. Небольшой город занимает несколько секунд.',
lastPlayed: 'Недавний',
openWorld: 'Открыть {name}',
deleteWorld: 'Удалить {name}',
deleteConfirm: 'Удалить «{name}»?',
slotsFull: 'Все {n} слотов заняты. Удалите мир, чтобы создать новый.',
},
form: {
newWorld: 'Новый мир',
name: 'Название',
namePlaceholder: 'Необязательно — по умолчанию координаты',
location: 'Место',
useMyLocation: 'Моё местоположение',
coordsPlaceholder: 'Широта, долгота',
size: 'Размер',
sizeValue: '{n} км',
start: 'Дата и время начала',
climate: 'Климат',
climateFromLocation: 'По месту',
generate: 'Создать мир',
},
climate: {
kind: {
equatorial: 'Экваториальный',
tropicalMonsoon: 'Тропический муссон',
savanna: 'Саванна',
hotDesert: 'Жаркая пустыня',
coldSteppe: 'Холодная степь',
mediterranean: 'Средиземноморский',
humidSubtropical: 'Влажный субтропический',
oceanic: 'Океанический',
centralEuropean: 'Центральноевропейский',
siberian: 'Сибирский',
tundra: 'Тундра',
highland: 'Высокогорный',
},
example: {
equatorial: 'Сингапур',
tropicalMonsoon: 'Мумбаи',
savanna: 'Найроби',
hotDesert: 'Каир',
coldSteppe: 'Астана',
mediterranean: 'Барселона',
humidSubtropical: 'Токио',
oceanic: 'Лондон',
centralEuropean: 'Варшава',
siberian: 'Якутск',
tundra: 'Мурманск',
highland: 'Ла-Пас',
},
describe: '{label} ({code}) · как {example}',
hintInferred: 'Это место предполагает {label} ({code}).',
hintNeedLocation: 'Введите место, чтобы увидеть, что подсказывает широта.',
hintMismatch: '{label} — по широте ближе {inferred}.',
hintChosen: '{label}, как {example}.',
},
status: {
pickPlace: 'Выберите место и создайте мир.',
continueHint: 'Продолжите с того места, где остановились, или откройте другой мир.',
chooseWorld: 'Выберите мир или создайте новый.',
loadingMap: 'Загрузка карты…',
requestingWorld: 'Запрос мира…',
findingLocation: 'Определение местоположения…',
locationFilled: 'Место подставлено — выберите размер и создайте мир.',
geolocationUnavailable: 'Геолокация недоступна в этом браузере.',
invalidCoords: 'Введите широту и долготу — например 31.90, -100.49.',
invalidStart: 'Выберите дату и время начала игрового календаря.',
generationFailed: 'Не удалось создать мир: {error}',
generationFailedUnknown: 'неизвестная ошибка',
deleted: 'Удалён {name}',
couldNotOpen: 'Не удалось открыть мир: {error}',
couldNotDelete: 'Не удалось удалить мир: {error}',
couldNotReachApi: 'Нет связи с API: {error}',
couldNotUpdateClock: 'Не удалось изменить часы: {error}',
couldNotGetLocation: 'Не удалось определить место: {error}',
worldProgress: '{name}: {stage}',
generating: 'Создание',
failed: 'Ошибка',
},
worldStatus: {
pending: 'Ожидание',
generating: 'Создание',
ready: 'Готов',
failed: 'Ошибка',
},
world: {
buildings: { one: '{n} здание', few: '{n} здания', many: '{n} зданий', other: '{n} зданий' },
roads: { one: '{n} дорога', few: '{n} дороги', many: '{n} дорог', other: '{n} дорог' },
sizeKm: '{n} км',
},
game: {
back: 'Назад в меню',
play: 'Играть',
pause: 'Пауза',
speed: 'Скорость симуляции',
weatherOn: 'Скрыть погоду',
weatherOff: 'Показать погоду',
weatherAria: 'Погодные эффекты',
},
hud: {
metersPerPixel: '{n} м/пикс',
chunks: '{loaded}/{total} чанков',
position: '{x}, {y} м',
loading: 'загрузка…',
},
weather: {
condition: {
clear: 'Ясно',
fewClouds: 'Малооблачно',
cloudy: 'Облачно',
overcast: 'Пасмурно',
fog: 'Туман',
drizzle: 'Морось',
rain: 'Дождь',
heavyRain: 'Ливень',
thunderstorm: 'Гроза',
sleet: 'Мокрый снег',
snow: 'Снег',
heavySnow: 'Сильный снег',
blizzard: 'Метель',
sandstorm: 'Песчаная буря',
},
compass: [
'С', 'ССВ', 'СВ', 'ВСВ', 'В', 'ВЮВ', 'ЮВ', 'ЮЮВ',
'Ю', 'ЮЮЗ', 'ЮЗ', 'ЗЮЗ', 'З', 'ЗСЗ', 'СЗ', 'ССЗ',
],
feels: 'ощущается {temp}',
feelsLike: 'Ощущается как {temp}',
wind: 'Ветер {wind}',
humidity: 'Влажность {n}%',
cloud: 'Облачность {n}%',
pressure: 'Давление {n} гПа',
precipitation: 'Осадки {n} мм/ч',
windSpeed: '{dir} {n} м/с',
},
time: {
months: [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
],
weekdays: ['воскресенье', 'понедельник', 'вторник', 'среда', 'четверг', 'пятница', 'суббота'],
shortWeekdays: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'],
format: '{day} {month} {year} · {time}',
formatWithWeekday: '{weekday} {day} {month} {year} · {time}',
},
stage: {
queued: 'В очереди',
starting: 'Запуск',
cachedOsm: 'Кэш OpenStreetMap',
queryingOsm: 'Запрос OpenStreetMap ({host}, попытка {n})',
parsingOsm: 'Разбор ответа OpenStreetMap',
importing: 'Импорт {n} объектов',
computingBounds: 'Расчёт границ и чанков',
slicing: 'Нарезка на чанки',
writing: 'Запись {n} чанков',
},
};
+32
View File
@@ -0,0 +1,32 @@
import { t } from './index';
const EXACT: Record<string, Parameters<typeof t>[0]> = {
Queued: 'stage.queued',
Starting: 'stage.starting',
'Using cached OpenStreetMap data': 'stage.cachedOsm',
'Parsing OpenStreetMap response': 'stage.parsingOsm',
'Computing bounds and chunks': 'stage.computingBounds',
'Slicing into chunks': 'stage.slicing',
};
function countFrom(raw: string): number | string {
const n = Number(raw.replace(/[^\d]/g, ''));
return Number.isFinite(n) ? n : raw;
}
/** Server generation stages are English codes-as-sentences; map the known ones, leave unknowns as-is. */
export function translateStage(stage: string): string {
const exact = EXACT[stage];
if (exact) return t(exact);
const importing = /^Importing (.+) elements$/.exec(stage);
if (importing) return t('stage.importing', { n: countFrom(importing[1]!) });
const writing = /^Writing (.+) chunks$/.exec(stage);
if (writing) return t('stage.writing', { n: countFrom(writing[1]!) });
const querying = /^Querying OpenStreetMap \((.+), attempt (\d+)\)$/.exec(stage);
if (querying) return t('stage.queryingOsm', { host: querying[1]!, n: Number(querying[2]) });
return stage;
}
+162
View File
@@ -0,0 +1,162 @@
import type { ClimateKind, WeatherCondition, WorldStatus } from '../api/types';
/**
* ICU-lite plural forms. `t(key, { n })` picks a category via `Intl.PluralRules`.
* English needs `one`/`other`; Russian also uses `few`/`many`.
*/
export interface Plural {
zero?: string;
one: string;
two?: string;
few?: string;
many?: string;
other: string;
}
export type MonthNames = [string, string, string, string, string, string, string, string, string, string, string, string];
export type WeekdayNames = [string, string, string, string, string, string, string];
export type CompassNames = [
string, string, string, string, string, string, string, string,
string, string, string, string, string, string, string, string,
];
/**
* Every user-facing string in the client. Add a key here first — TypeScript will then
* refuse to build until every registered locale fills it in.
*/
export interface Messages {
app: {
title: string;
subtitle: string;
};
locale: {
switch: string;
};
theme: {
day: string;
night: string;
switchTo: string;
};
menu: {
continue: string;
worlds: string;
worldsEmpty: string;
lastPlayed: string;
openWorld: string;
deleteWorld: string;
deleteConfirm: string;
slotsFull: string;
};
form: {
newWorld: string;
name: string;
namePlaceholder: string;
location: string;
useMyLocation: string;
coordsPlaceholder: string;
size: string;
sizeValue: string;
start: string;
climate: string;
climateFromLocation: string;
generate: string;
};
climate: {
kind: Record<ClimateKind, string>;
example: Record<ClimateKind, string>;
describe: string;
hintInferred: string;
hintNeedLocation: string;
hintMismatch: string;
hintChosen: string;
};
status: {
pickPlace: string;
continueHint: string;
chooseWorld: string;
loadingMap: string;
requestingWorld: string;
findingLocation: string;
locationFilled: string;
geolocationUnavailable: string;
invalidCoords: string;
invalidStart: string;
generationFailed: string;
generationFailedUnknown: string;
deleted: string;
couldNotOpen: string;
couldNotDelete: string;
couldNotReachApi: string;
couldNotUpdateClock: string;
couldNotGetLocation: string;
worldProgress: string;
generating: string;
failed: string;
};
worldStatus: Record<WorldStatus, string>;
world: {
buildings: Plural;
roads: Plural;
sizeKm: string;
};
game: {
back: string;
play: string;
pause: string;
speed: string;
weatherOn: string;
weatherOff: string;
weatherAria: string;
};
hud: {
metersPerPixel: string;
chunks: string;
position: string;
loading: string;
};
weather: {
condition: Record<WeatherCondition, string>;
compass: CompassNames;
feels: string;
feelsLike: string;
wind: string;
humidity: string;
cloud: string;
pressure: string;
precipitation: string;
windSpeed: string;
};
time: {
months: MonthNames;
weekdays: WeekdayNames;
shortWeekdays: WeekdayNames;
format: string;
formatWithWeekday: string;
};
stage: {
queued: string;
starting: string;
cachedOsm: string;
queryingOsm: string;
parsingOsm: string;
importing: string;
computingBounds: string;
slicing: string;
writing: string;
};
}
/** Paths to string or plural leaves, e.g. `menu.continue` or `weather.condition.clear`. */
export type MessageKey = {
[K in keyof Messages & string]: LeafKey<K, Messages[K]>;
}[keyof Messages & string];
type LeafKey<Prefix extends string, T> = T extends string
? Prefix
: T extends Plural
? Prefix
: T extends readonly string[]
? Prefix
: {
[K in keyof T & string]: LeafKey<`${Prefix}.${K}`, T[K]>;
}[keyof T & string];
+116 -54
View File
@@ -11,8 +11,11 @@ import {
interpolateGameTime, interpolateGameTime,
startGameTimeFromInput, startGameTimeFromInput,
} from './ui/gameTime'; } from './ui/gameTime';
import { climateFromLatitude, describeClimate, findClimate } from './ui/climate'; import { climateFromLatitude, climateExample, climateLabel, describeClimate, findClimate } from './ui/climate';
import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather'; import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather';
import { catalog, initI18n, subscribe, t } from './i18n';
import { applyDomTranslations, mountLocaleSwitch, paintLocaleSwitch } from './i18n/dom';
import { translateStage } from './i18n/stage';
const LAST_WORLD_KEY = 'the-living-world:last-world'; const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme'; const THEME_KEY = 'the-living-world:theme';
@@ -45,6 +48,7 @@ const elements = {
hud: required<HTMLDivElement>('hud'), hud: required<HTMLDivElement>('hud'),
worldTitle: required<HTMLElement>('world-title'), worldTitle: required<HTMLElement>('world-title'),
back: required<HTMLButtonElement>('back-button'), back: required<HTMLButtonElement>('back-button'),
localeSwitch: required<HTMLDivElement>('locale-switch'),
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'), menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'), gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
weatherToggle: required<HTMLButtonElement>('weather-toggle'), weatherToggle: required<HTMLButtonElement>('weather-toggle'),
@@ -76,6 +80,7 @@ let gameSnapshotAt = 0;
let gamePollTimer: number | null = null; let gamePollTimer: number | null = null;
let gamePaintTimer: number | null = null; let gamePaintTimer: number | null = null;
let clockUpdating = false; let clockUpdating = false;
let lastMapStatus: MapStatus | null = null;
function required<T extends HTMLElement>(id: string): T { function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id); const element = document.getElementById(id);
@@ -102,14 +107,20 @@ function showGame(): void {
} }
function renderHud(status: MapStatus): void { function renderHud(status: MapStatus): void {
const scale = status.metersPerPixel >= 10 lastMapStatus = status;
? `${Math.round(status.metersPerPixel)} m/px` const scale = t('hud.metersPerPixel', {
: `${status.metersPerPixel.toFixed(1)} m/px`; n: status.metersPerPixel >= 10
? String(Math.round(status.metersPerPixel))
: status.metersPerPixel.toFixed(1),
});
const chunks = `${status.loadedChunks}/${status.totalChunks} chunks`; const chunks = t('hud.chunks', { loaded: status.loadedChunks, total: status.totalChunks });
const position = `${Math.round(status.center.x)}, ${Math.round(status.center.y)} m`; const position = t('hud.position', {
x: Math.round(status.center.x),
y: Math.round(status.center.y),
});
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading' : ''}`; elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ` · ${t('hud.loading')}` : ''}`;
} }
function updateGenerateEnabled(): void { function updateGenerateEnabled(): void {
@@ -118,7 +129,7 @@ function updateGenerateEnabled(): void {
if (full) { if (full) {
elements.formHint.hidden = false; elements.formHint.hidden = false;
elements.formHint.textContent = `All ${maxConcurrentWorlds} slots are in use. Delete a world to create another.`; elements.formHint.textContent = t('menu.slotsFull', { n: maxConcurrentWorlds });
} else { } else {
elements.formHint.hidden = true; elements.formHint.hidden = true;
elements.formHint.textContent = ''; elements.formHint.textContent = '';
@@ -222,9 +233,11 @@ function stopMenuClockLoop(): void {
} }
function worldBadge(world: WorldSummary): string | null { function worldBadge(world: WorldSummary): string | null {
if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating'; if (world.status === 'pending' || world.status === 'generating') {
if (world.status === 'failed') return 'Failed'; return world.stage ? translateStage(world.stage) : t('status.generating');
if (world.id === lastWorldId()) return 'Last played'; }
if (world.status === 'failed') return t('status.failed');
if (world.id === lastWorldId()) return t('menu.lastPlayed');
return null; return null;
} }
@@ -242,8 +255,8 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
open.className = 'world__open'; open.className = 'world__open';
open.disabled = world.status !== 'ready'; open.disabled = world.status !== 'ready';
open.title = world.status === 'ready' open.title = world.status === 'ready'
? `Open ${world.name}` ? t('menu.openWorld', { name: world.name })
: (world.error ?? world.stage ?? world.status); : (world.error ?? (world.stage ? translateStage(world.stage) : catalog().worldStatus[world.status]));
const nameRow = document.createElement('span'); const nameRow = document.createElement('span');
nameRow.className = 'world__name-row'; nameRow.className = 'world__name-row';
@@ -274,8 +287,8 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
const remove = document.createElement('button'); const remove = document.createElement('button');
remove.type = 'button'; remove.type = 'button';
remove.className = 'world__delete'; remove.className = 'world__delete';
remove.title = `Delete ${world.name}`; remove.title = t('menu.deleteWorld', { name: world.name });
remove.setAttribute('aria-label', `Delete ${world.name}`); remove.setAttribute('aria-label', t('menu.deleteWorld', { name: world.name }));
remove.textContent = '×'; remove.textContent = '×';
remove.addEventListener('click', () => { remove.addEventListener('click', () => {
void deleteWorld(world); void deleteWorld(world);
@@ -286,7 +299,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
} }
function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string { function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string {
if (world.status === 'failed') return world.error ?? 'Generation failed'; if (world.status === 'failed') return world.error ?? t('status.failed');
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4); if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
// Enough to tell a town under snow from one in the tropics without opening either. // Enough to tell a town under snow from one in the tropics without opening either.
@@ -295,9 +308,9 @@ function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string
world.weather world.weather
? `${conditionIcon(world.weather.condition)} ${formatTemperature(world.weather.temperatureC)}` ? `${conditionIcon(world.weather.condition)} ${formatTemperature(world.weather.temperatureC)}`
: null, : null,
`${(world.sizeMeters / 1000).toFixed(0)} km`, t('world.sizeKm', { n: Number((world.sizeMeters / 1000).toFixed(0)) }),
world.stats world.stats
? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads` ? `${t('world.buildings', { n: world.stats.buildings })} · ${t('world.roads', { n: world.stats.roads })}`
: null, : null,
] ]
.filter((part): part is string => part !== null) .filter((part): part is string => part !== null)
@@ -325,8 +338,8 @@ function applyClockToControls(clock: WorldClock): void {
paintGameClock(); paintGameClock();
elements.playPause.textContent = clock.paused ? '▶' : '⏸'; elements.playPause.textContent = clock.paused ? '▶' : '⏸';
elements.playPause.title = clock.paused ? 'Play' : 'Pause'; elements.playPause.title = clock.paused ? t('game.play') : t('game.pause');
elements.playPause.setAttribute('aria-label', clock.paused ? 'Play' : 'Pause'); elements.playPause.setAttribute('aria-label', clock.paused ? t('game.play') : t('game.pause'));
for (const button of elements.speedButtons) { for (const button of elements.speedButtons) {
const scale = Number(button.dataset.scale); const scale = Number(button.dataset.scale);
@@ -415,7 +428,7 @@ async function patchClock(body: { paused?: boolean; timeScale?: number }): Promi
const clock = await api.updateClock(activeWorldId, body); const clock = await api.updateClock(activeWorldId, body);
applyClockToControls(clock); applyClockToControls(clock);
} catch (error) { } catch (error) {
setStatus(`Could not update clock: ${message(error)}`, 'error'); setStatus(t('status.couldNotUpdateClock', { error: message(error) }), 'error');
} finally { } finally {
clockUpdating = false; clockUpdating = false;
} }
@@ -429,6 +442,18 @@ async function loadClimates(): Promise<void> {
climateOptions = []; climateOptions = [];
} }
paintClimateOptions();
paintClimateHint();
}
function paintClimateOptions(): void {
const selected = elements.climate.value;
const fallback = elements.climate.options[0];
if (fallback) fallback.textContent = t('form.climateFromLocation');
while (elements.climate.options.length > 1) {
elements.climate.options[1]!.remove();
}
for (const option of climateOptions) { for (const option of climateOptions) {
const element = document.createElement('option'); const element = document.createElement('option');
element.value = option.kind; element.value = option.kind;
@@ -436,8 +461,8 @@ async function loadClimates(): Promise<void> {
elements.climate.append(element); elements.climate.append(element);
} }
elements.climate.value = selected;
elements.climate.disabled = climateOptions.length === 0; elements.climate.disabled = climateOptions.length === 0;
paintClimateHint();
} }
/** Explains what "From the location" will actually pick, and warns when a manual choice fights the latitude. */ /** Explains what "From the location" will actually pick, and warns when a manual choice fights the latitude. */
@@ -452,8 +477,8 @@ function paintClimateHint(): void {
if (!elements.climate.value) { if (!elements.climate.value) {
elements.climateHint.textContent = inferred elements.climateHint.textContent = inferred
? `This location suggests ${inferred.label} (${inferred.koppenCode}).` ? t('climate.hintInferred', { label: climateLabel(inferred), code: inferred.koppenCode })
: 'Enter a location to see what the latitude suggests.'; : t('climate.hintNeedLocation');
return; return;
} }
@@ -464,8 +489,8 @@ function paintClimateHint(): void {
} }
elements.climateHint.textContent = inferred && inferred.kind !== chosen.kind elements.climateHint.textContent = inferred && inferred.kind !== chosen.kind
? `${chosen.label} — the latitude would have suggested ${inferred.label}.` ? t('climate.hintMismatch', { label: climateLabel(chosen), inferred: climateLabel(inferred) })
: `${chosen.label}, like ${chosen.example}.`; : t('climate.hintChosen', { label: climateLabel(chosen), example: climateExample(chosen) });
} }
async function ensureMap(): Promise<void> { async function ensureMap(): Promise<void> {
@@ -478,7 +503,7 @@ async function ensureMap(): Promise<void> {
async function openWorld(id: string): Promise<void> { async function openWorld(id: string): Promise<void> {
try { try {
setStatus('Loading map', 'busy'); setStatus(t('status.loadingMap'), 'busy');
// Stage must be visible before Pixi init / camera.fit — a hidden host measures as 0×0 and // Stage must be visible before Pixi init / camera.fit — a hidden host measures as 0×0 and
// leaves the canvas stuck as a thin strip. // leaves the canvas stuck as a thin strip.
showGame(); showGame();
@@ -500,7 +525,7 @@ async function openWorld(id: string): Promise<void> {
await refreshWorldList(); await refreshWorldList();
} catch (error) { } catch (error) {
showMenu(); showMenu();
setStatus(`Could not open world: ${message(error)}`, 'error'); setStatus(t('status.couldNotOpen', { error: message(error) }), 'error');
} }
} }
@@ -515,12 +540,12 @@ async function returnToMenu(): Promise<void> {
await refreshWorldList(); await refreshWorldList();
setStatus(''); setStatus('');
} catch (error) { } catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error'); setStatus(t('status.couldNotReachApi', { error: message(error) }), 'error');
} }
} }
async function deleteWorld(world: WorldSummary): Promise<void> { async function deleteWorld(world: WorldSummary): Promise<void> {
if (!confirm(`Delete "${world.name}"?`)) return; if (!confirm(t('menu.deleteConfirm', { name: world.name }))) return;
try { try {
await api.deleteWorld(world.id); await api.deleteWorld(world.id);
@@ -538,9 +563,9 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
} }
await refreshWorldList(); await refreshWorldList();
setStatus(`Deleted ${world.name}`); setStatus(t('status.deleted', { name: world.name }));
} catch (error) { } catch (error) {
setStatus(`Could not delete world: ${message(error)}`, 'error'); setStatus(t('status.couldNotDelete', { error: message(error) }), 'error');
} }
} }
@@ -549,14 +574,14 @@ async function generate(event: SubmitEvent): Promise<void> {
const location = parseCoordinates(elements.coords.value); const location = parseCoordinates(elements.coords.value);
if (!location) { if (!location) {
setStatus('Enter a location as latitude, longitude — for example 31.90, -100.49.', 'error'); setStatus(t('status.invalidCoords'), 'error');
elements.coords.focus(); elements.coords.focus();
return; return;
} }
const startGameTime = startGameTimeFromInput(elements.start.value); const startGameTime = startGameTimeFromInput(elements.start.value);
if (!startGameTime) { if (!startGameTime) {
setStatus('Pick a start date and time for the in-world calendar.', 'error'); setStatus(t('status.invalidStart'), 'error');
elements.start.focus(); elements.start.focus();
return; return;
} }
@@ -564,7 +589,7 @@ async function generate(event: SubmitEvent): Promise<void> {
const sizeKm = Number(elements.size.value); const sizeKm = Number(elements.size.value);
generating = true; generating = true;
updateGenerateEnabled(); updateGenerateEnabled();
setStatus('Requesting world', 'busy'); setStatus(t('status.requestingWorld'), 'busy');
try { try {
const created = await api.createWorld({ const created = await api.createWorld({
@@ -580,18 +605,21 @@ async function generate(event: SubmitEvent): Promise<void> {
await refreshWorldList(); await refreshWorldList();
const finished = await waitForWorld(created.id, (summary) => { const finished = await waitForWorld(created.id, (summary) => {
setStatus(summary.stage ? `${summary.name}: ${summary.stage}` : `${summary.name}: ${summary.status}`, 'busy'); setStatus(t('status.worldProgress', {
name: summary.name,
stage: summary.stage ? translateStage(summary.stage) : catalog().worldStatus[summary.status],
}), 'busy');
void refreshWorldList(); void refreshWorldList();
}); });
if (finished.status === 'failed') { if (finished.status === 'failed') {
setStatus(`Generation failed: ${finished.error ?? 'unknown error'}`, 'error'); setStatus(t('status.generationFailed', { error: finished.error ?? t('status.generationFailedUnknown') }), 'error');
return; return;
} }
await openWorld(finished.id); await openWorld(finished.id);
} catch (error) { } catch (error) {
setStatus(`Generation failed: ${message(error)}`, 'error'); setStatus(t('status.generationFailed', { error: message(error) }), 'error');
} finally { } finally {
generating = false; generating = false;
void refreshWorldList(); void refreshWorldList();
@@ -600,18 +628,18 @@ async function generate(event: SubmitEvent): Promise<void> {
function useMyLocation(): void { function useMyLocation(): void {
if (!navigator.geolocation) { if (!navigator.geolocation) {
setStatus('Geolocation is not available in this browser.', 'error'); setStatus(t('status.geolocationUnavailable'), 'error');
return; return;
} }
setStatus('Finding your location', 'busy'); setStatus(t('status.findingLocation'), 'busy');
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
elements.coords.value = formatCoordinates(position.coords.latitude, position.coords.longitude); elements.coords.value = formatCoordinates(position.coords.latitude, position.coords.longitude);
setStatus('Location filled in — adjust the size and generate.'); setStatus(t('status.locationFilled'));
}, },
(error) => { (error) => {
setStatus(`Could not get location: ${error.message}`, 'error'); setStatus(t('status.couldNotGetLocation', { error: error.message }), 'error');
}, },
{ enableHighAccuracy: false, maximumAge: 60_000, timeout: 10_000 }, { enableHighAccuracy: false, maximumAge: 60_000, timeout: 10_000 },
); );
@@ -625,12 +653,12 @@ function message(error: unknown): string {
function applyTheme(name: ThemeName): void { function applyTheme(name: ThemeName): void {
if (mapReady) view.setTheme(name); if (mapReady) view.setTheme(name);
document.documentElement.dataset.theme = name; document.documentElement.dataset.theme = name;
const next = name === 'day' ? 'Night' : 'Day'; const next = name === 'day' ? t('theme.night') : t('theme.day');
elements.menuThemeToggle.textContent = next; elements.menuThemeToggle.textContent = next;
elements.menuThemeToggle.title = `Switch to ${next.toLowerCase()} theme`; elements.menuThemeToggle.title = t('theme.switchTo', { theme: next });
elements.gameThemeToggle.textContent = THEMES[name].dark ? '☀' : '☾'; elements.gameThemeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
elements.gameThemeToggle.title = `Switch to ${next.toLowerCase()} theme`; elements.gameThemeToggle.title = t('theme.switchTo', { theme: next });
elements.gameThemeToggle.setAttribute('aria-label', `Switch to ${next.toLowerCase()} theme`); elements.gameThemeToggle.setAttribute('aria-label', t('theme.switchTo', { theme: next }));
localStorage.setItem(THEME_KEY, name); localStorage.setItem(THEME_KEY, name);
} }
@@ -651,7 +679,7 @@ function applyWeatherEffects(enabled: boolean): void {
view.setWeatherEffectsEnabled(enabled); view.setWeatherEffectsEnabled(enabled);
elements.weatherToggle.setAttribute('aria-pressed', enabled ? 'true' : 'false'); elements.weatherToggle.setAttribute('aria-pressed', enabled ? 'true' : 'false');
elements.weatherToggle.title = enabled ? 'Hide weather effects' : 'Show weather effects'; elements.weatherToggle.title = enabled ? t('game.weatherOn') : t('game.weatherOff');
} }
function toggleTheme(): void { function toggleTheme(): void {
@@ -659,10 +687,44 @@ function toggleTheme(): void {
applyTheme(current === 'day' ? 'night' : 'day'); applyTheme(current === 'day' ? 'night' : 'day');
} }
function paintSizeValue(): void {
elements.sizeValue.textContent = t('form.sizeValue', { n: Number(elements.size.value) });
}
function paintMenuStatus(): void {
if (elements.menu.hidden) return;
if (elements.status.dataset.tone === 'error' || elements.status.dataset.tone === 'busy') return;
if (worldCount === 0) setStatus(t('status.pickPlace'));
else if (continueWorldId) setStatus(t('status.continueHint'));
else setStatus(t('status.chooseWorld'));
}
function onLocaleChanged(): void {
applyDomTranslations();
paintLocaleSwitch(elements.localeSwitch);
paintSizeValue();
paintClimateOptions();
paintClimateHint();
updateGenerateEnabled();
elements.worldList.replaceChildren(...sortWorlds(listedWorlds).map(renderWorldItem));
updateContinue(sortWorlds(listedWorlds));
paintMenuStatus();
applyTheme((document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme());
applyWeatherEffects(readWeatherEffects());
if (gameClock) applyClockToControls(gameClock);
applyWeatherToControls(gameWeather ?? undefined);
if (lastMapStatus) renderHud(lastMapStatus);
}
async function start(): Promise<void> { async function start(): Promise<void> {
elements.size.addEventListener('input', () => { initI18n();
elements.sizeValue.textContent = `${elements.size.value} km`; mountLocaleSwitch(elements.localeSwitch);
}); applyDomTranslations();
subscribe(onLocaleChanged);
elements.size.addEventListener('input', paintSizeValue);
paintSizeValue();
elements.form.addEventListener('submit', (event) => { elements.form.addEventListener('submit', (event) => {
void generate(event); void generate(event);
}); });
@@ -699,11 +761,11 @@ async function start(): Promise<void> {
try { try {
const worlds = await refreshWorldList(); const worlds = await refreshWorldList();
if (worlds.length === 0) setStatus('Pick a place and generate a world to get started.'); if (worlds.length === 0) setStatus(t('status.pickPlace'));
else if (continueWorldId) setStatus('Continue where you left off, or open another world.'); else if (continueWorldId) setStatus(t('status.continueHint'));
else setStatus('Choose a world or create a new one.'); else setStatus(t('status.chooseWorld'));
} catch (error) { } catch (error) {
setStatus(`Could not reach the API: ${message(error)}`, 'error'); setStatus(t('status.couldNotReachApi', { error: message(error) }), 'error');
} }
} }
+38
View File
@@ -73,6 +73,44 @@ body {
gap: 10px; gap: 10px;
} }
.menu__actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.locale-switch {
display: flex;
gap: 2px;
padding: 2px;
background: var(--surface);
border: 1px solid var(--panel-border);
border-radius: 8px;
}
.locale-button {
padding: 4px 8px;
font: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
}
.locale-button:hover {
color: var(--text);
}
.locale-button[aria-pressed='true'] {
color: var(--text);
background: var(--surface-hover);
}
.menu__header h1 { .menu__header h1 {
margin: 0; margin: 0;
font-size: 22px; font-size: 22px;
+11 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import type { ClimateOption } from '../api/types'; import type { ClimateOption } from '../api/types';
import { setLocale } from '../i18n';
import { climateFromLatitude, describeClimate, findClimate } from './climate'; import { climateFromLatitude, describeClimate, findClimate } from './climate';
/** Shaped like the real GET /api/climates payload: equator first, unbanded presets mixed in. */ /** Shaped like the real GET /api/climates payload: equator first, unbanded presets mixed in. */
@@ -18,6 +19,10 @@ const OPTIONS: ClimateOption[] = [
{ kind: 'highland', label: 'Highland', koppenCode: 'H', example: 'La Paz' }, { kind: 'highland', label: 'Highland', koppenCode: 'H', example: 'La Paz' },
]; ];
afterEach(() => {
setLocale('en');
});
describe('climateFromLatitude', () => { describe('climateFromLatitude', () => {
it('reproduces the server bands', () => { it('reproduces the server bands', () => {
expect(climateFromLatitude(OPTIONS, 1.35)).toBe('equatorial'); expect(climateFromLatitude(OPTIONS, 1.35)).toBe('equatorial');
@@ -62,6 +67,11 @@ describe('describeClimate', () => {
it('names the preset, its code and a place it feels like', () => { it('names the preset, its code and a place it feels like', () => {
expect(describeClimate(OPTIONS[8]!)).toBe('Central European (Dfb) · like Warsaw'); expect(describeClimate(OPTIONS[8]!)).toBe('Central European (Dfb) · like Warsaw');
}); });
it('translates the preset and the example city', () => {
setLocale('ru');
expect(describeClimate(OPTIONS[8]!)).toBe('Центральноевропейский (Dfb) · как Варшава');
});
}); });
describe('findClimate', () => { describe('findClimate', () => {
+14 -1
View File
@@ -1,4 +1,5 @@
import type { ClimateKind, ClimateOption } from '../api/types'; import type { ClimateKind, ClimateOption } from '../api/types';
import { catalog, t } from '../i18n';
/** /**
* Reproduces the server's latitude guess from the band limits it sent us, so the create form can preview the * Reproduces the server's latitude guess from the band limits it sent us, so the create form can preview the
@@ -23,9 +24,21 @@ export function climateFromLatitude(
return banded[banded.length - 1]!.kind; return banded[banded.length - 1]!.kind;
} }
export function climateLabel(option: ClimateOption): string {
return catalog().climate.kind[option.kind] ?? option.label;
}
export function climateExample(option: ClimateOption): string {
return catalog().climate.example[option.kind] ?? option.example;
}
/** `Central European (Dfb) · like Warsaw` — enough to pick from without reading a table of numbers. */ /** `Central European (Dfb) · like Warsaw` — enough to pick from without reading a table of numbers. */
export function describeClimate(option: ClimateOption): string { export function describeClimate(option: ClimateOption): string {
return `${option.label} (${option.koppenCode}) · like ${option.example}`; return t('climate.describe', {
label: climateLabel(option),
code: option.koppenCode,
example: climateExample(option),
});
} }
export function findClimate( export function findClimate(
+14 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { setLocale } from '../i18n';
import { import {
formatGameTime, formatGameTime,
formatGameTimeRaw, formatGameTimeRaw,
@@ -10,6 +11,10 @@ import {
DEFAULT_START_INPUT, DEFAULT_START_INPUT,
} from './gameTime'; } from './gameTime';
afterEach(() => {
setLocale('en');
});
describe('parseGameTime', () => { describe('parseGameTime', () => {
it('reads a naive ISO local datetime', () => { it('reads a naive ISO local datetime', () => {
const date = parseGameTime('2012-04-12T06:00:00'); const date = parseGameTime('2012-04-12T06:00:00');
@@ -58,6 +63,14 @@ describe('formatGameTime', () => {
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
]); ]);
}); });
it('formats in Russian with genitive months', () => {
setLocale('ru');
const date = new Date(2012, 3, 12, 6, 0, 0);
expect(formatGameTime(date)).toBe('12 апреля 2012 · 06:00');
expect(formatGameTime(date, { weekday: true })).toBe('чт 12 апреля 2012 · 06:00');
expect(formatWeekday(date)).toBe('четверг');
});
}); });
describe('interpolateGameTime', () => { describe('interpolateGameTime', () => {
+11 -18
View File
@@ -1,4 +1,5 @@
import type { WorldClock } from '../api/types'; import type { WorldClock } from '../api/types';
import { catalog, t } from '../i18n';
/** Matches server GameTime: five game minutes per real second at x1. */ /** Matches server GameTime: five game minutes per real second at x1. */
export const GAME_MINUTES_PER_REAL_SECOND = 5; export const GAME_MINUTES_PER_REAL_SECOND = 5;
@@ -7,18 +8,6 @@ export const GAME_MINUTES_PER_REAL_SECOND = 5;
export const MIN_TIME_SCALE = 1; export const MIN_TIME_SCALE = 1;
export const MAX_TIME_SCALE = 4; export const MAX_TIME_SCALE = 4;
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'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). * 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. * Treats the value as a local calendar instant, not UTC.
@@ -55,18 +44,22 @@ export function parseGameTime(raw: string): Date | null {
* the world list, so it is opt-in rather than always on. * the world list, so it is opt-in rather than always on.
*/ */
export function formatGameTime(date: Date, options?: { weekday?: boolean }): string { export function formatGameTime(date: Date, options?: { weekday?: boolean }): string {
const day = date.getDate(); const time = catalog().time;
const month = MONTHS[date.getMonth()]!;
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
const prefix = options?.weekday ? `${SHORT_WEEKDAYS[date.getDay()]!} ` : ''; const params = {
return `${prefix}${day} ${month} ${year} · ${hours}:${minutes}`; day: date.getDate(),
month: time.months[date.getMonth()]!,
year: String(date.getFullYear()),
time: `${hours}:${minutes}`,
weekday: time.shortWeekdays[date.getDay()]!,
};
return options?.weekday ? t('time.formatWithWeekday', params) : t('time.format', params);
} }
/** Full weekday name, for the tooltip where there is room for it. */ /** Full weekday name, for the tooltip where there is room for it. */
export function formatWeekday(date: Date): string { export function formatWeekday(date: Date): string {
return WEEKDAYS[date.getDay()]!; return catalog().time.weekdays[date.getDay()]!;
} }
export function formatGameTimeRaw(raw: string, options?: { weekday?: boolean }): string { export function formatGameTimeRaw(raw: string, options?: { weekday?: boolean }): string {
+13 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { setLocale } from '../i18n';
import type { Weather } from '../api/types'; import type { Weather } from '../api/types';
import { import {
conditionIcon, conditionIcon,
@@ -10,6 +11,10 @@ import {
windCompass, windCompass,
} from './weather'; } from './weather';
afterEach(() => {
setLocale('en');
});
function weather(overrides: Partial<Weather> = {}): Weather { function weather(overrides: Partial<Weather> = {}): Weather {
return { return {
condition: 'cloudy', condition: 'cloudy',
@@ -89,6 +94,13 @@ describe('formatWeather', () => {
it('reports the wind as a compass bearing and a speed', () => { it('reports the wind as a compass bearing and a speed', () => {
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('W 5 m/s'); expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('W 5 m/s');
}); });
it('reads condition and compass names from the active locale', () => {
setLocale('ru');
expect(conditionLabel('cloudy')).toBe('Облачно');
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('З 5 м/с');
expect(formatWeather(weather({ temperatureC: -6, feelsLikeC: -14 }))).toContain('ощущается -14 °C');
});
}); });
describe('describeWeather', () => { describe('describeWeather', () => {
+16 -32
View File
@@ -1,21 +1,5 @@
import type { Weather, WeatherCondition } from '../api/types'; import type { Weather, WeatherCondition } from '../api/types';
import { catalog, t } from '../i18n';
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> = { const CONDITION_ICONS: Record<WeatherCondition, string> = {
clear: '☀', clear: '☀',
@@ -34,13 +18,8 @@ const CONDITION_ICONS: Record<WeatherCondition, string> = {
sandstorm: '🌪', 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 { export function conditionLabel(condition: WeatherCondition): string {
return CONDITION_LABELS[condition] ?? condition; return catalog().weather.condition[condition] ?? condition;
} }
export function conditionIcon(condition: WeatherCondition): string { export function conditionIcon(condition: WeatherCondition): string {
@@ -58,11 +37,14 @@ export function formatTemperature(celsius: number): string {
export function windCompass(degrees: number): string { export function windCompass(degrees: number): string {
if (!Number.isFinite(degrees)) return '—'; if (!Number.isFinite(degrees)) return '—';
const normalised = ((degrees % 360) + 360) % 360; const normalised = ((degrees % 360) + 360) % 360;
return COMPASS[Math.round(normalised / 22.5) % 16]!; return catalog().weather.compass[Math.round(normalised / 22.5) % 16]!;
} }
export function formatWind(weather: Weather): string { export function formatWind(weather: Weather): string {
return `${windCompass(weather.windDirectionDeg)} ${Math.round(weather.windSpeedMs)} m/s`; return t('weather.windSpeed', {
dir: windCompass(weather.windDirectionDeg),
n: Math.round(weather.windSpeedMs),
});
} }
/** /**
@@ -74,7 +56,7 @@ export function formatWeather(weather: Weather): string {
const parts = [`${icon} ${conditionLabel(weather.condition)}`, formatTemperature(weather.temperatureC)]; const parts = [`${icon} ${conditionLabel(weather.condition)}`, formatTemperature(weather.temperatureC)];
if (Math.abs(weather.feelsLikeC - weather.temperatureC) >= 1.5) { if (Math.abs(weather.feelsLikeC - weather.temperatureC) >= 1.5) {
parts.push(`feels ${formatTemperature(weather.feelsLikeC)}`); parts.push(t('weather.feels', { temp: formatTemperature(weather.feelsLikeC) }));
} }
parts.push(formatWind(weather)); parts.push(formatWind(weather));
@@ -85,12 +67,14 @@ export function formatWeather(weather: Weather): string {
export function describeWeather(weather: Weather): string { export function describeWeather(weather: Weather): string {
return [ return [
`${conditionLabel(weather.condition)} ${formatTemperature(weather.temperatureC)}`, `${conditionLabel(weather.condition)} ${formatTemperature(weather.temperatureC)}`,
`Feels like ${formatTemperature(weather.feelsLikeC)}`, t('weather.feelsLike', { temp: formatTemperature(weather.feelsLikeC) }),
`Wind ${formatWind(weather)}`, t('weather.wind', { wind: formatWind(weather) }),
`Humidity ${Math.round(weather.humidity * 100)}%`, t('weather.humidity', { n: Math.round(weather.humidity * 100) }),
`Cloud ${Math.round(weather.cloudCover * 100)}%`, t('weather.cloud', { n: Math.round(weather.cloudCover * 100) }),
`Pressure ${Math.round(weather.pressureHpa)} hPa`, t('weather.pressure', { n: Math.round(weather.pressureHpa) }),
weather.precipitationMmH > 0 ? `Precipitation ${weather.precipitationMmH.toFixed(1)} mm/h` : null, weather.precipitationMmH > 0
? t('weather.precipitation', { n: weather.precipitationMmH.toFixed(1) })
: null,
] ]
.filter((line): line is string => line !== null) .filter((line): line is string => line !== null)
.join('\n'); .join('\n');