From f50f6eacf8e007b4d40597dc030f96471b251476 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 18 Aug 2026 13:48:30 +0300 Subject: [PATCH] Remove PixiJS dependencies and references from the project, transitioning to a plain DOM approach for UI rendering. Update documentation to reflect these changes and enhance the layout and structure of the game screen for improved user experience. --- AGENTS.md | 3 +- README.md | 2 +- docs/design/near-term.md | 3 +- docs/phases/00-drop-pixi.md | 4 +- docs/phases/01-manager-shell.md | 10 +- docs/phases/README.md | 4 +- src/HSchool.Client/package-lock.json | 104 ----------------- src/HSchool.Client/package.json | 3 - src/HSchool.Client/src/i18n/strings.ts | 36 +++++- src/HSchool.Client/src/style.css | 97 +++++++++++++++- src/HSchool.Client/src/ui/gameScreen.ts | 147 +++++++++++++++++++++++- 11 files changed, 283 insertions(+), 130 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e1e2bf..e618920 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,8 +115,7 @@ say so explicitly in the change description. bare `` in the project. - Transitive pinning is on, so a downgrade warning means you bump the central version rather than adding a per-project override. -- Keep the dependency count low. Arch, Aspire and the test runners are the whole budget; `pixi.js` - is installed but not imported yet — it is there for the game view inside a school. +- Keep the dependency count low. Arch, Aspire and the test runners are the whole budget. ## Things that will bite you diff --git a/README.md b/README.md index 7842668..dcb88fd 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ school screen with a running game clock. The school itself is still empty. | Server | .NET 10, ASP.NET Core | | Simulation | [Arch](https://github.com/genaray/Arch) ECS, fixed 20 Hz tick | | Transport | REST for the menu, raw WebSocket + binary protocol for the clock | -| Client | TypeScript, Vite, plain DOM ([PixiJS 8](https://pixijs.com/) is installed for the game view that comes next) | +| Client | TypeScript, Vite, plain DOM | | Orchestration | .NET Aspire 13 | | Tests | xUnit v3, Vitest, `Aspire.Hosting.Testing` | diff --git a/docs/design/near-term.md b/docs/design/near-term.md index 2cc537c..3ac36c0 100644 --- a/docs/design/near-term.md +++ b/docs/design/near-term.md @@ -18,8 +18,7 @@ QSP: не «полотно текста + список действий», а п Анимация пока только переходы панелей/списков и живые цифры (часы, счётчики). Графики — когда появятся числа, которые ими стоит показывать, не раньше. -Клиент остаётся plain DOM + CSS. `pixi.js` для этого не нужен и в этом горизонте его стоит -убрать из зависимостей. +Клиент остаётся plain DOM + CSS. WebGL-сцены нет. Симуляция по-прежнему на сервере: ученики и комнаты могут существовать как данные в Arch ECS, даже если клиент рисует строки списка, а не человечков. diff --git a/docs/phases/00-drop-pixi.md b/docs/phases/00-drop-pixi.md index c9cbe5a..55a1219 100644 --- a/docs/phases/00-drop-pixi.md +++ b/docs/phases/00-drop-pixi.md @@ -11,8 +11,8 @@ ## Задачи -- [ ] Удалить `pixi.js` из `src/HSchool.Client/package.json` и обновить lockfile -- [ ] Убрать обещание Pixi из `README.md` и `AGENTS.md` (клиент: Vite + TypeScript + DOM) +- [x] Удалить `pixi.js` из `src/HSchool.Client/package.json` и обновить lockfile +- [x] Убрать обещание Pixi из `README.md` и `AGENTS.md` (клиент: Vite + TypeScript + DOM) ## Критерий готовности diff --git a/docs/phases/01-manager-shell.md b/docs/phases/01-manager-shell.md index 9eebfc9..63f6ddc 100644 --- a/docs/phases/01-manager-shell.md +++ b/docs/phases/01-manager-shell.md @@ -11,11 +11,11 @@ ## Задачи -- [ ] Три области: дерево карты, общие события, выбранная локация — плюс часы и скорость -- [ ] События: заголовок и пустое состояние, без выдуманных записей -- [ ] Локация: секции имя, предметы, персонажи, действия на месте, должности — пустые, не ломаются -- [ ] Клик по дереву переключает панель локации на клиенте (пока узлы-заглушки или пустое дерево) -- [ ] Переключение языка обновляет подписи панелей и пустых состояний (`t(...)`) +- [x] Три области: дерево карты, общие события, выбранная локация — плюс часы и скорость +- [x] События: заголовок и пустое состояние, без выдуманных записей +- [x] Локация: секции имя, предметы, персонажи, действия на месте, должности — пустые, не ломаются +- [x] Клик по дереву переключает панель локации на клиенте (пока узлы-заглушки или пустое дерево) +- [x] Переключение языка обновляет подписи панелей и пустых состояний (`t(...)`) ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 50b8c25..ef2b578 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -11,8 +11,8 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [0. Убрать PixiJS](00-drop-pixi.md) | ⬜ | Сцена не планируется | -| [1. Оболочка менеджера](01-manager-shell.md) | ⬜ | Панели с секциями среза, пока без данных | +| [0. Убрать PixiJS](00-drop-pixi.md) | ✅ | Сцена не планируется | +| [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных | | [2. Работник школы и диск](02-school-worker.md) | ⬜ | Поток + World + сейв — основа | | [3. Каталог def и карта](03-defs-map.md) | ⬜ | JSONC, core, валидация раскладки | | [4. Моды и редактор в create](04-create-editor.md) | ⬜ | Выбор модов, карта в POST, снимок при открытии | diff --git a/src/HSchool.Client/package-lock.json b/src/HSchool.Client/package-lock.json index 1e1ca33..4e40d58 100644 --- a/src/HSchool.Client/package-lock.json +++ b/src/HSchool.Client/package-lock.json @@ -7,9 +7,6 @@ "": { "name": "hschool-client", "version": "0.1.0", - "dependencies": { - "pixi.js": "^8.19.0" - }, "devDependencies": { "@types/node": "^24.10.1", "typescript": "~5.9.3", @@ -37,12 +34,6 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@pixi/colord": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", - "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", - "license": "MIT" - }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", @@ -313,12 +304,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/earcut": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", - "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -449,21 +434,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webgpu/types": { - "version": "0.1.71", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz", - "integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==", - "license": "BSD-3-Clause" - }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", - "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -501,12 +471,6 @@ "node": ">=8" } }, - "node_modules/earcut": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", - "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", - "license": "ISC" - }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -524,12 +488,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -573,27 +531,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gifuct-js": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", - "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", - "license": "MIT", - "dependencies": { - "js-binary-schema-parser": "^2.0.3" - } - }, - "node_modules/ismobilejs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", - "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", - "license": "MIT" - }, - "node_modules/js-binary-schema-parser": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", - "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", - "license": "MIT" - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -898,12 +835,6 @@ "node": ">=12.20.0" } }, - "node_modules/parse-svg-path": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", - "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -931,32 +862,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pixi.js": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.19.0.tgz", - "integrity": "sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==", - "license": "MIT", - "workspaces": [ - "examples", - "playground" - ], - "dependencies": { - "@pixi/colord": "^2.9.6", - "@types/earcut": "^3.0.0", - "@webgpu/types": "^0.1.69", - "@xmldom/xmldom": "^0.8.13", - "earcut": "^3.0.2", - "eventemitter3": "^5.0.1", - "gifuct-js": "^2.1.2", - "ismobilejs": "^1.1.1", - "parse-svg-path": "^0.2.0", - "tiny-lru": "^11.4.7" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/pixijs" - } - }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -1050,15 +955,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tiny-lru": { - "version": "11.4.7", - "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", - "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/src/HSchool.Client/package.json b/src/HSchool.Client/package.json index 60d881e..d8dcbc3 100644 --- a/src/HSchool.Client/package.json +++ b/src/HSchool.Client/package.json @@ -14,9 +14,6 @@ "test": "vitest run", "test:watch": "vitest" }, - "dependencies": { - "pixi.js": "^8.19.0" - }, "devDependencies": { "@types/node": "^24.10.1", "typescript": "~5.9.3", diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index aa1140e..d9ab961 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -40,9 +40,25 @@ const ru = { errorInvalidStartDate: 'Дата начала вне допустимого диапазона.', backToMenu: '← В главное меню', - emptySchoolHint: 'Школа пока пуста — здесь появится сама игра.', pause: 'Пауза', resume: 'Продолжить', + + mapTitle: 'Карта', + eventsTitle: 'События', + eventsEmpty: 'Пока ничего не происходит.', + locationName: 'Локация', + locationItems: 'Предметы', + locationCharacters: 'Персонажи', + locationActivities: 'Сейчас', + locationPositions: 'Должности', + itemsEmpty: 'Пусто.', + charactersEmpty: 'Никого нет.', + activitiesEmpty: 'Ничего не происходит.', + positionsEmpty: 'Нет должностей.', + stubTerritory: 'Двор', + stubBuilding: 'Главный корпус', + stubFloor: '1 этаж', + stubRoom: 'Кабинет директора', } as const; type Messages = { [K in keyof typeof ru]: string }; @@ -87,9 +103,25 @@ const en: Messages = { errorInvalidStartDate: 'The start date is outside the allowed range.', backToMenu: '← Main menu', - emptySchoolHint: 'The school is empty for now — the game itself will appear here.', pause: 'Pause', resume: 'Resume', + + mapTitle: 'Map', + eventsTitle: 'Events', + eventsEmpty: 'Nothing is happening yet.', + locationName: 'Location', + locationItems: 'Items', + locationCharacters: 'People', + locationActivities: 'Now', + locationPositions: 'Positions', + itemsEmpty: 'Empty.', + charactersEmpty: 'Nobody here.', + activitiesEmpty: 'Nothing is happening.', + positionsEmpty: 'No positions.', + stubTerritory: 'Yard', + stubBuilding: 'Main building', + stubFloor: 'Floor 1', + stubRoom: "Principal's office", }; const catalogs: Record = { ru, en }; diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index d824428..322446a 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -24,7 +24,7 @@ body { } #app { - max-width: 900px; + max-width: 1080px; margin: 0 auto; padding: 32px 20px 72px; } @@ -191,6 +191,101 @@ body { margin-top: 20px; } +.screen.game .clock { + padding: 20px; +} + +.screen.game .clock__time { + font-size: 40px; +} + +/* Manager */ + +.manager { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(200px, 1fr) minmax(240px, 1.35fr); + gap: 16px; + margin-top: 20px; +} + +.panel { + min-width: 0; + padding: 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-raised); +} + +.panel__title { + margin: 0 0 12px; + font-size: 15px; + font-weight: 600; +} + +.panel__name { + margin: 0 0 12px; + font-size: 18px; + font-weight: 600; +} + +.panel__section { + margin-top: 12px; +} + +.panel__section-title { + margin: 0 0 4px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.02em; +} + +.panel__empty { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + +.tree { + margin: 0; + padding: 0; + list-style: none; +} + +.tree .tree { + margin-top: 2px; +} + +.tree__button { + display: block; + width: 100%; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.tree__button:hover { + border-color: var(--border); +} + +.tree__button--active { + border-color: var(--accent); + background: rgba(76, 201, 240, 0.16); + color: var(--accent); +} + +@media (max-width: 800px) { + .manager { + grid-template-columns: 1fr; + } +} + /* Controls */ .button { diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 49c627c..75b02c3 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -1,6 +1,6 @@ import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts'; import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; -import { t } from '../i18n/strings.ts'; +import { t, type MessageKey } from '../i18n/strings.ts'; import type { School } from '../net/api.ts'; import { el } from './dom.ts'; @@ -10,11 +10,37 @@ interface GameScreenOptions { readonly onSetSpeed: (speedIndex: number) => void; } +interface StubNode { + readonly id: string; + readonly labelKey: MessageKey; + readonly children?: readonly StubNode[]; +} + +/** Placeholder tree until the server sends a map snapshot. Clicking only filters the location panel. */ +const STUB_MAP: StubNode = { + id: 'territory', + labelKey: 'stubTerritory', + children: [ + { + id: 'building', + labelKey: 'stubBuilding', + children: [ + { + id: 'floor', + labelKey: 'stubFloor', + children: [{ id: 'room', labelKey: 'stubRoom' }], + }, + ], + }, + ], +}; + const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4']; /** - * The inside of a school: for now the calendar and the controls that drive it. The server owns - * the clock, so every button only sends an intent and the display follows the next clock frame. + * The inside of a school: calendar controls plus the manager shell. Data for the tree and + * location lists arrives in a later phase; until then the panels stay empty except for stubs + * that prove the tree filters the location pane on the client. */ export class GameScreen { private readonly root = el('section', { class: 'screen game' }); @@ -23,10 +49,26 @@ export class GameScreen { private readonly time = el('p', { class: 'clock__time', text: '--:--' }); private readonly date = el('p', { class: 'clock__date' }); private readonly weekday = el('p', { class: 'clock__weekday' }); - private readonly emptyHint = el('p', { class: 'hint' }); private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' }); private readonly speedButtons: HTMLButtonElement[]; + private readonly mapTitle = el('h2', { class: 'panel__title' }); + private readonly tree = el('ul', { class: 'tree' }); + private readonly eventsTitle = el('h2', { class: 'panel__title' }); + private readonly eventsEmpty = el('p', { class: 'panel__empty' }); + private readonly locationTitle = el('h2', { class: 'panel__title' }); + private readonly locationName = el('p', { class: 'panel__name' }); + private readonly itemsHeading = el('h3', { class: 'panel__section-title' }); + private readonly itemsEmpty = el('p', { class: 'panel__empty' }); + private readonly charactersHeading = el('h3', { class: 'panel__section-title' }); + private readonly charactersEmpty = el('p', { class: 'panel__empty' }); + private readonly activitiesHeading = el('h3', { class: 'panel__section-title' }); + private readonly activitiesEmpty = el('p', { class: 'panel__empty' }); + private readonly positionsHeading = el('h3', { class: 'panel__section-title' }); + private readonly positionsEmpty = el('p', { class: 'panel__empty' }); + + private readonly treeButtons = new Map(); + private selectedId = STUB_MAP.id; private running = false; private lastGameTime: Date | null = null; private lastSpeedIndex = 0; @@ -44,6 +86,8 @@ export class GameScreen { this.backButton.addEventListener('click', options.onLeave); this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running)); + this.buildTree(this.tree, [STUB_MAP], 0); + this.root.append( el('header', { class: 'screen__header' }, this.backButton, this.schoolName), el( @@ -54,7 +98,22 @@ export class GameScreen { this.weekday, el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons), ), - this.emptyHint, + 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.locationTitle, + this.locationName, + el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty), + 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.localize(); @@ -66,7 +125,21 @@ export class GameScreen { localize(): void { this.backButton.textContent = t('backToMenu'); - this.emptyHint.textContent = t('emptySchoolHint'); + this.mapTitle.textContent = t('mapTitle'); + this.eventsTitle.textContent = t('eventsTitle'); + this.eventsEmpty.textContent = t('eventsEmpty'); + this.locationTitle.textContent = t('locationName'); + this.itemsHeading.textContent = t('locationItems'); + this.itemsEmpty.textContent = t('itemsEmpty'); + this.charactersHeading.textContent = t('locationCharacters'); + this.charactersEmpty.textContent = t('charactersEmpty'); + this.activitiesHeading.textContent = t('locationActivities'); + this.activitiesEmpty.textContent = t('activitiesEmpty'); + this.positionsHeading.textContent = t('locationPositions'); + this.positionsEmpty.textContent = t('positionsEmpty'); + + this.paintTreeLabels(STUB_MAP); + this.paintSelection(); if (this.lastGameTime !== null) { this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex); @@ -85,6 +158,53 @@ export class GameScreen { this.applyClock(clock.gameTime, clock.running, clock.speedIndex); } + private buildTree(list: HTMLUListElement, nodes: readonly StubNode[], depth: number): void { + for (const node of nodes) { + const item = el('li', { class: 'tree__node' }); + const button = el('button', { + class: 'tree__button', + type: 'button', + onClick: () => this.select(node.id), + }); + button.style.paddingLeft = `${8 + depth * 14}px`; + this.treeButtons.set(node.id, button); + item.append(button); + + if (node.children !== undefined && node.children.length > 0) { + const nested = el('ul', { class: 'tree' }); + this.buildTree(nested, node.children, depth + 1); + item.append(nested); + } + + list.append(item); + } + } + + private paintTreeLabels(node: StubNode): void { + const button = this.treeButtons.get(node.id); + if (button !== undefined) { + button.textContent = t(node.labelKey); + } + + for (const child of node.children ?? []) { + this.paintTreeLabels(child); + } + } + + private select(id: string): void { + this.selectedId = id; + this.paintSelection(); + } + + private paintSelection(): void { + for (const [id, button] of this.treeButtons) { + button.classList.toggle('tree__button--active', id === this.selectedId); + } + + const labelKey = findStub(STUB_MAP, this.selectedId)?.labelKey; + this.locationName.textContent = labelKey === undefined ? '' : t(labelKey); + } + private applyClock(gameTime: Date, running: boolean, speedIndex: number): void { this.running = running; this.lastGameTime = gameTime; @@ -102,3 +222,18 @@ export class GameScreen { }); } } + +function findStub(node: StubNode, id: string): StubNode | null { + if (node.id === id) { + return node; + } + + for (const child of node.children ?? []) { + const match = findStub(child, id); + if (match !== null) { + return match; + } + } + + return null; +}