vimp-engine 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -86,10 +86,14 @@ export default class LobbyModel {
86
86
  this._emitList();
87
87
  }
88
88
 
89
- // пользователь выбрал сервер — во внешнего подписчика (P2P-подключение)
89
+ // пользователь выбрал сервер — во внешнего подписчика (P2P-подключение).
90
+ // gameId едет вместе с hostId: комната может быть по другой игре, и клиент
91
+ // обязан активировать её ClientPlugin до подключения
90
92
  join(hostId) {
91
- if (this._servers.has(hostId)) {
92
- this.publisher.emit('join', hostId);
93
+ const server = this._servers.get(hostId);
94
+
95
+ if (server) {
96
+ this.publisher.emit('join', { hostId, gameId: server.gameId });
93
97
  }
94
98
  }
95
99
 
@@ -0,0 +1,38 @@
1
+ // Активная игра переключается в лобби на лету: ClientPlugin выбранной игры
2
+ // (или игры комнаты, в которую заходим) грузится в момент клика, а не один раз
3
+ // при бутстрапе. Это возможно потому, что всё пер-игровое состояние (Factory,
4
+ // Pixi-приложения, clientCore) появляется только на старте матча, а после
5
+ // матча lobby-режим перезагружает страницу — лобби всегда в чистом
6
+ // до-матчевом состоянии.
7
+ //
8
+ // Вынесено из main.js (бутстрап, тестами не покрывается) отдельным модулем,
9
+ // чтобы кеш и обработка отказа проверялись юнит-тестом.
10
+ export function createGameActivator({ gamesById, loadClientPlugin }) {
11
+ // gameId -> промис загрузки: кешируется именно промис, иначе два быстрых
12
+ // клика подряд запустили бы импорт дважды
13
+ const plugins = new Map();
14
+
15
+ return async function activateGame(gameId) {
16
+ const manifest = gamesById.get(gameId);
17
+
18
+ if (!manifest) {
19
+ throw new Error(`unknown game "${gameId}"`);
20
+ }
21
+
22
+ let pending = plugins.get(gameId);
23
+
24
+ if (!pending) {
25
+ pending = loadClientPlugin(manifest).catch(e => {
26
+ // отказ не кешируем: сеть могла моргнуть, повторный клик обязан
27
+ // попробовать снова, а не переигрывать ту же ошибку вечно
28
+ plugins.delete(gameId);
29
+
30
+ throw e;
31
+ });
32
+
33
+ plugins.set(gameId, pending);
34
+ }
35
+
36
+ return { manifest, plugin: await pending };
37
+ };
38
+ }
@@ -31,7 +31,7 @@ import {
31
31
  mergeRoomDefaults,
32
32
  reportFormValidity,
33
33
  } from './lib/formBuilder.js';
34
- import { getHostGateState } from './lib/hostGate.js';
34
+ import { createGameActivator } from './lib/gameActivator.js';
35
35
  import createAutostart from './lib/autostart.js';
36
36
  import { getBootConfig, resolveBootConfig } from './boot.js';
37
37
  import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
@@ -80,18 +80,36 @@ import lobbyConfig from '../config/lobby.js';
80
80
  import authClientConfig from '../config/authClient.js';
81
81
  import clientDefaults from '../config/clientDefaults.js';
82
82
 
83
- // Динамическая загрузка игры по каталогу мастера (Этап 6.3): активная для
84
- // host остаётся первой записью каталога (граница lobby-page-plan динамика
85
- // загрузки ClientPlugin выбранной игры откладывается до появления второй
86
- // игры), но сам каталог манифестов (gamesManifest) теперь используется
87
- // целиком селектором игр и Leaderboard (populateGameSelect/gamesById в
88
- // initLobby). ClientPlugin (parts, bakers, игровой CSS, хуки ядра) грузится
89
- // по entries.client её манифеста движок больше не импортирует игру статически
83
+ // Динамическая загрузка игры по каталогу мастера (Этап 6.3): ClientPlugin
84
+ // (parts, bakers, игровой CSS, хуки ядра) грузится по entries.client манифеста
85
+ // движок не импортирует игру статически. Первой активируется gamesManifest[0]
86
+ // (или boot.gameId), но активная игра не заморожена: выбор в #lobby-game и
87
+ // вход в чужую комнату переключают её через gameActivator (см. bindActiveGame).
88
+ // Переключение безопасно ровно до старта матча: всё пер-игровое состояние
89
+ // (Factory, Pixi-приложения, clientCore, звук) появляется только в CONFIG_DATA,
90
+ // а после матча lobby-режим перезагружает страницу
90
91
  let activeGameManifest;
91
92
  let clientPlugin;
92
- let parts;
93
93
  let gamesManifest;
94
94
 
95
+ // узел игрового CSS: при переключении игры его текст заменяется, иначе стили
96
+ // двух игр жили бы в head одновременно и конфликтовали селекторами
97
+ let gameStyleNode = null;
98
+
99
+ // единственная точка присвоения активной игры — и в бутстрапе, и при
100
+ // переключении в лобби
101
+ function bindActiveGame(manifest, plugin) {
102
+ activeGameManifest = manifest;
103
+ clientPlugin = plugin;
104
+
105
+ if (!gameStyleNode) {
106
+ gameStyleNode = document.createElement('style');
107
+ document.head.append(gameStyleNode);
108
+ }
109
+
110
+ gameStyleNode.textContent = plugin.styles ?? '';
111
+ }
112
+
95
113
  // режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
96
114
  // solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
97
115
  // Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
@@ -138,12 +156,10 @@ try {
138
156
  throw new Error('master has no games in its catalog');
139
157
  }
140
158
 
141
- clientPlugin = boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest));
142
- parts = clientPlugin.parts;
143
-
144
- const gameStyle = document.createElement('style');
145
- gameStyle.textContent = clientPlugin.styles;
146
- document.head.append(gameStyle);
159
+ bindActiveGame(
160
+ activeGameManifest,
161
+ boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
162
+ );
147
163
  } catch (e) {
148
164
  document.body.textContent = `Failed to load the game: ${e.message}`;
149
165
  throw e;
@@ -296,7 +312,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
296
312
 
297
313
  // инициализация сущностей игры
298
314
  for (const entity of Object.keys(entitiesOnCanvas)) {
299
- Factory.add({ [entity]: parts[entity] });
315
+ Factory.add({ [entity]: clientPlugin.parts[entity] });
300
316
  }
301
317
 
302
318
  gameInformer = document.getElementById(data.gameInform.id);
@@ -1828,13 +1844,62 @@ function populateRoomForm(manifest) {
1828
1844
  }
1829
1845
  }
