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.
ci / server (push) Failing after 3m40s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 13:48:30 +03:00
parent bc9a33e67f
commit f50f6eacf8
11 changed files with 283 additions and 130 deletions
-104
View File
@@ -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",
-3
View File
@@ -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",
+34 -2
View File
@@ -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<Locale, Messages> = { ru, en };
+96 -1
View File
@@ -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 {
+141 -6
View File
@@ -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<string, HTMLButtonElement>();
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;
}