Implement live game clock and simulation controls; enhance API with clock update functionality and improve world summary with clock data; update UI to display simulation controls and integrate clock features into the game experience.

This commit is contained in:
Leonid Pershin
2026-08-16 19:31:12 +03:00
parent b75844d11a
commit 3b7fca1495
22 changed files with 1290 additions and 14 deletions
+16
View File
@@ -85,6 +85,22 @@
</button>
<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>
<button
id="play-pause"
type="button"
class="icon-button"
title="Pause"
aria-label="Pause"
></button>
<div class="sim-controls__speeds" role="group" aria-label="Simulation speed">
<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="3">x3</button>
<button type="button" class="speed-button" data-scale="4">x4</button>
</div>
</div>
<button id="game-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
</button>
+16 -1
View File
@@ -1,4 +1,12 @@
import type { CreateWorldRequest, MapChunk, WorldList, WorldMap, WorldSummary } from './types';
import type {
CreateWorldRequest,
MapChunk,
UpdateClockRequest,
WorldClock,
WorldList,
WorldMap,
WorldSummary,
} from './types';
const BASE = '/api/worlds';
@@ -47,6 +55,13 @@ export const api = {
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
updateClock: (id: string, body: UpdateClockRequest) =>
request<WorldClock>(`${BASE}/${id}/clock`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
deleteWorld: async (id: string): Promise<void> => {
const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error(await describeFailure(response));
+13
View File
@@ -21,6 +21,19 @@ export interface WorldSummary {
error?: string;
createdAt: string;
stats?: WorldStats;
clock?: WorldClock;
}
/** In-world calendar. gameTime is a naive local datetime string (no Z). */
export interface WorldClock {
gameTime: string;
timeScale: number;
paused: boolean;
}
export interface UpdateClockRequest {
paused?: boolean;
timeScale?: number;
}
/** Response body for GET /api/worlds. */
+186 -7
View File
@@ -1,12 +1,16 @@
import './styles.css';
import { api, waitForWorld } from './api/client';
import type { WorldSummary } from './api/types';
import type { 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, interpolateGameTime } from './ui/gameTime';
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;
const elements = {
menu: required<HTMLDivElement>('menu'),
@@ -31,6 +35,10 @@ const elements = {
back: required<HTMLButtonElement>('back-button'),
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
simControls: required<HTMLDivElement>('sim-controls'),
gameClock: required<HTMLElement>('game-clock'),
playPause: required<HTMLButtonElement>('play-pause'),
speedButtons: [...document.querySelectorAll<HTMLButtonElement>('.speed-button')],
};
const view = new MapView();
@@ -41,6 +49,18 @@ let mapReady = false;
let generating = false;
let continueWorldId: string | null = null;
/** Latest list from the API, used to refresh clock labels between polls. */
let listedWorlds: WorldSummary[] = [];
let menuSnapshotAt = 0;
let menuPollTimer: number | null = null;
let menuPaintTimer: number | null = null;
let gameClock: WorldClock | null = null;
let gameSnapshotAt = 0;
let gamePollTimer: number | null = null;
let gamePaintTimer: number | null = null;
let clockUpdating = false;
function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing element #${id}`);
@@ -55,11 +75,14 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v
function showMenu(): void {
elements.menu.hidden = false;
elements.game.hidden = true;
stopGameClockLoop();
startMenuClockLoop();
}
function showGame(): void {
elements.menu.hidden = true;
elements.game.hidden = false;
stopMenuClockLoop();
}
function renderHud(status: MapStatus): void {
@@ -106,11 +129,26 @@ function sortWorlds(worlds: WorldSummary[]): WorldSummary[] {
});
}
function displayClock(clock: WorldClock | undefined, snapshotAt: number): string | null {
if (!clock) return null;
const date = interpolateGameTime(clock, performance.now() - snapshotAt);
return date ? formatGameTime(date) : formatGameTimeRaw(clock.gameTime);
}
function updateContinue(worlds: WorldSummary[]): void {
const last = worlds.find((world) => world.id === lastWorldId() && world.status === 'ready');
continueWorldId = last?.id ?? null;
elements.continue.hidden = last === undefined;
elements.continueName.textContent = last?.name ?? '';
if (!last) {
elements.continueName.textContent = '';
return;
}
const clockLabel = displayClock(last.clock, menuSnapshotAt);
elements.continueName.textContent = clockLabel
? `${last.name} · ${clockLabel}`
: last.name;
}
async function refreshWorldList(): Promise<WorldSummary[]> {
@@ -119,6 +157,9 @@ async function refreshWorldList(): Promise<WorldSummary[]> {
worldCount = list.worlds.length;
elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`;
listedWorlds = list.worlds;
menuSnapshotAt = performance.now();
const worlds = sortWorlds(list.worlds);
elements.worldsEmpty.hidden = worlds.length > 0;
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
@@ -127,6 +168,43 @@ async function refreshWorldList(): Promise<WorldSummary[]> {
return list.worlds;
}
function paintMenuClocks(): void {
if (elements.menu.hidden) return;
for (const item of elements.worldList.querySelectorAll<HTMLLIElement>('.world')) {
const id = item.dataset.id;
if (!id) continue;
const world = listedWorlds.find((entry) => entry.id === id);
if (!world) continue;
const detail = item.querySelector('.world__detail');
if (detail) detail.textContent = describeWorld(world, menuSnapshotAt);
}
updateContinue(sortWorlds(listedWorlds));
}
function startMenuClockLoop(): void {
stopMenuClockLoop();
menuPollTimer = window.setInterval(() => {
void refreshWorldList().catch(() => {
// Keep the last known list if a poll fails.
});
}, MENU_POLL_MS);
menuPaintTimer = window.setInterval(paintMenuClocks, CLOCK_PAINT_MS);
}
function stopMenuClockLoop(): void {
if (menuPollTimer !== null) {
window.clearInterval(menuPollTimer);
menuPollTimer = null;
}
if (menuPaintTimer !== null) {
window.clearInterval(menuPaintTimer);
menuPaintTimer = null;
}
}
function worldBadge(world: WorldSummary): string | null {
if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating';
if (world.status === 'failed') return 'Failed';
@@ -138,6 +216,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li');
item.className = 'world';
item.dataset.status = world.status;
item.dataset.id = world.id;
if (world.id === lastWorldId() && world.status === 'ready') {
item.dataset.last = 'true';
}
@@ -168,7 +247,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
const detail = document.createElement('span');
detail.className = 'world__detail';
detail.textContent = describeWorld(world);
detail.textContent = describeWorld(world, menuSnapshotAt);
open.append(nameRow, detail);
open.addEventListener('click', () => {
@@ -189,14 +268,97 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
return item;
}
function describeWorld(world: WorldSummary): string {
function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string {
if (world.status === 'failed') return world.error ?? 'Generation failed';
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
if (!world.stats) return size;
const clockLabel = displayClock(world.clock, snapshotAt);
const stats = world.stats
? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`
: null;
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
if (clockLabel && stats) return `${clockLabel} · ${size} · ${stats}`;
if (clockLabel) return `${clockLabel} · ${size}`;
if (stats) return `${size} · ${stats}`;
return size;
}
function applyClockToControls(clock: WorldClock): void {
gameClock = clock;
gameSnapshotAt = performance.now();
elements.simControls.hidden = false;
paintGameClock();
elements.playPause.textContent = clock.paused ? '▶' : '⏸';
elements.playPause.title = clock.paused ? 'Play' : 'Pause';
elements.playPause.setAttribute('aria-label', clock.paused ? 'Play' : 'Pause');
for (const button of elements.speedButtons) {
const scale = Number(button.dataset.scale);
button.setAttribute('aria-pressed', scale === clock.timeScale ? 'true' : 'false');
}
}
function paintGameClock(): void {
if (!gameClock) {
elements.gameClock.textContent = '';
return;
}
const date = interpolateGameTime(gameClock, performance.now() - gameSnapshotAt);
elements.gameClock.textContent = date
? formatGameTime(date)
: formatGameTimeRaw(gameClock.gameTime);
}
function startGameClockLoop(worldId: string): void {
stopGameClockLoop();
gamePollTimer = window.setInterval(() => {
void pollGameClock(worldId);
}, GAME_POLL_MS);
gamePaintTimer = window.setInterval(paintGameClock, CLOCK_PAINT_MS);
}
function stopGameClockLoop(): void {
if (gamePollTimer !== null) {
window.clearInterval(gamePollTimer);
gamePollTimer = null;
}
if (gamePaintTimer !== null) {
window.clearInterval(gamePaintTimer);
gamePaintTimer = null;
}
gameClock = null;
elements.simControls.hidden = true;
elements.gameClock.textContent = '';
}
async function pollGameClock(worldId: string): Promise<void> {
if (activeWorldId !== worldId || clockUpdating) return;
try {
const summary = await api.getWorld(worldId);
if (activeWorldId !== worldId || !summary.clock) return;
applyClockToControls(summary.clock);
} catch {
// Keep interpolating from the last good snapshot.
}
}
async function patchClock(body: { paused?: boolean; timeScale?: number }): Promise<void> {
if (!activeWorldId || clockUpdating) return;
clockUpdating = true;
try {
const clock = await api.updateClock(activeWorldId, body);
applyClockToControls(clock);
} catch (error) {
setStatus(`Could not update clock: ${message(error)}`, 'error');
} finally {
clockUpdating = false;
}
}
async function ensureMap(): Promise<void> {
@@ -214,7 +376,7 @@ async function openWorld(id: string): Promise<void> {
// leaves the canvas stuck as a thin strip.
showGame();
await ensureMap();
const map = await api.getMap(id);
const [map, summary] = await Promise.all([api.getMap(id), api.getWorld(id)]);
activeWorldId = id;
localStorage.setItem(LAST_WORLD_KEY, id);
@@ -223,6 +385,10 @@ async function openWorld(id: string): Promise<void> {
elements.worldTitle.textContent = map.name;
elements.hud.textContent = '';
setStatus('');
if (summary.clock) applyClockToControls(summary.clock);
startGameClockLoop(id);
await refreshWorldList();
} catch (error) {
showMenu();
@@ -232,6 +398,7 @@ async function openWorld(id: string): Promise<void> {
async function returnToMenu(): Promise<void> {
activeWorldId = null;
stopGameClockLoop();
if (mapReady) view.clear();
elements.hud.textContent = '';
elements.worldTitle.textContent = '';
@@ -252,6 +419,7 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
if (activeWorldId === world.id) {
activeWorldId = null;
stopGameClockLoop();
localStorage.removeItem(LAST_WORLD_KEY);
if (mapReady) view.clear();
elements.hud.textContent = '';
@@ -376,6 +544,17 @@ async function start(): Promise<void> {
});
elements.menuThemeToggle.addEventListener('click', toggleTheme);
elements.gameThemeToggle.addEventListener('click', toggleTheme);
elements.playPause.addEventListener('click', () => {
if (!gameClock) return;
void patchClock({ paused: !gameClock.paused });
});
for (const button of elements.speedButtons) {
button.addEventListener('click', () => {
const scale = Number(button.dataset.scale);
if (!Number.isFinite(scale) || scale === gameClock?.timeScale) return;
void patchClock({ timeScale: scale });
});
}
applyTheme(readStoredTheme());
showMenu();
+56
View File
@@ -143,6 +143,62 @@ body {
backdrop-filter: blur(6px);
}
.sim-controls {
display: flex;
flex: none;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 10px;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
backdrop-filter: blur(6px);
}
.sim-controls__clock {
min-width: 11.5rem;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
white-space: nowrap;
}
.sim-controls__speeds {
display: flex;
gap: 2px;
}
.speed-button {
min-width: 32px;
height: 26px;
padding: 0 6px;
font: inherit;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.speed-button:hover {
color: var(--text);
background: var(--surface-hover);
}
.speed-button[aria-pressed='true'] {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.speed-button[aria-pressed='true']:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.icon-button {
flex: none;
width: 30px;
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
formatGameTime,
formatGameTimeRaw,
GAME_MINUTES_PER_REAL_SECOND,
interpolateGameTime,
parseGameTime,
} from './gameTime';
describe('parseGameTime', () => {
it('reads a naive ISO local datetime', () => {
const date = parseGameTime('2012-04-12T06:00:00');
expect(date).not.toBeNull();
expect(date!.getFullYear()).toBe(2012);
expect(date!.getMonth()).toBe(3);
expect(date!.getDate()).toBe(12);
expect(date!.getHours()).toBe(6);
expect(date!.getMinutes()).toBe(0);
});
it('rejects garbage', () => {
expect(parseGameTime('')).toBeNull();
expect(parseGameTime('not-a-date')).toBeNull();
});
});
describe('formatGameTime', () => {
it('formats without seconds', () => {
const date = new Date(2012, 3, 12, 6, 0, 0);
expect(formatGameTime(date)).toBe('12 April 2012 · 06:00');
});
it('formats a raw API string', () => {
expect(formatGameTimeRaw('2012-04-12T06:00:00')).toBe('12 April 2012 · 06:00');
});
});
describe('interpolateGameTime', () => {
it('holds still while paused', () => {
const date = interpolateGameTime(
{ gameTime: '2012-04-12T06:00:00', timeScale: 4, paused: true },
10_000,
);
expect(date).not.toBeNull();
expect(formatGameTime(date!)).toBe('12 April 2012 · 06:00');
});
it('advances five game minutes per real second at x1', () => {
const date = interpolateGameTime(
{ gameTime: '2012-04-12T06:00:00', timeScale: 1, paused: false },
1000,
);
expect(date).not.toBeNull();
expect(formatGameTime(date!)).toBe('12 April 2012 · 06:05');
expect(GAME_MINUTES_PER_REAL_SECOND).toBe(5);
});
it('scales with timeScale', () => {
const date = interpolateGameTime(
{ gameTime: '2012-04-12T06:00:00', timeScale: 2, paused: false },
1000,
);
expect(date).not.toBeNull();
expect(formatGameTime(date!)).toBe('12 April 2012 · 06:10');
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { WorldClock } from '../api/types';
/** Matches server GameTime: five game minutes per real second at x1. */
export const GAME_MINUTES_PER_REAL_SECOND = 5;
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
] 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.
*/
export function parseGameTime(raw: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?/.exec(raw.trim());
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6]);
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year
|| date.getMonth() !== month - 1
|| date.getDate() !== day
|| date.getHours() !== hour
|| date.getMinutes() !== minute
) {
return 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 {
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}`;
}
export function formatGameTimeRaw(raw: string): string {
const date = parseGameTime(raw);
return date ? formatGameTime(date) : raw;
}
/**
* Interpolates displayed game time between server polls so the clock does not jump.
* `elapsedRealMs` is wall time since the snapshot was received.
*/
export function interpolateGameTime(
clock: WorldClock,
elapsedRealMs: number,
): Date | null {
const base = parseGameTime(clock.gameTime);
if (!base) return null;
if (clock.paused || elapsedRealMs <= 0) return base;
const scale = Number.isFinite(clock.timeScale) ? clock.timeScale : 1;
const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000;
return new Date(base.getTime() + gameMs);
}