1830
1846
 
1831
- // каталог манифестов по id (lobby-page-plan): достаточно для формы и
1832
- // leaderboard селектора игр активная игра для host остаётся
1833
- // gamesManifest[0] (граница задачи, plan/lobby-page-plan.md)
1847
+ // каталог манифестов по id: форма и leaderboard селектора игр, а также
1848
+ // активация игры перед созданием комнаты и входом в чужую
1834
1849
  const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
1835
1850
 
1836
- // заполняет #lobby-game всем каталогом манифестов мастера (lobby-page-plan)
1837
- // раньше туда попадал только gamesManifest[0], теперь селектор рабочий
1851
+ // ClientPlugin выбранной игры грузится в момент клика (создание комнаты /
1852
+ // вход в комнату), а не при смене селектора: просмотр каталога не должен
1853
+ // качать бандлы игр, в которые не станут играть
1854
+ const activateGame = createGameActivator({ gamesById, loadClientPlugin });
1855
+
1856
+ // строка отказа в форме лобби: загрузка плагина восстановима (другая игра,
1857
+ // повторный клик), поэтому #tech-informer не годится — он кроет вкладку
1858
+ // непрозрачным слоем под терминальные причины
1859
+ function showLobbyError(text) {
1860
+ const elem = document.getElementById(lobbyConfig.elems.errorId);
1861
+
1862
+ if (elem) {
1863
+ elem.textContent = text;
1864
+ elem.style.display = '';
1865
+ }
1866
+ }
1867
+
1868
+ function clearLobbyError() {
1869
+ const elem = document.getElementById(lobbyConfig.elems.errorId);
1870
+
1871
+ if (elem) {
1872
+ elem.textContent = '';
1873
+ elem.style.display = 'none';
1874
+ }
1875
+ }
1876
+
1877
+ // активирует игру и перепривязывает манифест/плагин/CSS; false — отказ уже
1878
+ // показан игроку, лобби остаётся рабочим
1879
+ async function selectActiveGame(gameId) {
1880
+ if (gameId === activeGameManifest.id) {
1881
+ return true;
1882
+ }
1883
+
1884
+ try {
1885
+ const { manifest, plugin } = await activateGame(gameId);
1886
+
1887
+ bindActiveGame(manifest, plugin);
1888
+ clearLobbyError();
1889
+
1890
+ return true;
1891
+ } catch (e) {
1892
+ console.error(`[game] failed to activate "${gameId}":`, e);
1893
+ showLobbyError(
1894
+ `Failed to load ${gamesById.get(gameId)?.title ?? gameId}: ${e.message}`,
1895
+ );
1896
+
1897
+ return false;
1898
+ }
1899
+ }
1900
+
1901
+ // заполняет #lobby-game всем каталогом манифестов мастера — раньше туда
1902
+ // попадал только gamesManifest[0], теперь селектор рабочий
1838
1903
  function populateGameSelect() {
1839
1904
  const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
1840
1905
 
@@ -1885,8 +1950,16 @@ function initLobby() {
1885
1950
  lobbyModel.resolvePong(msg.pingId, performance.now());
1886
1951
  });
