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:
@@ -32,7 +32,11 @@ public static class WorldEndpoints
|
||||
.Select(summary => generation.GetInFlight(summary.Id) ?? summary)
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(merged);
|
||||
return Results.Ok(new WorldListDto
|
||||
{
|
||||
Worlds = merged,
|
||||
MaxConcurrentWorlds = generation.MaxConcurrentWorlds,
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateWorld(
|
||||
@@ -52,6 +56,15 @@ public static class WorldEndpoints
|
||||
["request"] = [ex.Message],
|
||||
});
|
||||
}
|
||||
catch (WorldCapacityExceededException ex)
|
||||
{
|
||||
return Results.Conflict(new
|
||||
{
|
||||
title = "World capacity exceeded",
|
||||
detail = ex.Message,
|
||||
maxConcurrentWorlds = ex.MaxConcurrentWorlds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWorld(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TheLivingWorld.Api.Generation;
|
||||
|
||||
/// <summary>Raised when creating a world would exceed <c>WorldStorage:MaxConcurrentWorlds</c>.</summary>
|
||||
public sealed class WorldCapacityExceededException : InvalidOperationException
|
||||
{
|
||||
public WorldCapacityExceededException(int maxConcurrentWorlds)
|
||||
: base($"The server already holds {maxConcurrentWorlds} worlds; delete one before creating another.")
|
||||
{
|
||||
MaxConcurrentWorlds = maxConcurrentWorlds;
|
||||
}
|
||||
|
||||
public int MaxConcurrentWorlds { get; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TheLivingWorld.Api.Storage;
|
||||
using TheLivingWorld.Core.Contracts;
|
||||
using TheLivingWorld.Core.Export;
|
||||
@@ -19,6 +20,7 @@ public sealed class WorldGenerationService(
|
||||
OsmWorldGenerator generator,
|
||||
WorldStore store,
|
||||
ChunkExporter exporter,
|
||||
IOptions<WorldStorageOptions> storageOptions,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<WorldGenerationService> logger) : IDisposable
|
||||
{
|
||||
@@ -28,6 +30,9 @@ public sealed class WorldGenerationService(
|
||||
/// <summary>Overpass mirrors are shared infrastructure, so only one download runs at a time.</summary>
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>Serialises capacity checks against concurrent create requests.</summary>
|
||||
private readonly SemaphoreSlim _capacityGate = new(1, 1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
|
||||
|
||||
public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
|
||||
@@ -56,8 +61,24 @@ public sealed class WorldGenerationService(
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
_inFlight[id] = summary;
|
||||
await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false);
|
||||
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var max = storageOptions.Value.MaxConcurrentWorlds;
|
||||
if (max < 1)
|
||||
throw new ArgumentException("WorldStorage:MaxConcurrentWorlds must be at least 1.");
|
||||
|
||||
var count = await store.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (count >= max)
|
||||
throw new WorldCapacityExceededException(max);
|
||||
|
||||
_inFlight[id] = summary;
|
||||
await store.SaveSummaryAsync(summary, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_capacityGate.Release();
|
||||
}
|
||||
|
||||
// Detached on purpose: the caller gets the pending world back straight away.
|
||||
_ = Task.Run(() => RunAsync(summary, request.ForceRefresh), CancellationToken.None);
|
||||
@@ -68,6 +89,8 @@ public sealed class WorldGenerationService(
|
||||
/// <summary>Returns the live status of a generation still in progress, if there is one.</summary>
|
||||
public WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
|
||||
|
||||
public int MaxConcurrentWorlds => storageOptions.Value.MaxConcurrentWorlds;
|
||||
|
||||
private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh)
|
||||
{
|
||||
// Generation should stop when the host does, not drag shutdown out for minutes.
|
||||
@@ -219,7 +242,11 @@ public sealed class WorldGenerationService(
|
||||
return $"{prefix}-{Guid.NewGuid().ToString("n")[..8]}";
|
||||
}
|
||||
|
||||
public void Dispose() => _gate.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.Dispose();
|
||||
_capacityGate.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CallbackProgress(Action<string> callback) : IProgress<string>
|
||||
{
|
||||
|
||||
@@ -6,4 +6,10 @@ public sealed class WorldStorageOptions
|
||||
|
||||
/// <summary>Where generated worlds live, relative to the content root unless rooted.</summary>
|
||||
public string RootDirectory { get; set; } = "data/worlds";
|
||||
|
||||
/// <summary>
|
||||
/// How many worlds may exist at once. Today that means folders on disk; later the same budget will cap
|
||||
/// how many worlds the backend simulates concurrently.
|
||||
/// </summary>
|
||||
public int MaxConcurrentWorlds { get; set; } = 8;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,25 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/// <summary>Number of worlds that occupy a storage slot (any status with a readable state file).</summary>
|
||||
public async Task<int> CountAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Directory.Exists(_root)) return 0;
|
||||
|
||||
var count = 0;
|
||||
foreach (var directory in Directory.EnumerateDirectories(_root))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var id = Path.GetFileName(directory);
|
||||
if (!IsValidId(id)) continue;
|
||||
if (await GetSummaryAsync(id, cancellationToken).ConfigureAwait(false) is not null)
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<WorldSummaryDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = Path.Combine(WorldDirectory(id), StateFileName);
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"WorldStorage": {
|
||||
"RootDirectory": "data/worlds"
|
||||
"RootDirectory": "data/worlds",
|
||||
"MaxConcurrentWorlds": 8
|
||||
},
|
||||
"Osm": {
|
||||
"Endpoints": [
|
||||
|
||||
@@ -24,6 +24,15 @@ public sealed record CreateWorldRequest
|
||||
public bool ForceRefresh { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response body for <c>GET /api/worlds</c>.</summary>
|
||||
public sealed record WorldListDto
|
||||
{
|
||||
public required IReadOnlyList<WorldSummaryDto> Worlds { get; init; }
|
||||
|
||||
/// <summary>Server-wide slot budget; see <c>WorldStorage:MaxConcurrentWorlds</c>.</summary>
|
||||
public required int MaxConcurrentWorlds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>A world as it appears in listings and while generation is still running.</summary>
|
||||
public sealed record WorldSummaryDto
|
||||
{
|
||||
|
||||
@@ -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