Add start game time feature to world generation; update API and UI to support custom start dates for in-world calendar
This commit is contained in:
@@ -68,6 +68,16 @@
|
||||
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Start date & time</span>
|
||||
<input
|
||||
id="field-start"
|
||||
type="datetime-local"
|
||||
value="2012-04-12T06:00"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p id="form-hint" class="form__hint" hidden></p>
|
||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||
</form>
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface CreateWorldRequest {
|
||||
longitude: number;
|
||||
sizeKm: number;
|
||||
forceRefresh?: boolean;
|
||||
/** Naive local game calendar start, e.g. `2012-04-12T06:00:00`. Omits → server default. */
|
||||
startGameTime?: string;
|
||||
}
|
||||
|
||||
/** [minX, minY, maxX, maxY] in world metres. */
|
||||
|
||||
@@ -4,7 +4,12 @@ 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';
|
||||
import {
|
||||
formatGameTime,
|
||||
formatGameTimeRaw,
|
||||
interpolateGameTime,
|
||||
startGameTimeFromInput,
|
||||
} from './ui/gameTime';
|
||||
|
||||
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||
const THEME_KEY = 'the-living-world:theme';
|
||||
@@ -21,6 +26,7 @@ const elements = {
|
||||
coords: required<HTMLInputElement>('field-coords'),
|
||||
size: required<HTMLInputElement>('field-size'),
|
||||
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
||||
start: required<HTMLInputElement>('field-start'),
|
||||
generate: required<HTMLButtonElement>('generate-button'),
|
||||
formHint: required<HTMLElement>('form-hint'),
|
||||
useLocation: required<HTMLButtonElement>('use-location'),
|
||||
@@ -446,6 +452,13 @@ async function generate(event: SubmitEvent): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const startGameTime = startGameTimeFromInput(elements.start.value);
|
||||
if (!startGameTime) {
|
||||
setStatus('Pick a start date and time for the in-world calendar.', 'error');
|
||||
elements.start.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const sizeKm = Number(elements.size.value);
|
||||
generating = true;
|
||||
updateGenerateEnabled();
|
||||
@@ -457,6 +470,7 @@ async function generate(event: SubmitEvent): Promise<void> {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
sizeKm,
|
||||
startGameTime,
|
||||
});
|
||||
|
||||
await refreshWorldList();
|
||||
|
||||
@@ -323,7 +323,8 @@ body {
|
||||
}
|
||||
|
||||
.field input[type='text'],
|
||||
.field input[type='number'] {
|
||||
.field input[type='number'],
|
||||
.field input[type='datetime-local'] {
|
||||
padding: 7px 9px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
GAME_MINUTES_PER_REAL_SECOND,
|
||||
interpolateGameTime,
|
||||
parseGameTime,
|
||||
startGameTimeFromInput,
|
||||
DEFAULT_START_INPUT,
|
||||
} from './gameTime';
|
||||
|
||||
describe('parseGameTime', () => {
|
||||
@@ -64,3 +66,12 @@ describe('interpolateGameTime', () => {
|
||||
expect(formatGameTime(date!)).toBe('12 April 2012 · 06:10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startGameTimeFromInput', () => {
|
||||
it('converts a datetime-local value to naive ISO', () => {
|
||||
expect(startGameTimeFromInput('2012-04-12T06:00')).toBe('2012-04-12T06:00:00');
|
||||
expect(startGameTimeFromInput(DEFAULT_START_INPUT)).toBe('2012-04-12T06:00:00');
|
||||
expect(startGameTimeFromInput('')).toBeNull();
|
||||
expect(startGameTimeFromInput('not-a-date')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,3 +68,39 @@ export function interpolateGameTime(
|
||||
const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000;
|
||||
return new Date(base.getTime() + gameMs);
|
||||
}
|
||||
|
||||
/** Default start shown in the create form (`datetime-local` value). */
|
||||
export const DEFAULT_START_INPUT = '2012-04-12T06:00';
|
||||
|
||||
/**
|
||||
* Converts a `datetime-local` value (`YYYY-MM-DDTHH:mm`) into the naive ISO string the API expects.
|
||||
* Returns null when the field is empty or malformed.
|
||||
*/
|
||||
export function startGameTimeFromInput(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(trimmed);
|
||||
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] ?? '0');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}:00`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user