1887
1952
 
1888
- // выбор сервера → установка P2P
1889
- lobbyModel.publisher.on('join', connectToHost);
1953
+ // выбор сервера → активация игры комнаты и установка P2P. gameId нет у
1954
+ // хостов старше 6.4 — тогда заходим на активной игре, как раньше: неизвестная
1955
+ // версия не повод запретить вход
1956
+ lobbyModel.publisher.on('join', async ({ hostId, gameId }) => {
1957
+ if (gameId && gamesById.has(gameId) && !(await selectActiveGame(gameId))) {
1958
+ return;
1959
+ }
1960
+
1961
+ connectToHost(hostId);
1962
+ });
1890
1963
 
1891
1964
  // Leaderboard (lobby-page-plan): контроллер сигнализирует, для какой игры
1892
1965
  // нужны свежие данные (смена #lobby-game или первое открытие вкладки).
@@ -1928,9 +2001,8 @@ function initLobby() {
1928
2001
  populateRoomForm(activeGameManifest);
1929
2002
  lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
1930
2003
 
1931
- // селектор игр (lobby-page-plan): работает с каталогом манифестов меняет
1932
- // форму создания комнаты и Leaderboard, но НЕ активную игру для host
1933
- // (граница задачи, см. plan/lobby-page-plan.md)
2004
+ // селектор игр: меняет форму создания комнаты и Leaderboard сразу
2005
+ // (синхронно, без сети); сама игра активируется уже по клику
1934
2006
  const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
1935
2007
 
1936
2008
  const hostBtn = document.getElementById(lobbyConfig.elems.hostBtnId);
@@ -1944,47 +2016,47 @@ function initLobby() {
1944
2016
 
1945
2017
  populateRoomForm(manifest);
1946
2018
  lobby.gameChanged(manifest.id, manifest.title);
1947
-
1948
- if (hostBtn) {
1949
- const { disabled, title } = getHostGateState(manifest.id, activeGameManifest);
1950
-
1951
- hostBtn.disabled = disabled;
1952
- hostBtn.title = title;
1953
- }
2019
+ clearLobbyError();
1954
2020
  });
1955
2021
 
1956
2022
  const nameInput = document.getElementById(lobbyConfig.elems.nameId);
1957
2023
 
