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;
+}