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:
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user