1958
- hostBtn?.addEventListener('click', () => {
1959
- // защита в глубину поверх hostBtn.disabled (code review lobby-page): клик
1960
- // не должен создать комнату с полями формы чужой игры, даже если
1961
- // disabled почему-то не выставился/был обойдён
1962
- if (gameSelect && getHostGateState(gameSelect.value, activeGameManifest).disabled) {
1963
- return;
1964
- }
1965
-
2024
+ hostBtn?.addEventListener('click', async () => {
1966
2025
  // нативная валидация (pattern/required) — единственная граница
1967
2026
  // room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
1968
2027
  // (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
1969
2028
  // "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
1970
- // (вызывается из host.worker.js при создании комнаты)
2029
+ // (вызывается из host.worker.js при создании комнаты). Проверяем до
2030
+ // активации игры: неверная форма не должна стоить загрузки плагина
1971
2031
  if (
1972
2032
  !reportFormValidity(document.getElementById(lobbyConfig.elems.fieldsId))
1973
2033
  ) {
1974
2034
  return;
1975
2035
  }
1976
2036
 
2037
+ const manifest = gamesById.get(gameSelect?.value) ?? activeGameManifest;
1977
2038
  const name =
1978
2039
  (nameInput?.value || '').trim() || lobbyConfig.create.defaultName;
1979
- const { roomDefaults } = activeGameManifest;
1980
2040
 
1981
- // дефолты манифеста перекрываются значениями сгенерированных полей
1982
- const overrides = { ...roomDefaults };
2041
+ // значения формы снимаем до await: активация асинхронна, а roomFormFields
2042
+ // пересобираются при смене игры — в комнату должно уехать то, что игрок
2043
+ // видел в момент клика
2044
+ const overrides = { ...manifest.roomDefaults };
1983
2045
 
1984
2046
  for (const [key, field] of roomFormFields) {
1985
2047
  overrides[key] = field.getValue();
1986
2048
  }
1987
2049
 
2050
+ // повторный клик, пока грузится плагин, не должен поднять вторую комнату;
2051
+ // при успехе лобби закрывается, разблокировка нужна только на отказе
2052
+ hostBtn.disabled = true;
2053
+
2054
+ if (!(await selectActiveGame(manifest.id))) {
2055
+ hostBtn.disabled = false;
2056
+
2057
+ return;
2058
+ }
2059
+
1988
2060
  connectAsHost({
1989
2061
  name,
1990
2062
  hostSocketId: lobbyConfig.create.hostSocketId,
@@ -619,6 +619,14 @@ body.hide-cursor {
619
619
  margin: 16px auto 0;
620
620
  }
621
621
 
622
+ #lobby-error {
623
+ margin: 8px 0 0;
624
+ color: #d98282;
625
+ font-size: 12px;
626
+ line-height: 16px;
627
+ text-align: center;
628
+ }
629
+
622
630
  #lobby-search {
623
631
  display: block;
624
632
  width: 100%;
@@ -27,6 +27,11 @@ div#lobby(style='display:none')
27
27
  //- манифеста активной игры (docs/en/plugin-api.md "Form schema")
28
28
  div#lobby-fields
29
29
  input#lobby-host(type='button', value='Create server')
30
+ //- отказ загрузки плагина выбранной игры: восстановим (другая игра,
31
+ //- повторный клик), поэтому строкой в форме, а не #tech-informer —
32
+ //- тот кроет вкладку непрозрачным слоем и рассчитан на терминальные
33
+ //- причины
34
+ p#lobby-error(style='display:none')
30
35
  //- правая панель: вкладки Active Servers / Leaderboard (lobby-page-plan)
31
36
  div#lobby-browser-panel.card
32
37
  //- абсолютный слой: высоту .card задаёт форма слева (stretch), а
@@ -77,9 +77,11 @@ export default {
77
77
  emptyId: 'lobby-empty',
78
78
  nameId: 'lobby-name',
79
79
  hostBtnId: 'lobby-host',
80
- // селектор игры (lobby-page-plan): заполняется всем каталогом мастера,
81
- // выбор меняет активную форму/leaderboard, но не активную игру для host
82
- // (граница задачи — см. plan/lobby-page-plan.md)
80
+ // строка отказа под кнопкой: загрузка ClientPlugin выбранной игры может
81
+ // не удаться, и лобби обязано остаться рабочим
82
+ errorId: 'lobby-error',
83
+ // селектор игры: заполняется всем каталогом мастера, выбор задаёт и
84
+ // форму/leaderboard, и игру, которая поднимется по «Create server»
83
85
  gameId: 'lobby-game',
84
86
  // контейнер полей комнаты: генерируются по ключам roomDefaults
85
87
  // манифеста активной игры (Д7) — движок не знает игровых полей
@@ -1,16 +0,0 @@
1
- // Пока хост всегда поднимает activeGameManifest (динамическая загрузка
2
- // ClientPlugin по выбору игры отложена — граница задачи, plan/lobby-page-plan.md),
3
- // нельзя дать создать комнату с полями формы игры, отличной от активной
4
- // (code review lobby-page 2.1). Вынесено в чистую функцию, чтобы поведение
5
- // было проверяемо юнит-тестом отдельно от main.js (main.js — бутстрап, тестами
6
- // не покрывается).
7
- export function getHostGateState(selectedGameId, activeGameManifest) {
8
- if (selectedGameId === activeGameManifest.id) {
9
- return { disabled: false, title: '' };
10
- }
11
-
12
- return {
13
- disabled: true,
14
- title: `Hosting ${activeGameManifest.title} — switch back to create a server`,
15
- };
16
- }