Enhance README with updated instructions for world generation and menu navigation; implement world capacity management in the API with new configuration options; improve client interface with a full-screen menu and world list display; add theme toggle functionality and refine styling for better user experience.
This commit is contained in:
@@ -7,53 +7,72 @@
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<div id="menu" class="screen screen--menu">
|
||||
<div class="menu">
|
||||
<header class="menu__header">
|
||||
<div class="menu__titles">
|
||||
<h1>The Living World</h1>
|
||||
<p class="menu__subtitle">Generate a world from OpenStreetMap</p>
|
||||
</div>
|
||||
<button id="menu-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
|
||||
☾
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<aside id="panel" class="panel">
|
||||
<header class="panel__header">
|
||||
<div class="panel__titles">
|
||||
<h1>The Living World</h1>
|
||||
<p class="panel__subtitle">Generate a world from OpenStreetMap</p>
|
||||
</div>
|
||||
<button id="theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
|
||||
<section class="worlds">
|
||||
<div class="worlds__header">
|
||||
<h2>Worlds</h2>
|
||||
<span id="slot-count" class="slot-count"></span>
|
||||
</div>
|
||||
<ul id="world-list" class="world-list"></ul>
|
||||
</section>
|
||||
|
||||
<form id="generate-form" class="form">
|
||||
<h2 class="form__title">Create world</h2>
|
||||
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Latitude</span>
|
||||
<input id="field-lat" type="number" step="any" value="31.8966010" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Longitude</span>
|
||||
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Size <output id="field-size-value">10 km</output></span>
|
||||
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||
</label>
|
||||
|
||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||
</form>
|
||||
|
||||
<footer id="status" class="status"></footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="game" class="screen screen--game" hidden>
|
||||
<div id="stage"></div>
|
||||
|
||||
<header class="game-bar">
|
||||
<button id="back-button" type="button" class="icon-button" title="Back to menu" aria-label="Back to menu">
|
||||
←
|
||||
</button>
|
||||
<span id="world-title" class="game-bar__title"></span>
|
||||
<button id="game-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
|
||||
☾
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form id="generate-form" class="form">
|
||||
<label class="field">
|
||||
<span>Name</span>
|
||||
<input id="field-name" type="text" placeholder="Robert Lee" autocomplete="off" />
|
||||
</label>
|
||||
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Latitude</span>
|
||||
<input id="field-lat" type="number" step="any" value="31.8966010" required />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Longitude</span>
|
||||
<input id="field-lon" type="number" step="any" value="-100.4858591" required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Size <output id="field-size-value">10 km</output></span>
|
||||
<input id="field-size" type="range" min="1" max="20" step="1" value="10" />
|
||||
</label>
|
||||
|
||||
<button id="generate-button" type="submit" class="button">Generate world</button>
|
||||
</form>
|
||||
|
||||
<section class="worlds">
|
||||
<h2>Worlds</h2>
|
||||
<ul id="world-list" class="world-list"></ul>
|
||||
</section>
|
||||
|
||||
<footer id="status" class="status"></footer>
|
||||
</aside>
|
||||
|
||||
<div id="hud" class="hud"></div>
|
||||
<div id="hud" class="hud"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreateWorldRequest, MapChunk, WorldMap, WorldSummary } from './types';
|
||||
import type { CreateWorldRequest, MapChunk, WorldList, WorldMap, WorldSummary } from './types';
|
||||
|
||||
const BASE = '/api/worlds';
|
||||
|
||||
@@ -14,9 +14,14 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
|
||||
async function describeFailure(response: Response): Promise<string> {
|
||||
try {
|
||||
const problem = (await response.json()) as { title?: string; errors?: Record<string, string[]> };
|
||||
const problem = (await response.json()) as {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
const details = problem.errors ? Object.values(problem.errors).flat().join('; ') : undefined;
|
||||
if (details) return details;
|
||||
if (problem.detail) return problem.detail;
|
||||
if (problem.title) return problem.title;
|
||||
} catch {
|
||||
// Not a problem-details body; fall through to the status line.
|
||||
@@ -26,7 +31,7 @@ async function describeFailure(response: Response): Promise<string> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listWorlds: () => request<WorldSummary[]>(BASE),
|
||||
listWorlds: () => request<WorldList>(BASE),
|
||||
|
||||
createWorld: (body: CreateWorldRequest) =>
|
||||
request<WorldSummary>(BASE, {
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface WorldSummary {
|
||||
stats?: WorldStats;
|
||||
}
|
||||
|
||||
/** Response body for GET /api/worlds. */
|
||||
export interface WorldList {
|
||||
worlds: WorldSummary[];
|
||||
maxConcurrentWorlds: number;
|
||||
}
|
||||
|
||||
export interface CreateWorldRequest {
|
||||
name?: string;
|
||||
latitude: number;
|
||||
|
||||
@@ -8,6 +8,8 @@ const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||
const THEME_KEY = 'the-living-world:theme';
|
||||
|
||||
const elements = {
|
||||
menu: required<HTMLDivElement>('menu'),
|
||||
game: required<HTMLDivElement>('game'),
|
||||
stage: required<HTMLDivElement>('stage'),
|
||||
form: required<HTMLFormElement>('generate-form'),
|
||||
name: required<HTMLInputElement>('field-name'),
|
||||
@@ -17,13 +19,20 @@ const elements = {
|
||||
sizeValue: required<HTMLOutputElement>('field-size-value'),
|
||||
generate: required<HTMLButtonElement>('generate-button'),
|
||||
worldList: required<HTMLUListElement>('world-list'),
|
||||
slotCount: required<HTMLElement>('slot-count'),
|
||||
status: required<HTMLElement>('status'),
|
||||
hud: required<HTMLDivElement>('hud'),
|
||||
themeToggle: required<HTMLButtonElement>('theme-toggle'),
|
||||
worldTitle: required<HTMLElement>('world-title'),
|
||||
back: required<HTMLButtonElement>('back-button'),
|
||||
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
|
||||
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
|
||||
};
|
||||
|
||||
const view = new MapView();
|
||||
let activeWorldId: string | null = null;
|
||||
let maxConcurrentWorlds = 8;
|
||||
let mapReady = false;
|
||||
let generating = false;
|
||||
|
||||
function required<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
@@ -36,6 +45,16 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v
|
||||
elements.status.dataset.tone = tone;
|
||||
}
|
||||
|
||||
function showMenu(): void {
|
||||
elements.menu.hidden = false;
|
||||
elements.game.hidden = true;
|
||||
}
|
||||
|
||||
function showGame(): void {
|
||||
elements.menu.hidden = true;
|
||||
elements.game.hidden = false;
|
||||
}
|
||||
|
||||
function renderHud(status: MapStatus): void {
|
||||
const scale = status.metersPerPixel >= 10
|
||||
? `${Math.round(status.metersPerPixel)} m/px`
|
||||
@@ -47,17 +66,26 @@ function renderHud(status: MapStatus): void {
|
||||
elements.hud.textContent = `${scale} · ${position} · ${chunks}${status.loading ? ' · loading…' : ''}`;
|
||||
}
|
||||
|
||||
function updateGenerateEnabled(worldCount: number): void {
|
||||
elements.generate.disabled = generating || worldCount >= maxConcurrentWorlds;
|
||||
}
|
||||
|
||||
async function refreshWorldList(): Promise<WorldSummary[]> {
|
||||
const worlds = await api.listWorlds();
|
||||
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
|
||||
return worlds;
|
||||
const list = await api.listWorlds();
|
||||
maxConcurrentWorlds = list.maxConcurrentWorlds;
|
||||
elements.slotCount.textContent = `${list.worlds.length} / ${maxConcurrentWorlds}`;
|
||||
elements.worldList.replaceChildren(...list.worlds.map(renderWorldItem));
|
||||
updateGenerateEnabled(list.worlds.length);
|
||||
return list.worlds;
|
||||
}
|
||||
|
||||
function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'world';
|
||||
item.dataset.status = world.status;
|
||||
if (world.id === activeWorldId) item.dataset.active = 'true';
|
||||
if (world.id === activeWorldId || world.id === localStorage.getItem(LAST_WORLD_KEY)) {
|
||||
item.dataset.active = 'true';
|
||||
}
|
||||
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
@@ -100,22 +128,48 @@ function describeWorld(world: WorldSummary): string {
|
||||
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
|
||||
}
|
||||
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (mapReady) return;
|
||||
await view.init(elements.stage);
|
||||
view.onStatusChange = renderHud;
|
||||
mapReady = true;
|
||||
applyTheme(readStoredTheme());
|
||||
}
|
||||
|
||||
async function openWorld(id: string): Promise<void> {
|
||||
try {
|
||||
setStatus('Loading map…', 'busy');
|
||||
await ensureMap();
|
||||
const map = await api.getMap(id);
|
||||
|
||||
activeWorldId = id;
|
||||
localStorage.setItem(LAST_WORLD_KEY, id);
|
||||
|
||||
view.showWorld(map);
|
||||
setStatus(`${map.name} — ${map.stats.buildings.toLocaleString()} buildings, ${map.chunks.length} chunks`);
|
||||
elements.worldTitle.textContent = map.name;
|
||||
elements.hud.textContent = '';
|
||||
showGame();
|
||||
setStatus('');
|
||||
await refreshWorldList();
|
||||
} catch (error) {
|
||||
setStatus(`Could not open world: ${message(error)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function returnToMenu(): Promise<void> {
|
||||
activeWorldId = null;
|
||||
if (mapReady) view.clear();
|
||||
elements.hud.textContent = '';
|
||||
elements.worldTitle.textContent = '';
|
||||
showMenu();
|
||||
try {
|
||||
await refreshWorldList();
|
||||
setStatus('');
|
||||
} catch (error) {
|
||||
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorld(world: WorldSummary): Promise<void> {
|
||||
if (!confirm(`Delete "${world.name}"?`)) return;
|
||||
|
||||
@@ -125,8 +179,12 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
|
||||
if (activeWorldId === world.id) {
|
||||
activeWorldId = null;
|
||||
localStorage.removeItem(LAST_WORLD_KEY);
|
||||
view.clear();
|
||||
if (mapReady) view.clear();
|
||||
elements.hud.textContent = '';
|
||||
elements.worldTitle.textContent = '';
|
||||
showMenu();
|
||||
} else if (localStorage.getItem(LAST_WORLD_KEY) === world.id) {
|
||||
localStorage.removeItem(LAST_WORLD_KEY);
|
||||
}
|
||||
|
||||
await refreshWorldList();
|
||||
@@ -143,7 +201,8 @@ async function generate(event: SubmitEvent): Promise<void> {
|
||||
const longitude = Number(elements.longitude.value);
|
||||
const sizeKm = Number(elements.size.value);
|
||||
|
||||
elements.generate.disabled = true;
|
||||
generating = true;
|
||||
updateGenerateEnabled(Number.POSITIVE_INFINITY);
|
||||
setStatus('Requesting world…', 'busy');
|
||||
|
||||
try {
|
||||
@@ -170,7 +229,7 @@ async function generate(event: SubmitEvent): Promise<void> {
|
||||
} catch (error) {
|
||||
setStatus(`Generation failed: ${message(error)}`, 'error');
|
||||
} finally {
|
||||
elements.generate.disabled = false;
|
||||
generating = false;
|
||||
void refreshWorldList();
|
||||
}
|
||||
}
|
||||
@@ -181,9 +240,11 @@ function message(error: unknown): string {
|
||||
|
||||
/** Applies a theme to both halves of the app: the Pixi map and the surrounding page chrome. */
|
||||
function applyTheme(name: ThemeName): void {
|
||||
view.setTheme(name);
|
||||
if (mapReady) view.setTheme(name);
|
||||
document.documentElement.dataset.theme = name;
|
||||
elements.themeToggle.textContent = THEMES[name].dark ? '☀' : '☾';
|
||||
const label = THEMES[name].dark ? '☀' : '☾';
|
||||
elements.menuThemeToggle.textContent = label;
|
||||
elements.gameThemeToggle.textContent = label;
|
||||
localStorage.setItem(THEME_KEY, name);
|
||||
}
|
||||
|
||||
@@ -194,6 +255,11 @@ function readStoredTheme(): ThemeName {
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'day';
|
||||
}
|
||||
|
||||
function toggleTheme(): void {
|
||||
const current = (document.documentElement.dataset.theme as ThemeName | undefined) ?? readStoredTheme();
|
||||
applyTheme(current === 'day' ? 'night' : 'day');
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
elements.size.addEventListener('input', () => {
|
||||
elements.sizeValue.textContent = `${elements.size.value} km`;
|
||||
@@ -201,24 +267,19 @@ async function start(): Promise<void> {
|
||||
elements.form.addEventListener('submit', (event) => {
|
||||
void generate(event);
|
||||
});
|
||||
|
||||
elements.themeToggle.addEventListener('click', () => {
|
||||
applyTheme(view.themeName === 'day' ? 'night' : 'day');
|
||||
elements.back.addEventListener('click', () => {
|
||||
void returnToMenu();
|
||||
});
|
||||
elements.menuThemeToggle.addEventListener('click', toggleTheme);
|
||||
elements.gameThemeToggle.addEventListener('click', toggleTheme);
|
||||
|
||||
view.onStatusChange = renderHud;
|
||||
await view.init(elements.stage);
|
||||
applyTheme(readStoredTheme());
|
||||
showMenu();
|
||||
|
||||
try {
|
||||
const worlds = await refreshWorldList();
|
||||
const remembered = localStorage.getItem(LAST_WORLD_KEY);
|
||||
const target =
|
||||
worlds.find((world) => world.id === remembered && world.status === 'ready') ??
|
||||
worlds.find((world) => world.status === 'ready');
|
||||
|
||||
if (target) await openWorld(target.id);
|
||||
else setStatus('No worlds yet — generate one to get started.');
|
||||
if (worlds.length === 0) setStatus('No worlds yet — create one to get started.');
|
||||
else setStatus('Choose a world or create a new one.');
|
||||
} catch (error) {
|
||||
setStatus(`Could not reach the API: ${message(error)}`, 'error');
|
||||
}
|
||||
|
||||
@@ -41,9 +41,58 @@ body {
|
||||
background: var(--page-bg);
|
||||
}
|
||||
|
||||
#stage {
|
||||
.screen[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.screen--menu {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: min(420px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding: 22px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.menu__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu__header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.menu__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.screen--game {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#stage canvas {
|
||||
@@ -55,37 +104,38 @@ body {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
.game-bar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
width: 320px;
|
||||
max-height: calc(100vh - 32px);
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
overflow-y: auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-bar > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-bar__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 30px rgba(30, 35, 28, 0.14);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.panel__header h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
@@ -104,18 +154,22 @@ body {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form__title,
|
||||
.worlds__header h2 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -174,15 +228,25 @@ body {
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.worlds h2 {
|
||||
margin: 0 0 8px;
|
||||
.worlds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.worlds__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.slot-count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -193,6 +257,16 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.world-list:empty::after {
|
||||
content: 'No worlds yet — create one below.';
|
||||
display: block;
|
||||
padding: 10px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.world {
|
||||
@@ -294,9 +368,10 @@ body {
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -308,12 +383,16 @@ body {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.panel {
|
||||
top: auto;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
width: auto;
|
||||
max-height: 55vh;
|
||||
.screen--menu {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 16px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.world-list {
|
||||
max-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user