vimp-engine 0.10.2 → 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/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  edition = "2024"
5
5
  description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,24 @@ export default class InputListener {
26
26
  window.addEventListener('mousedown', emitMouse);
27
27
  window.addEventListener('mousemove', emitMouse);
28
28
 
29
+ // указатель: один набор Pointer Events закрывает мышь, палец и стилус —
30
+ // это и есть поддержка смартфона. Экранные координаты сырые: в мировые
31
+ // их переводит CanvasManager, у которого камера и масштаб полотна.
32
+ // Безаргументный mouseAction выше остаётся: на нём висит скрытие курсора
33
+ const emitPointer = type => event =>
34
+ this.publisher.emit('pointerAction', {
35
+ type,
36
+ x: event.clientX,
37
+ y: event.clientY,
38
+ });
39
+
40
+ window.addEventListener('pointerdown', emitPointer('down'));
41
+ window.addEventListener('pointermove', emitPointer('move'));
42
+ window.addEventListener('pointerup', emitPointer('up'));
43
+ // палец, уведённый системным жестом, up не даёт — без этого змейка
44
+ // осталась бы разогнанной с «прижатым» указателем
45
+ window.addEventListener('pointercancel', emitPointer('up'));
46
+
29
47
  window.addEventListener('resize', () =>
30
48
  this.publisher.emit('resize', {
31
49
  width: innerWidth,
@@ -19,6 +19,11 @@ export default class CanvasManagerCtrl {
19
19
  this._model.updateCoords(x, y, cameraReset, shakeData);
20
20
  }
21
21
 
22
+ // экранная точка указателя -> мировая (в координатах игрового полотна)
23
+ toWorld(clientX, clientY) {
24
+ return this._view.toWorld(this._model.pointerCanvasId, clientX, clientY);
25
+ }
26
+
22
27
  // обновляет размеры
23
28
  resize(data) {
24
29
  this._model.resize(data);
@@ -24,6 +24,11 @@ export default class ControlsCtrl {
24
24
  this._model.removeKey(event);
25
25
  }
26
26
 
27
+ // добавляет ввод указателем (мышь/палец)
28
+ addPointer(data) {
29
+ this._model.addPointer(data);
30
+ }
31
+
27
32
  // задает текущий режим
28
33
  switchMode(data) {
29
34
  this._model.setMode(data.name, data.status);
@@ -60,6 +60,10 @@ export default class CanvasManagerModel {
60
60
 
61
61
  const canvases = data.canvases || {};
62
62
 
63
+ // полотно, в системе координат которого считается указатель: имя из
64
+ // конфига либо первое объявленное (у игры оно, как правило, одно)
65
+ this._pointerCanvasId = data.pointerCanvas || Object.keys(canvases)[0];
66
+
63
67
  for (const canvasName in canvases) {
64
68
  if (Object.hasOwn(canvases, canvasName)) {
65
69
  const canvasData = canvases[canvasName];
@@ -81,6 +85,10 @@ export default class CanvasManagerModel {
81
85
  this.publisher = new Publisher();
82
86
  }
83
87
 
88
+ get pointerCanvasId() {
89
+ return this._pointerCanvasId;
90
+ }
91
+
84
92
  // рассчитывает размеры элементов с учетом пропорций
85
93
  resize(data) {
86
94
  const screenWidth = data.width;
@@ -16,11 +16,32 @@ export default class ControlsModel {
16
16
  this._modes = data.modes;
17
17
  this._cmds = data.cmds;
18
18
 
19
+ this._currentKeySetIndex = 0; // индекс текущего набора клавиш
19
20
  this._currentKeySet = this._keySetList[0]; // текущий набор клавиш
20
21
  this._currentModes = {}; // статусы режимов
21
22
  this._pressedKeys = {}; // объект для хранения состояния зажатых клавиш
22
23
  this._areKeysEnabled = false; // статус возможности нажатия клавиш
23
24
 
25
+ // канал указателя: игра, не объявившая modules.controls.pointer, его не
26
+ // получает вовсе — ни одного лишнего сообщения по проводу
27
+ this._pointer = data.pointer
28
+ ? {
29
+ doubleTapMs: data.pointer.doubleTapMs ?? 300,
30
+ doubleTapPx: data.pointer.doubleTapPx ?? 40,
31
+ sendIntervalMs: data.pointer.sendIntervalMs ?? 50,
32
+ // индексы наборов клавиш, в которых указатель живой; по умолчанию
33
+ // все (у наблюдателя рулить нечем, но это дело игры)
34
+ keySets: data.pointer.keySets ?? null,
35
+ }
36
+ : null;
37
+
38
+ this._isPointerDown = false; // указатель прижат
39
+ this._isPointerDoubled = false; // нажатие было вторым в двойном тапе
40
+ this._lastTapTime = 0;
41
+ this._lastTapX = 0;
42
+ this._lastTapY = 0;
43
+ this._lastAimTime = 0;
44
+
24
45
  this.publisher = new Publisher();
25
46
  }
26
47
 
@@ -101,20 +122,117 @@ export default class ControlsModel {
101
122
  setMode(mode, status) {
102
123
  if (status === 'opened') {
103
124
  this._currentModes[mode] = true;
125
+
126
+ // иначе змейка так и ехала бы к последней точке, пока игрок набирает
127
+ // сообщение в чат
128
+ this._releasePointer(this._lastTapX, this._lastTapY);
104
129
  } else if (status === 'closed') {
105
130
  this._currentModes[mode] = false;
106
131
  }
107
132
  }
108
133
 
134
+ // живой ли сейчас канал указателя: те же правила, что и у клавиш —
135
+ // ввод разрешён, ни один режим не открыт, набор клавиш игровой
136
+ _isPointerLive() {
137
+ if (!this._pointer || this._areKeysEnabled === false) {
138
+ return false;
139
+ }
140
+
141
+ if (
142
+ this._currentModes.chat ||
143
+ this._currentModes.stat ||
144
+ this._currentModes.vote
145
+ ) {
146
+ return false;
147
+ }
148
+
149
+ const { keySets } = this._pointer;
150
+
151
+ return keySets === null || keySets.includes(this._currentKeySetIndex);
152
+ }
153
+
154
+ // отпускает указатель (чат открылся, набор сменился, палец ушёл)
155
+ _releasePointer(x, y) {
156
+ if (!this._isPointerDown) {
157
+ return;
158
+ }
159
+
160
+ this._isPointerDown = false;
161
+ this._isPointerDoubled = false;
162
+ this._lastAimTime = 0;
163
+
164
+ this.publisher.emit('aim', { x, y, flags: 0 });
165
+ }
166
+
167
+ // добавляет ввод указателем (мышь/палец/стилус)
168
+ //
169
+ // Канал живёт от нажатия до отпускания: `move` без прижатого указателя не
170
+ // шлётся — иначе любое движение мыши перехватывало бы управление у клавиш
171
+ // и грузило провод. `flags`: бит 0 — прижат, бит 1 — двойной тап.
172
+ addPointer({ type, x, y }) {
173
+ if (!this._pointer) {
174
+ return;
175
+ }
176
+
177
+ if (!this._isPointerLive()) {
178
+ this._releasePointer(x, y);
179
+
180
+ return;
181
+ }
182
+
183
+ const { doubleTapMs, doubleTapPx, sendIntervalMs } = this._pointer;
184
+ const now = Date.now();
185
+
186
+ if (type === 'down') {
187
+ // dblclick тач-устройства не гарантируют — распознаём сами: два
188
+ // нажатия в пределах doubleTapMs и doubleTapPx
189
+ const dx = x - this._lastTapX;
190
+ const dy = y - this._lastTapY;
191
+
192
+ this._isPointerDoubled =
193
+ now - this._lastTapTime <= doubleTapMs &&
194
+ Math.sqrt(dx * dx + dy * dy) <= doubleTapPx;
195
+
196
+ this._isPointerDown = true;
197
+ this._lastTapTime = now;
198
+ this._lastTapX = x;
199
+ this._lastTapY = y;
200
+ } else if (type === 'up') {
201
+ this._releasePointer(x, y);
202
+
203
+ return;
204
+ } else if (!this._isPointerDown) {
205
+ return;
206
+ } else if (now - this._lastAimTime < sendIntervalMs) {
207
+ // move приходит с частотой опроса устройства; по проводу он идёт не
208
+ // чаще sendIntervalMs
209
+ return;
210
+ }
211
+
212
+ this._lastAimTime = now;
213
+
214
+ this.publisher.emit('aim', {
215
+ x,
216
+ y,
217
+ flags: 1 | (this._isPointerDoubled ? 2 : 0),
218
+ });
219
+ }
220
+
109
221
  // меняет набор клавиш
110
222
  changeKeySet(key) {
223
+ this._currentKeySetIndex = key;
111
224
  this._currentKeySet = this._keySetList[key];
112
225
  this._pressedKeys = {};
226
+ this._releasePointer(this._lastTapX, this._lastTapY);
113
227
  }
114
228
 
115
229
  // задаёт возможность нажатия клавиш
116
230
  // (stat и chat доступны при любом значении)
117
231
  setKeysEnabled(isEnabled) {
118
232
  this._areKeysEnabled = isEnabled;
233
+
234
+ if (isEnabled === false) {
235
+ this._releasePointer(this._lastTapX, this._lastTapY);
236
+ }
119
237
  }
120
238
  }
@@ -30,6 +30,10 @@ export default class GameModel {
30
30
  data,
31
31
  this._assets.get(constructor),
32
32
  this._dependencies.get(constructor),
33
+ // контекст экземпляра: id той сущности, которую рисует part. Игра
34
+ // сравнивает его со своим gameId (сервис localPlayer) - без этого part
35
+ // не может отличить своего персонажа от чужого
36
+ { id },
33
37
  );
34
38
 
35
39
  this._data[constructor] = this._data[constructor] || {};
@@ -47,6 +51,8 @@ export default class GameModel {
47
51
  data,
48
52
  this._assets.get(constructor),
49
53
  this._dependencies.get(constructor),
54
+ // у эффекта нет id: запись кадра анонимна, живёт до конца анимации
55
+ { id: null },
50
56
  );
51
57
 
52
58
  this._managedEffects[constructor] =
@@ -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
 
@@ -30,6 +30,39 @@ export default class CanvasManagerView {
30
30
  app.renderer.resize(sizes.width, sizes.height);
31
31
  }
32
32
 
33
+ // переводит экранную точку (clientX/clientY указателя) в мировую
34
+ //
35
+ // Считается по фактическому состоянию сцены, а не по копии камеры: stage
36
+ // уже несёт и смещение, и масштаб последнего updateCoords, а
37
+ // getBoundingClientRect закрывает случай, когда CSS растянул полотно
38
+ // не один к одному с его буфером.
39
+ toWorld(id, clientX, clientY) {
40
+ const app = this._apps[id];
41
+
42
+ if (!app) {
43
+ return null;
44
+ }
45
+
46
+ const rect = app.canvas.getBoundingClientRect();
47
+
48
+ if (!rect.width || !rect.height) {
49
+ return null;
50
+ }
51
+
52
+ const px = ((clientX - rect.left) * app.canvas.width) / rect.width;
53
+ const py = ((clientY - rect.top) * app.canvas.height) / rect.height;
54
+ const { position, scale } = app.stage;
55
+
56
+ if (!scale.x || !scale.y) {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ x: (px - position.x) / scale.x,
62
+ y: (py - position.y) / scale.y,
63
+ };
64
+ }
65
+
33
66
  // вычисляет координаты для отображения и обновляет полотно
34
67
  updateCoords({ id, coords, scale }) {
35
68
  const app = this._apps[id];
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ // Сервис пула зависимостей `localPlayer`: отвечает part'у на один вопрос —
2
+ // «эта сущность моя или чужая?».
3
+ //
4
+ // Свой gameId знает только клиентское ядро (ClientCore.my_game_id()), а part
5
+ // получает id своего экземпляра четвёртым аргументом конструктора
6
+ // (GameModel.create → { id }). Свести одно с другим внутри игры было нечем:
7
+ // поэтому сервис общий, а не переизобретается каждой игрой заново.
8
+ //
9
+ // Читает id ЛЕНИВО, на каждый вызов: player-блок приходит только с первым
10
+ // бинарным кадром, а сущности создаются раньше — из FIRST_SHOT_DATA. Флаг,
11
+ // вычисленный в конструкторе part'а, был бы для своего же персонажа ложным.
12
+ //
13
+ // getCore — геттер ядра, а не само ядро: ядро создаётся позже пула сервисов и
14
+ // пересоздаётся на смене игры.
15
+ export function createLocalPlayer(getCore) {
16
+ return {
17
+ // свой gameId или null, пока player-блок не пришёл (ядро отдаёт -1)
18
+ get id() {
19
+ const id = getCore()?.my_game_id?.();
20
+
21
+ return typeof id === 'number' && id >= 0 ? id : null;
22
+ },
23
+
24
+ // id сущности приходит в part ключом объекта кадра, то есть СТРОКОЙ —
25
+ // сравнение приводит обе стороны к строке, иначе '3' !== 3 и звук своего
26
+ // персонажа не отличить от чужого
27
+ is(id) {
28
+ const my = this.id;
29
+
30
+ if (my === null || id === null || id === undefined) {
31
+ return false;
32
+ }
33
+
34
+ return String(id) === String(my);
35
+ },
36
+ };
37
+ }
@@ -31,11 +31,12 @@ 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';
38
38
  import { createContextTracker } from './lib/contextTracker.js';
39
+ import { createLocalPlayer } from './lib/localPlayer.js';
39
40
  import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
40
41
  import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
41
42
  import {
@@ -79,18 +80,36 @@ import lobbyConfig from '../config/lobby.js';
79
80
  import authClientConfig from '../config/authClient.js';
80
81
  import clientDefaults from '../config/clientDefaults.js';
81
82
 
82
- // Динамическая загрузка игры по каталогу мастера (Этап 6.3): активная для
83
- // host остаётся первой записью каталога (граница lobby-page-plan динамика
84
- // загрузки ClientPlugin выбранной игры откладывается до появления второй
85
- // игры), но сам каталог манифестов (gamesManifest) теперь используется
86
- // целиком селектором игр и Leaderboard (populateGameSelect/gamesById в
87
- // initLobby). ClientPlugin (parts, bakers, игровой CSS, хуки ядра) грузится
88
- // по 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-режим перезагружает страницу
89
91
  let activeGameManifest;
90
92
  let clientPlugin;
91
- let parts;
92
93
  let gamesManifest;
93
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
+
94
113
  // режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
95
114
  // solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
96
115
  // Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
@@ -137,12 +156,10 @@ try {
137
156
  throw new Error('master has no games in its catalog');
138
157
  }
139
158
 
140
- clientPlugin = boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest));
141
- parts = clientPlugin.parts;
142
-
143
- const gameStyle = document.createElement('style');
144
- gameStyle.textContent = clientPlugin.styles;
145
- document.head.append(gameStyle);
159
+ bindActiveGame(
160
+ activeGameManifest,
161
+ boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
162
+ );
146
163
  } catch (e) {
147
164
  document.body.textContent = `Failed to load the game: ${e.message}`;
148
165
  throw e;
@@ -260,6 +277,10 @@ const socketMethods = []; // методы для обработки сокет-
260
277
  // получении конфига; wasm — результат init() для zero-copy чтения памяти
261
278
  let clientCore = null;
262
279
  let wasm = null;
280
+
281
+ // сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
282
+ // геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
283
+ const localPlayer = createLocalPlayer(() => clientCore);
263
284
  let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
264
285
 
265
286
  // обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
@@ -291,7 +312,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
291
312
 
292
313
  // инициализация сущностей игры
293
314
  for (const entity of Object.keys(entitiesOnCanvas)) {
294
- Factory.add({ [entity]: parts[entity] });
315
+ Factory.add({ [entity]: clientPlugin.parts[entity] });
295
316
  }
296
317
 
297
318
  gameInformer = document.getElementById(data.gameInform.id);
@@ -345,6 +366,10 @@ socketMethods[PS_CONFIG_DATA] = async data => {
345
366
  const availableServices = {
346
367
  renderer: app.renderer,
347
368
  soundManager,
369
+ // «свой ли это персонаж»: part сравнивает id своего экземпляра (четвёртый
370
+ // аргумент конструктора) со своим gameId. Игра без этого сервиса звучит
371
+ // одинаково за всех — чужие подборы и чужие смерти вперемешку со своими
372
+ localPlayer,
348
373
  // база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
349
374
  // картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
350
375
  // part, объявивший сервис в componentDependencies, строит URL сам —
@@ -912,6 +937,10 @@ function runModules(data) {
912
937
  'mouseAction',
913
938
  modules.controls.resetCursorHideTimer.bind(modules.controls),
914
939
  );
940
+ inputListener.publisher.on(
941
+ 'pointerAction',
942
+ modules.controls.addPointer.bind(modules.controls),
943
+ );
915
944
  inputListener.publisher.on(
916
945
  'resize',
917
946
  modules.canvasManager.resize.bind(modules.canvasManager),
@@ -955,6 +984,25 @@ function runModules(data) {
955
984
 
956
985
  sending(PC_KEYS_DATA, `${inputSeq}:${data}`);
957
986
  });
987
+
988
+ // указатель: экранная точка -> мировая (камера и масштаб полотна знает
989
+ // только движок), дальше тем же портом, что и клавиши
990
+ controlsModel.publisher.on('aim', ({ x, y, flags }) => {
991
+ const world = modules.canvasManager.toWorld(x, y);
992
+
993
+ if (!world) {
994
+ return;
995
+ }
996
+
997
+ const wx = Math.round(world.x * 100) / 100;
998
+ const wy = Math.round(world.y * 100) / 100;
999
+
1000
+ inputSeq = (inputSeq + 1) >>> 0;
1001
+ clientCore?.apply_aim?.(wx, wy, flags, performance.now());
1002
+
1003
+ // формат wire: 'seq:aim:x:y:flags' рядом с 'seq:action:name'
1004
+ sending(PC_KEYS_DATA, `${inputSeq}:aim:${wx}:${wy}:${flags}`);
1005
+ });
958
1006
  chatModel.publisher.on('socket', handleChatSend);
959
1007
  voteModel.publisher.on('socket', data => sending(PC_VOTE_DATA, data));
960
1008
 
@@ -1796,13 +1844,62 @@ function populateRoomForm(manifest) {
1796
1844
  }
1797
1845
  }
1798
1846
 
1799
- // каталог манифестов по id (lobby-page-plan): достаточно для формы и
1800
- // leaderboard селектора игр активная игра для host остаётся
1801
- // gamesManifest[0] (граница задачи, plan/lobby-page-plan.md)
1847
+ // каталог манифестов по id: форма и leaderboard селектора игр, а также
1848
+ // активация игры перед созданием комнаты и входом в чужую
1802
1849
  const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
1803
1850
 
1804
- // заполняет #lobby-game всем каталогом манифестов мастера (lobby-page-plan)
1805
- // раньше туда попадал только 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], теперь селектор рабочий
1806
1903
  function populateGameSelect() {
1807
1904
  const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
1808
1905
 
@@ -1853,8 +1950,16 @@ function initLobby() {
1853
1950
  lobbyModel.resolvePong(msg.pingId, performance.now());
1854
1951
  });
1855
1952
 
1856
- // выбор сервера → установка P2P
1857
- 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
+ });
1858
1963
 
1859
1964
  // Leaderboard (lobby-page-plan): контроллер сигнализирует, для какой игры
1860
1965
  // нужны свежие данные (смена #lobby-game или первое открытие вкладки).
@@ -1896,9 +2001,8 @@ function initLobby() {
1896
2001
  populateRoomForm(activeGameManifest);
1897
2002
  lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
1898
2003
 
1899
- // селектор игр (lobby-page-plan): работает с каталогом манифестов меняет
1900
- // форму создания комнаты и Leaderboard, но НЕ активную игру для host
1901
- // (граница задачи, см. plan/lobby-page-plan.md)
2004
+ // селектор игр: меняет форму создания комнаты и Leaderboard сразу
2005
+ // (синхронно, без сети); сама игра активируется уже по клику
1902
2006
  const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
1903
2007
 
1904
2008
  const hostBtn = document.getElementById(lobbyConfig.elems.hostBtnId);
@@ -1912,47 +2016,47 @@ function initLobby() {
1912
2016
 
1913
2017
  populateRoomForm(manifest);
1914
2018
  lobby.gameChanged(manifest.id, manifest.title);
1915
-
1916
- if (hostBtn) {
1917
- const { disabled, title } = getHostGateState(manifest.id, activeGameManifest);
1918
-
1919
- hostBtn.disabled = disabled;
1920
- hostBtn.title = title;
1921
- }
2019
+ clearLobbyError();
1922
2020
  });
1923
2021
 
1924
2022
  const nameInput = document.getElementById(lobbyConfig.elems.nameId);
1925
2023
 
1926
- hostBtn?.addEventListener('click', () => {
1927
- // защита в глубину поверх hostBtn.disabled (code review lobby-page): клик
1928
- // не должен создать комнату с полями формы чужой игры, даже если
1929
- // disabled почему-то не выставился/был обойдён
1930
- if (gameSelect && getHostGateState(gameSelect.value, activeGameManifest).disabled) {
1931
- return;
1932
- }
1933
-
2024
+ hostBtn?.addEventListener('click', async () => {
1934
2025
  // нативная валидация (pattern/required) — единственная граница
1935
2026
  // room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
1936
2027
  // (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
1937
2028
  // "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
1938
- // (вызывается из host.worker.js при создании комнаты)
2029
+ // (вызывается из host.worker.js при создании комнаты). Проверяем до
2030
+ // активации игры: неверная форма не должна стоить загрузки плагина
1939
2031
  if (
1940
2032
  !reportFormValidity(document.getElementById(lobbyConfig.elems.fieldsId))
1941
2033
  ) {
1942
2034
  return;
1943
2035
  }
1944
2036
 
2037
+ const manifest = gamesById.get(gameSelect?.value) ?? activeGameManifest;
1945
2038
  const name =
1946
2039
  (nameInput?.value || '').trim() || lobbyConfig.create.defaultName;
1947
- const { roomDefaults } = activeGameManifest;
1948
2040
 
1949
- // дефолты манифеста перекрываются значениями сгенерированных полей
1950
- const overrides = { ...roomDefaults };
2041
+ // значения формы снимаем до await: активация асинхронна, а roomFormFields
2042
+ // пересобираются при смене игры — в комнату должно уехать то, что игрок
2043
+ // видел в момент клика
2044
+ const overrides = { ...manifest.roomDefaults };
1951
2045
 
1952
2046
  for (const [key, field] of roomFormFields) {
1953
2047
  overrides[key] = field.getValue();
1954
2048
  }
1955
2049
 
2050
+ // повторный клик, пока грузится плагин, не должен поднять вторую комнату;
2051
+ // при успехе лобби закрывается, разблокировка нужна только на отказе
2052
+ hostBtn.disabled = true;
2053
+
2054
+ if (!(await selectActiveGame(manifest.id))) {
2055
+ hostBtn.disabled = false;
2056
+
2057
+ return;
2058
+ }
2059
+
1956
2060
  connectAsHost({
1957
2061
  name,
1958
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%;
@@ -115,6 +115,9 @@ export function ensureCanvas(canvasId, size, container = document.body) {
115
115
  canvas.setAttribute('id', canvasId);
116
116
  canvas.width = size.width;
117
117
  canvas.height = size.height;
118
+ // палец на полотне ведёт игру, а не страницу: без этого браузер
119
+ // смартфона забирает жест себе — прокрутка, pull-to-refresh, зум
120
+ canvas.style.touchAction = 'none';
118
121
  container.appendChild(canvas);
119
122
  }
120
123
 
@@ -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) — движок не знает игровых полей
@@ -24,8 +24,11 @@ export default {
24
24
  // не в packages/engine — vimp-engine остаётся game-agnostic и не тянет
25
25
  // конкретную игру за собой (кодревью Этапов A, находка F1). Версию задаёт
26
26
  // именно эта зависимость, каталог берёт её из dist/manifest.json пакета.
27
- // В проде список переопределяется переменной окружения GAMES_MATRIX (JSON),
28
- // см. master/main.js
27
+ // Список переопределяется переменной окружения GAMES_MATRIX (JSON) — её
28
+ // задаёт деплой; вне прода каталог вдобавок дополняется собранными
29
+ // пакетами @vimp-games/* из node_modules (master/localGames.js), чтобы
30
+ // прилинкованная игра попадала в лобби без правки этого массива: он
31
+ // публикуется вместе с пакетом vimp-engine
29
32
  games: [{ id: 'tanks', package: '@vimp-games/tanks' }],
30
33
 
31
34
  // список серверов (GET /servers)
@@ -40,7 +43,11 @@ export default {
40
43
  // ограничения регистрируемых комнат
41
44
  host: {
42
45
  maxNameLength: 30, // длина имени комнаты
43
- maxPlayersLimit: 8, // целевой размер комнаты (рамка P2P-плана)
46
+ // санитарная рамка вместимости комнаты для случая, когда игра комнаты
47
+ // неизвестна мастеру (gameId: null у старых хостов или id не из
48
+ // каталога). Потолок известной игры задаёт её манифест
49
+ // (roomDefaults.maxPlayers) — движок его не ограничивает
50
+ maxPlayersLimit: 8,
44
51
  heartbeatTimeout: 30000, // нет heartbeat дольше — комната удаляется
45
52
  sweepInterval: 10000, // период проверки протухших комнат
46
53
  },
@@ -363,6 +363,23 @@ async function applyOp(op, ctx) {
363
363
  break;
364
364
  }
365
365
 
366
+ case 'aim': {
367
+ const entry = requireEntry(byParticipant, op.who);
368
+ const seq = (inputSeq.get(op.who) ?? 0) + 1;
369
+ const flags = op.flags ?? 1;
370
+
371
+ inputSeq.set(op.who, seq);
372
+ host.updateKeys(entry.gameId, `${seq}:aim:${op.x}:${op.y}:${flags}`);
373
+
374
+ // клиент предсказывает тот же ввод — как и с клавишами, иначе дрейф
375
+ // предикта не с чем сравнивать
376
+ clients
377
+ .get(entry.socketId)
378
+ ?.core.apply_aim?.(op.x, op.y, flags, virtualClock.monotonic());
379
+
380
+ break;
381
+ }
382
+
366
383
  case 'chat':
367
384
  host.pushMessage(requireEntry(byParticipant, op.who).gameId, op.text);
368
385
  break;
@@ -1,15 +1,13 @@
1
1
  import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
- // Движковые команды разбираются switch'ем раньше реестра игровых
4
- // (CommandProcessor.parseCommand): одноимённая команда плагина
5
- // регистрируется, но не вызывается никогда.
6
- const RESERVED = ['/name', '/nr', '/timeleft', '/mapname', '/rank'];
7
-
3
+ // Своих команд у движка нет: реестр CommandProcessor наполняет только игра,
4
+ // поэтому зарезервированных имён не существует. Проверяем форму: ведущий слэш
5
+ // и отсутствие дублей вторая регистрация одного имени молча затирает первую.
8
6
  export default {
9
7
  id: 'B7',
10
8
  name: 'chatCommands',
11
9
  level: ERROR,
12
- title: 'chat commands do not shadow the engine commands',
10
+ title: 'chat commands are well-formed and unique',
13
11
 
14
12
  check(ctx) {
15
13
  const commands = ctx.hostPlugin?.chatCommands;
@@ -31,10 +29,8 @@ export default {
31
29
  continue;
32
30
  }
33
31
 
34
- if (RESERVED.includes(name)) {
35
- violations.push(
36
- `chat command "${name}" is an engine command — it will never fire`,
37
- );
32
+ if (typeof command.handler !== 'function') {
33
+ violations.push(`chat command "${name}" has no handler function`);
38
34
  }
39
35
 
40
36
  if (seen.has(name)) {
@@ -1,9 +1,9 @@
1
1
  import { ERROR, skip, verdict } from '../result.js';
2
2
 
3
- // Пул сервисов клиента ровно из трёх имён (client/main.js). Незнакомое имя
3
+ // Пул сервисов клиента ровно из четырёх имён (client/main.js). Незнакомое имя
4
4
  // не ошибка для движка: part получает undefined и рисует пустоту — карта
5
5
  // без assetsBase выглядит как чистый холст без единой строки в консоли.
6
- const SERVICES = ['renderer', 'soundManager', 'assetsBase'];
6
+ const SERVICES = ['renderer', 'soundManager', 'assetsBase', 'localPlayer'];
7
7
 
8
8
  export default {
9
9
  id: 'C4',
@@ -112,6 +112,10 @@ export default class DebugRecorder {
112
112
  this._pushFor(gameId, who => ({ op: 'key', who, action, name }));
113
113
  }
114
114
 
115
+ noteAim(gameId, x, y, flags) {
116
+ this._pushFor(gameId, who => ({ op: 'aim', who, x, y, flags }));
117
+ }
118
+
115
119
  noteChat(gameId, text) {
116
120
  this._pushFor(gameId, who => ({ op: 'chat', who, text }));
117
121
  }
@@ -88,6 +88,14 @@ export default class GameCoreAdapter {
88
88
  this._core.apply_input(gameId, seq, action, name);
89
89
  }
90
90
 
91
+ // ввод указателем: мировая точка + биты состояния (бит 0 — прижат,
92
+ // бит 1 — двойной тап). У трейта дефолтная пустая реализация, но ядро
93
+ // игры, собранное против крейта старше 0.4, метода не экспортирует
94
+ // вовсе — такой игре канал просто не доезжает, вместо падения хоста
95
+ applyAim(gameId, seq, x, y, flags) {
96
+ this._core.apply_aim?.(gameId, seq, x, y, flags);
97
+ }
98
+
91
99
  // ***** запросы состояния ***** //
92
100
 
93
101
  isAlive(gameId) {
@@ -870,7 +870,9 @@ export default class HostGame {
870
870
  // финальная синхронизация rank/state перед уходом участника (Этап B4)
871
871
  this._playerDataSync
872
872
  .flush(gameId)
873
- .catch(err => console.warn('[playerData] final flush failed:', err.message))
873
+ .catch(err =>
874
+ console.warn('[playerData] final flush failed:', err.message),
875
+ )
874
876
  .finally(() => this._playerDataSync.removeUser(gameId));
875
877
 
876
878
  // если не наблюдатель — удалить танк из ядра (null-маркер ставит ядро)
@@ -883,7 +885,8 @@ export default class HostGame {
883
885
  this._chat.pushSystem('USER_LEFT', [user.name]);
884
886
  }
885
887
 
886
- // обновляет команды (формат wire: 'seq:action:name')
888
+ // обновляет команды (формат wire: 'seq:action:name', указатель —
889
+ // 'seq:aim:x:y:flags')
887
890
  updateKeys(gameId, keyStr) {
888
891
  const user = this._participants.get(gameId);
889
892
 
@@ -891,7 +894,33 @@ export default class HostGame {
891
894
  return;
892
895
  }
893
896
 
894
- const [seq, action, name] = keyStr.split(':');
897
+ const [seq, action, name, aimY, aimFlags] = keyStr.split(':');
898
+
899
+ // ввод указателем: наблюдателю рулить нечем, живому актору — мировая
900
+ // точка и биты состояния указателя
901
+ if (action === 'aim') {
902
+ this._recorder?.noteAim(
903
+ gameId,
904
+ Number(name),
905
+ Number(aimY),
906
+ Number(aimFlags) >>> 0,
907
+ );
908
+
909
+ user.lastActionTime = clock.now();
910
+ user.lastInputSeq = Number(seq) >>> 0;
911
+
912
+ if (user.isWatching !== true) {
913
+ this._game.applyAim(
914
+ gameId,
915
+ user.lastInputSeq,
916
+ Number(name),
917
+ Number(aimY),
918
+ Number(aimFlags) >>> 0,
919
+ );
920
+ }
921
+
922
+ return;
923
+ }
895
924
 
896
925
  this._recorder?.noteKey(gameId, action, name);
897
926
 
@@ -270,7 +270,7 @@ export default class PortMachine {
270
270
  // 4: first shot ready
271
271
  () => host.firstShotReady(state.gameId),
272
272
 
273
- // 5: keys data ('seq:action:name')
273
+ // 5: keys data ('seq:action:name'; указатель — 'seq:aim:x:y:flags')
274
274
  keyEventString => {
275
275
  if (typeof keyEventString === 'string') {
276
276
  host.updateKeys(state.gameId, keyEventString);
@@ -1,16 +1,19 @@
1
- // Обработчик чат-команд. Движковое ядро: /name, /nr, /timeleft, /mapname,
2
- // /rank; игровые команды (HostPlugin.chatCommands) регистрируются через
3
- // registerCommand и получают контекст меты: handler(ctx, gameId, args).
1
+ // Обработчик чат-команд. Своих команд у движка НЕТ: реестр целиком наполняет
2
+ // игра через HostPlugin.chatCommands, а обработчик получает контекст меты —
3
+ // handler(ctx, gameId, args). Одно и то же имя (/name, /bot, /timeleft …) в
4
+ // разных играх может делать разное или отсутствовать вовсе; незнакомая
5
+ // команда — COMMANDS_NOT_FOUND.
6
+ //
7
+ // Всё, что раньше разбирал движковый switch, доступно играм через контекст:
8
+ // roundManager (смена ника, новый раунд, текущая карта), timerManager
9
+ // (остаток времени карты), playerDataSync (ранг), chat, isDevMode.
4
10
  class CommandProcessor {
5
11
  constructor(deps) {
6
12
  this._chat = deps.chat;
7
- this._roundManager = deps.roundManager;
8
- this._timerManager = deps.timerManager;
9
- this._playerDataSync = deps.playerDataSync;
10
- this._isDevMode = deps.isDevMode;
11
13
 
12
14
  // контекст игровых команд (participants, chat, scripted, roundManager,
13
- // voteCoordinator, teams, spectatorTeam, spectatorId, …)
15
+ // voteCoordinator, timerManager, playerDataSync, teams, spectatorTeam,
16
+ // spectatorId, isDevMode)
14
17
  this._ctx = deps;
15
18
 
16
19
  this._commands = new Map();
@@ -31,67 +34,12 @@ class CommandProcessor {
31
34
 
32
35
  const arr = message.split(' ');
33
36
  const cmd = arr.shift();
37
+ const handler = this._commands.get(cmd);
34
38
 
35
- switch (cmd) {
36
- // смена ника
37
- case '/name':
38
- this._roundManager.changeName(gameId, arr.join(' '));
39
- break;
40
-
41
- // новый раунд
42
- case '/nr':
43
- if (this._isDevMode) {
44
- this._roundManager.initiateNewRound();
45
- } else {
46
- this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
47
- }
48
- break;
49
-
50
- // время карты
51
- case '/timeleft': {
52
- function getTime(ms) {
53
- const totalSeconds = Math.floor(ms / 1000);
54
- let minutes = Math.floor(totalSeconds / 60);
55
- let seconds = totalSeconds % 60;
56
-
57
- if (minutes < 10) {
58
- minutes = '0' + minutes;
59
- }
60
-
61
- if (seconds < 10) {
62
- seconds = '0' + seconds;
63
- }
64
-
65
- return `${minutes}:${seconds}`;
66
- }
67
-
68
- this._chat.pushSystemByUser(gameId, [
69
- getTime(this._timerManager.getMapTimeLeft()),
70
- ]);
71
- break;
72
- }
73
-
74
- // название текущей карты
75
- case '/mapname':
76
- this._chat.pushSystemByUser(gameId, [this._roundManager.currentMap]);
77
- break;
78
-
79
- // ранг игрока (Этап B4/B5: PlayerDataSync, подгружен с auth-сервиса)
80
- case '/rank':
81
- this._chat.pushSystemByUser(gameId, 'RANK', [
82
- this._playerDataSync.getRank(gameId),
83
- ]);
84
- break;
85
-
86
- default: {
87
- const handler = this._commands.get(cmd);
88
-
89
- if (handler) {
90
- handler(this._ctx, gameId, arr);
91
- } else {
92
- this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
93
- }
94
- }
39
+ if (handler) {
40
+ handler(this._ctx, gameId, arr);
41
+ } else {
42
+ this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
95
43
  }
96
44
  }
97
45
  }
@@ -112,6 +112,23 @@ export default class FakeGameCore {
112
112
  }
113
113
  }
114
114
 
115
+ // ввод указателем: миниигра просто запоминает последнюю точку и биты —
116
+ // движковым тестам важно, что канал доходит до ядра
117
+ apply_aim(gameId, seq, x, y, flags) {
118
+ const actor = this._actors.get(gameId);
119
+
120
+ if (!actor) {
121
+ return;
122
+ }
123
+
124
+ actor.lastInputSeq = seq;
125
+ actor.aim = { x, y, flags };
126
+ }
127
+
128
+ aim_of(gameId) {
129
+ return this._actors.get(gameId)?.aim ?? null;
130
+ }
131
+
115
132
  last_input_seq(gameId) {
116
133
  return this._actors.get(gameId)?.lastInputSeq ?? 0;
117
134
  }
@@ -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
- }