Enhance UI components for map editing and school creation. Update styles for improved layout and responsiveness, including adjustments to dialog positioning and flex properties. Introduce new localization strings for better user guidance in both English and Russian. Refactor code structure for clarity and maintainability, ensuring a more intuitive user experience.
ci / server (push) Failing after 3m32s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 16:34:35 +03:00
parent 3068d10409
commit 7658443cd5
9 changed files with 378 additions and 119 deletions
+10 -2
View File
@@ -51,7 +51,11 @@ const ru = {
mapEditedHint: 'Карта изменена.',
resetMap: 'Сбросить к умолчанию',
done: 'Готово',
editorSlots: 'Слоты',
editorStructure: 'Структура',
editorSlots: 'Обстановка',
editorPickRoom: 'Выберите комнату в дереве слева.',
editorRooms: 'Комнаты',
editorNoLinks: 'Проходов нет.',
editorLinks: 'Проходы',
addLink: 'Связать',
removeLink: 'Убрать',
@@ -130,7 +134,11 @@ const en: Messages = {
mapEditedHint: 'The map has been edited.',
resetMap: 'Reset to default',
done: 'Done',
editorSlots: 'Slots',
editorStructure: 'Structure',
editorSlots: 'Furnishing',
editorPickRoom: 'Pick a room in the tree on the left.',
editorRooms: 'Rooms',
editorNoLinks: 'No passages yet.',
editorLinks: 'Passages',
addLink: 'Link',
removeLink: 'Remove',
+181 -55
View File
@@ -3,6 +3,7 @@
--surface: #10141c;
--surface-raised: #171d28;
--surface-sunken: #0c1017;
--border: #2a3242;
--text: #d7e0ef;
--text-muted: #8b98ad;
@@ -19,14 +20,39 @@
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
background: var(--surface);
}
/*
* The shell fills the window. A management screen that stops halfway down a very wide page reads
* as a half-loaded document rather than a game, so every screen is a full-height flex column and
* decides its own comfortable width.
*/
#app {
max-width: 1080px;
display: flex;
min-height: 100dvh;
padding: 24px 24px 52px;
}
.screen {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
width: 100%;
margin: 0 auto;
padding: 32px 20px 72px;
}
/* A short list of saves sits in the middle of the window instead of clinging to the top edge. */
.screen.menu {
max-width: 900px;
justify-content: center;
}
.screen.game {
max-width: 1500px;
}
#status {
@@ -73,7 +99,7 @@ body {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
margin-bottom: 16px;
}
.screen__title {
@@ -100,15 +126,16 @@ body {
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
align-content: start;
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
gap: 6px;
padding: 18px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-raised);
@@ -151,75 +178,109 @@ body {
}
.card__actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
/* Clock */
.clock {
padding: 28px;
/*
* Inside a school the calendar is a toolbar, not a hero banner: the panels below are the screen,
* and a 56px centred clock pushed them down for no information gained.
*/
.clockbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding: 12px 18px;
border: 1px solid var(--border);
border-radius: 16px;
border-radius: 14px;
background: var(--surface-raised);
text-align: center;
}
.clockbar__now {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.clock__time {
margin: 0;
font-size: 56px;
font-size: 34px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
line-height: 1.1;
}
.clockbar__labels {
display: flex;
flex-direction: column;
min-width: 0;
}
.clock__date {
margin: 4px 0 0;
font-size: 18px;
margin: 0;
font-size: 15px;
}
.clock__weekday {
margin: 2px 0 0;
margin: 0;
color: var(--text-muted);
font-size: 13px;
text-transform: capitalize;
}
.clock__controls {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.screen.game .clock {
padding: 20px;
}
.screen.game .clock__time {
font-size: 40px;
margin-left: auto;
}
/* Manager */
.manager {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(200px, 1fr) minmax(240px, 1.35fr);
grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.25fr);
gap: 16px;
margin-top: 20px;
flex: 1;
min-height: 0;
margin-top: 16px;
}
.panel {
display: flex;
flex-direction: column;
min-width: 0;
padding: 16px;
min-height: 0;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-raised);
}
.panel__title {
margin: 0 0 12px;
font-size: 15px;
flex: 0 0 auto;
margin: 0;
padding: 11px 16px;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* The body scrolls, the title stays: a long tree must not push the panel out of the window. */
.panel__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 12px 16px;
}
.panel__name {
@@ -229,7 +290,11 @@ body {
}
.panel__section {
margin-top: 12px;
margin-top: 14px;
}
.panel__section:first-child {
margin-top: 0;
}
.panel__section-title {
@@ -286,9 +351,20 @@ body {
color: var(--accent);
}
@media (max-width: 800px) {
/* Below three columns the panels stop competing for height and the page scrolls instead. */
@media (max-width: 900px) {
#app {
min-height: 0;
}
.manager {
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
flex: 0 0 auto;
}
.panel__body {
overflow: visible;
}
}
@@ -302,6 +378,7 @@ body {
color: var(--text);
font: inherit;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
@@ -342,11 +419,11 @@ body {
}
.input {
flex: 1;
min-width: 0;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
background: var(--surface-sunken);
color: var(--text);
font: inherit;
font-size: 14px;
@@ -361,7 +438,7 @@ body {
.dialog {
min-width: 340px;
max-width: 440px;
max-width: 460px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 14px;
@@ -369,13 +446,20 @@ body {
color: var(--text);
}
/*
* The editor grows with its map instead of always claiming 800px: a two-room school framed by a
* half-empty window was the loudest thing on screen.
*/
.dialog--screen[open] {
display: flex;
flex-direction: column;
min-width: unset;
max-width: none;
width: min(1080px, calc(100vw - 24px));
height: min(800px, calc(100vh - 24px));
width: min(1080px, calc(100vw - 32px));
/* A modal dialog is absolutely positioned with `inset: 0`, so `height: auto` stretches it to the
max instead of shrink-wrapping. `fit-content` is what actually lets it size to its map. */
height: fit-content;
max-height: calc(100dvh - 48px);
padding: 16px 20px 20px;
}
@@ -389,21 +473,12 @@ body {
}
.dialog--screen .map-editor {
flex: 1;
min-height: 0;
grid-template-columns: minmax(220px, 0.9fr) minmax(280px, 1.3fr);
flex: 1 1 auto;
min-height: min(400px, 55dvh);
grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.3fr);
gap: 16px;
}
.dialog--screen .map-editor__pane,
.dialog--screen .map-editor__details {
min-height: 0;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
}
.dialog--screen .dialog__actions {
flex: 0 0 auto;
margin-top: 12px;
@@ -453,6 +528,20 @@ body {
color: var(--text-muted);
}
/*
* Without this the rows fell back to inline-block flow, `.input { flex: 1 }` never applied and
* every field in the create form ended up a different width.
*/
.field__row {
display: flex;
align-items: center;
gap: 8px;
}
.field__row > .input {
flex: 1;
}
.field .hint {
margin: 0;
font-size: 13px;
@@ -460,8 +549,8 @@ body {
.mod-list {
display: flex;
flex-direction: column;
gap: 6px;
flex-wrap: wrap;
gap: 8px 16px;
}
.mod-list__item {
@@ -471,16 +560,53 @@ body {
font-size: 14px;
}
/* Map editor */
.map-editor {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr);
grid-template-columns: minmax(200px, 0.9fr) minmax(280px, 1.3fr);
gap: 12px;
}
.map-editor__pane,
.map-editor__details {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
min-height: 0;
gap: 12px;
overflow: auto;
}
.editor-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface-sunken);
}
.editor-section--grow {
flex: 1;
min-height: 0;
overflow: auto;
}
.editor-section__title {
margin: 0;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.editor-section__hint {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.map-editor__links {
@@ -41,7 +41,12 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const mapLabel = el('span', { class: 'field__label' });
const editMapButton = el('button', { class: 'button', type: 'button' });
const mapHint = el('p', { class: 'hint' });
const mapField = el('div', { class: 'field' }, mapLabel, editMapButton, mapHint);
const mapField = el(
'div',
{ class: 'field' },
mapLabel,
el('div', { class: 'field__row' }, editMapButton, mapHint),
);
const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40;
@@ -65,12 +70,14 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const nameLabel = el('span', { class: 'field__label' });
const startLabel = el('span', { class: 'field__label' });
// Name first: it is what the player came to type. Mods and the map are setup they usually leave
// alone, so they sit below rather than in front of the field the dialog focuses.
const form = el(
'form',
{ class: 'form' },
modsField,
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField,
mapField,
error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
+28 -11
View File
@@ -69,26 +69,43 @@ export class GameScreen {
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
el(
'div',
{ class: 'clock' },
this.time,
this.date,
this.weekday,
{ class: 'clockbar' },
el(
'div',
{ class: 'clockbar__now' },
this.time,
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
),
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
),
el(
'div',
{ class: 'manager' },
el('section', { class: 'panel' }, this.mapTitle, this.tree),
el('section', { class: 'panel' }, this.eventsTitle, this.eventsEmpty),
el(
'section',
{ class: 'panel' },
this.mapTitle,
el('div', { class: 'panel__body' }, this.tree),
),
el(
'section',
{ class: 'panel' },
this.eventsTitle,
el('div', { class: 'panel__body' }, this.eventsEmpty),
),
el(
'section',
{ class: 'panel' },
this.locationTitle,
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
el(
'div',
{ class: 'panel__body' },
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
),
),
),
);
+91 -45
View File
@@ -23,11 +23,16 @@ export function mapEditor(options: MapEditorOptions): {
let selectedId: string | null = map.territory.id;
const tree = el('ul', { class: 'tree map-editor__tree' });
const treeTitle = el('h3', { class: 'editor-section__title' });
const details = el('div', { class: 'map-editor__details' });
const root = el(
'div',
{ class: 'map-editor' },
el('div', { class: 'map-editor__pane' }, tree),
el(
'div',
{ class: 'map-editor__pane' },
el('section', { class: 'editor-section editor-section--grow' }, treeTitle, tree),
),
details,
);
@@ -37,10 +42,20 @@ export function mapEditor(options: MapEditorOptions): {
};
const render = (): void => {
treeTitle.textContent = t('editorStructure');
paintTree();
paintDetails();
};
/** Every group of controls gets a frame and a heading; loose selects read as debug output. */
const section = (title: string, ...children: (Node | null)[]): HTMLElement =>
el(
'section',
{ class: 'editor-section' },
el('h3', { class: 'editor-section__title', text: title }),
...children,
);
const paintTree = (): void => {
clear(tree);
appendNode(tree, map.territory.id, labelOf(catalog, map, map.territory.id), 0);
@@ -48,10 +63,7 @@ export function mapEditor(options: MapEditorOptions): {
for (const building of map.buildings) {
appendNode(tree, building.id, defLabel(catalog.buildings, building.def) ?? building.id, 1);
for (const floor of map.floors.filter((candidate) => candidate.building === building.id)) {
const floorName = floor.label !== undefined && floor.label.length > 0
? floor.label
: (defLabel(catalog.floors, floor.def) ?? floor.id);
appendNode(tree, floor.id, floorName, 2);
appendNode(tree, floor.id, floorName(catalog, floor), 2);
for (const room of map.rooms.filter((candidate) => candidate.floor === floor.id)) {
appendNode(tree, room.id, defLabel(catalog.rooms, room.def) ?? room.id, 3);
}
@@ -77,42 +89,58 @@ export function mapEditor(options: MapEditorOptions): {
const paintDetails = (): void => {
clear(details);
details.append(paintSelected(), paintLinks(), paintRooms());
};
/** Furnishing of the selected room, or the reason there is nothing to furnish. */
const paintSelected = (): HTMLElement => {
const room = map.rooms.find((candidate) => candidate.id === selectedId);
const roomDef = room === undefined ? undefined : catalog.rooms.find((def) => def.defName === room.def);
const title = `${t('editorSlots')}${labelOf(catalog, map, selectedId ?? map.territory.id)}`;
if (room !== undefined && roomDef !== undefined) {
details.append(el('p', { class: 'field__label', text: t('editorSlots') }));
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
if (room === undefined || roomDef === undefined) {
return section(title, el('p', { class: 'editor-section__hint', text: t('editorPickRoom') }));
}
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
select.addEventListener('change', () => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
slots.push({ key: slot.key, thing: select.value });
}
room.slots = slots;
emit();
});
details.append(
el('label', { class: 'field' }, el('span', { class: 'field__label', text: slot.key }), select),
);
const rows: HTMLElement[] = [];
for (const slot of roomDef.slots) {
const select = el('select', { class: 'input' });
const empty = el('option', { text: t('slotEmpty') });
empty.value = '';
select.append(empty);
for (const thing of catalog.things) {
const option = el('option', { text: thing.label });
option.value = thing.defName;
select.append(option);
}
const remove = el('button', {
class: 'button',
const fill = (room.slots ?? []).find((candidate) => candidate.key === slot.key);
select.value = fill?.thing ?? '';
select.addEventListener('change', () => {
const slots = [...(room.slots ?? [])].filter((candidate) => candidate.key !== slot.key);
if (select.value !== '') {
slots.push({ key: slot.key, thing: select.value });
}
room.slots = slots;
emit();
});
rows.push(
el(
'label',
{ class: 'field__row' },
el('span', { class: 'field__label', text: slot.key }),
select,
),
);
}
return section(
title,
...rows,
el('div', { class: 'dialog__actions' }, el('button', {
class: 'button button--danger button--small',
type: 'button',
text: t('removeRoom'),
onClick: () => {
@@ -121,11 +149,11 @@ export function mapEditor(options: MapEditorOptions): {
selectedId = map.territory.id;
emit();
},
});
details.append(remove);
}
})),
);
};
details.append(el('p', { class: 'field__label', text: t('editorLinks') }));
const paintLinks = (): HTMLElement => {
const linksList = el('ul', { class: 'map-editor__links' });
for (const [index, link] of map.links.entries()) {
const row = el('li', { class: 'map-editor__link' });
@@ -144,12 +172,13 @@ export function mapEditor(options: MapEditorOptions): {
linksList.append(row);
}
details.append(linksList);
const walkable = walkableIds(map);
const from = selectOf(walkable, catalog, map);
const to = selectOf(walkable, catalog, map);
details.append(
return section(
t('editorLinks'),
map.links.length > 0 ? linksList : el('p', { class: 'editor-section__hint', text: t('editorNoLinks') }),
el(
'div',
{ class: 'field__row' },
@@ -172,7 +201,9 @@ export function mapEditor(options: MapEditorOptions): {
}),
),
);
};
const paintRooms = (): HTMLElement => {
const roomSelect = el('select', { class: 'input' });
for (const def of catalog.rooms) {
const option = el('option', { text: def.label });
@@ -180,7 +211,8 @@ export function mapEditor(options: MapEditorOptions): {
roomSelect.append(option);
}
details.append(
return section(
t('editorRooms'),
el(
'div',
{ class: 'field__row' },
@@ -277,6 +309,22 @@ function slug(defName: string): string {
.toLowerCase();
}
/**
* A floor's `label` is its designation, not a replacement name — the vanilla map says "1". Shown
* on its own it is a stray digit in the tree, so it follows the def label: "Этаж 1", "Floor 1".
* Mirrors `MapView.FloorName` on the server.
*/
function floorName(catalog: CatalogResponse, floor: MapLayout['floors'][number]): string {
const def = defLabel(catalog.floors, floor.def);
const label = floor.label ?? '';
if (def === undefined) {
return label.length > 0 ? label : floor.id;
}
return label.length > 0 ? `${def} ${label}` : def;
}
function defLabel(defs: readonly { defName: string; label: string }[], defName: string): string | undefined {
return defs.find((def) => def.defName === defName)?.label;
}
@@ -293,9 +341,7 @@ function labelOf(catalog: CatalogResponse, map: MapLayout, id: string): string {
const floor = map.floors.find((node) => node.id === id);
if (floor !== undefined) {
return floor.label !== undefined && floor.label.length > 0
? floor.label
: (defLabel(catalog.floors, floor.def) ?? id);
return floorName(catalog, floor);
}
const room = map.rooms.find((node) => node.id === id);