vimp-engine 0.40.0 → 0.41.0

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.
@@ -177,6 +177,39 @@ body.hide-cursor {
177
177
  background-color: #333;
178
178
  }
179
179
 
180
+ /* ячейки с подписью (panel.cells): подпись, значение и шкала */
181
+ #panel table td {
182
+ position: relative;
183
+ }
184
+
185
+ #panel table td .panel-label {
186
+ opacity: 0.6;
187
+ margin-right: 0.4em;
188
+ }
189
+
190
+ #panel table td.is-empty {
191
+ opacity: 0.5;
192
+ }
193
+
194
+ #panel table td div.panel-gauge {
195
+ position: absolute;
196
+ left: 0;
197
+ bottom: 0;
198
+ width: 0;
199
+ height: 2px;
200
+ background-color: currentColor;
201
+ }
202
+
203
+ @keyframes panel-gauge-fill {
204
+ from {
205
+ width: 0;
206
+ }
207
+
208
+ to {
209
+ width: 100%;
210
+ }
211
+ }
212
+
180
213
  /* bar-поля панели (type: 'bar' в схеме игры) — классы нейтральные,
181
214
  без привязки к игровым DOM-id */
182
215
  #panel table td div.panel-bar-wrapper {
@@ -211,6 +244,11 @@ body.hide-cursor {
211
244
  #panel table td div.panel-bar-blink {
212
245
  animation: none;
213
246
  }
247
+
248
+ #panel table td div.panel-gauge {
249
+ animation: none !important;
250
+ width: 100%;
251
+ }
214
252
  }
215
253
 
216
254
  /*stat*/
@@ -314,6 +352,29 @@ body.hide-cursor {
314
352
  text-align: right;
315
353
  }
316
354
 
355
+ /*menu hint*/
356
+
357
+ /* Подсказка личного меню (корзина) под местом HUD связи; игра
358
+ перестилизует её по #menu-hints и .menu-hint.is-on. Селектор по id
359
+ перебивает `.vimp-shell > * { display: none }` */
360
+ #menu-hints {
361
+ display: block;
362
+ position: absolute;
363
+ right: 24px;
364
+ top: calc(50% + 44px);
365
+ z-index: 4;
366
+ pointer-events: none;
367
+ color: #fff;
368
+ }
369
+
370
+ #menu-hints .menu-hint {
371
+ display: none;
372
+ }
373
+
374
+ #menu-hints .menu-hint.is-on {
375
+ display: block;
376
+ }
377
+
317
378
  /*voice*/
318
379
 
319
380
  /* Нейтральный вид HUD связи; игра перестилизует его по #voice-hud и
@@ -331,7 +392,7 @@ body.hide-cursor {
331
392
  gap: 8px;
332
393
  color: #fff;
333
394
  font-size: 14px;
334
- text-shadow: 1px 1px 1px #000;
395
+ text-shadow: 0 0 1px #000;
335
396
  }
336
397
 
337
398
  #voice-hud.is-talking,
@@ -358,6 +419,12 @@ body.hide-cursor {
358
419
  display: flex;
359
420
  }
360
421
 
422
+ /* тёмная обводка: серый и зелёный значок читаются на светлой карте */
423
+ #voice-hud .voice-icon,
424
+ #voice-hud .voice-ring {
425
+ filter: drop-shadow(0 0 1px #000);
426
+ }
427
+
361
428
  #voice-hud .voice-icon {
362
429
  width: 32px;
363
430
  height: 32px;
@@ -375,17 +442,22 @@ body.hide-cursor {
375
442
  display: none;
376
443
  }
377
444
 
445
+ #voice-hud .voice-mark {
446
+ position: relative;
447
+ display: flex;
448
+ }
449
+
378
450
  #voice-hud .voice-ring {
379
451
  display: none;
380
452
  position: absolute;
381
- left: -2px;
382
- top: -2px;
453
+ left: 50%;
454
+ top: 50%;
383
455
  width: 36px;
384
456
  height: 36px;
385
457
  fill: none;
386
458
  stroke: currentColor;
387
459
  stroke-width: 2;
388
- transform: rotate(-90deg);
460
+ transform: translate(-50%, -50%) rotate(-90deg);
389
461
  }
390
462
 
391
463
  #voice-hud.is-talking .voice-ring {
@@ -489,6 +561,11 @@ body.hide-cursor {
489
561
  overflow: hidden;
490
562
  }
491
563
 
564
+ /* недоступный пункт личного меню */
565
+ #vote .vote-list li.disabled {
566
+ opacity: 0.4;
567
+ }
568
+
492
569
  #vote .vote-nav {
493
570
  padding: 0 0 0 5px;
494
571
  margin-top: 10px;
@@ -186,7 +186,9 @@ export default class MicCapture {
186
186
  try {
187
187
  stream = await this._mediaDevices.getUserMedia({
188
188
  audio: {
189
- echoCancellation: true,
189
+ // эхоподавление включает системную обработку голоса (macOS) и
190
+ // приглушает весь остальной звук на время открытия и закрытия
191
+ echoCancellation: false,
190
192
  noiseSuppression: true,
191
193
  autoGainControl: true,
192
194
  channelCount: 1,
@@ -23,6 +23,43 @@ export const abiOps = createRegistry('abiOps', [
23
23
  payload: 'none',
24
24
  response: 'json',
25
25
  },
26
+ // ответ игрока на личное меню: { id, name, path } (путь листа «2.1»)
27
+ {
28
+ value: 'menu.answer',
29
+ since: 4,
30
+ payload: 'json',
31
+ response: 'none',
32
+ },
33
+ // участник покинул матч: { id } — ядро игры удаляет всё, что хранит о нём
34
+ {
35
+ value: 'actor.forget',
36
+ since: 4,
37
+ payload: 'json',
38
+ response: 'none',
39
+ },
40
+ // новая игра: движок обнулил счёт, ядро игры сбрасывает накопленное
41
+ // между раундами
42
+ {
43
+ value: 'match.reset',
44
+ since: 4,
45
+ payload: 'json',
46
+ response: 'none',
47
+ },
48
+ // состояние игры между раундами: запрос { roundOver }, ответ — JSON игры,
49
+ // который переживает мягкую миграцию хоста и эстафету Worker'а
50
+ {
51
+ value: 'state.persist',
52
+ since: 4,
53
+ payload: 'json',
54
+ response: 'json',
55
+ },
56
+ // тот же JSON обратно новому ядру до первого раунда
57
+ {
58
+ value: 'state.restore',
59
+ since: 4,
60
+ payload: 'json',
61
+ response: 'none',
62
+ },
26
63
  ]);
27
64
 
28
65
  // плоский список активных опкодов — то, что вправе позвать движок
@@ -33,5 +70,10 @@ export const ABI_OPS = abiOps.values();
33
70
  // опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
34
71
  // CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
35
72
  export const ABI_OP_DEBUG_JSON = 'debug.json';
73
+ export const ABI_OP_MENU_ANSWER = 'menu.answer';
74
+ export const ABI_OP_ACTOR_FORGET = 'actor.forget';
75
+ export const ABI_OP_MATCH_RESET = 'match.reset';
76
+ export const ABI_OP_STATE_PERSIST = 'state.persist';
77
+ export const ABI_OP_STATE_RESTORE = 'state.restore';
36
78
 
37
79
  export default abiOps;
@@ -0,0 +1,3 @@
1
+ // формат имени личного меню: проверяется и хостом (ответ игрока), и клиентом
2
+ // (данные недоверенного хоста)
3
+ export const MENU_NAME_PATTERN = /^[a-z][a-zA-Z0-9]{0,15}$/;
@@ -10,7 +10,14 @@
10
10
  // (spawn_scripted_actor/remove_scripted_actor), человек — только танк
11
11
  // (spawn_actor/remove_actor).
12
12
 
13
- import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
13
+ import {
14
+ ABI_OP_ACTOR_FORGET,
15
+ ABI_OP_DEBUG_JSON,
16
+ ABI_OP_MATCH_RESET,
17
+ ABI_OP_MENU_ANSWER,
18
+ ABI_OP_STATE_PERSIST,
19
+ ABI_OP_STATE_RESTORE,
20
+ } from '../config/abiOps.js';
14
21
  import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
15
22
 
16
23
  export default class GameCoreAdapter {
@@ -26,7 +33,7 @@ export default class GameCoreAdapter {
26
33
  this._core = core;
27
34
  this._participants = participants;
28
35
  this._onCoreEvent = onCoreEvent;
29
- this._services = {}; // { vimp, panel } — инъекция как у Game.js
36
+ this._services = {}; // { vimp, panel, vote } — инъекция как у Game.js
30
37
 
31
38
  // Метода нет (или самоописание нечитаемо) — ядро собрано до появления
32
39
  // механизма. Это не ошибка: поколение 0, ни одного опционального опкода
@@ -44,7 +51,7 @@ export default class GameCoreAdapter {
44
51
  return this._abi;
45
52
  }
46
53
 
47
- // получает сервисы (аналог Game.injectServices): { vimp, panel }
54
+ // получает сервисы (аналог Game.injectServices): { vimp, panel, vote }
48
55
  injectServices(services) {
49
56
  Object.assign(this._services, services);
50
57
  }
@@ -159,7 +166,7 @@ export default class GameCoreAdapter {
159
166
  // vimp }); 'custom' (игровой смысл вне словаря) — в HostPlugin.onCoreEvent
160
167
  _drainEvents() {
161
168
  const events = JSON.parse(this._core.take_events());
162
- const { panel, vimp } = this._services;
169
+ const { panel, vimp, vote } = this._services;
163
170
 
164
171
  for (const event of events) {
165
172
  switch (event.type) {
@@ -182,6 +189,20 @@ export default class GameCoreAdapter {
182
189
  });
183
190
  break;
184
191
 
192
+ case 'menu':
193
+ this._pushPersonal(vote, event.id, { menu: event.menu });
194
+ break;
195
+
196
+ case 'menuClose':
197
+ this._pushPersonal(vote, event.id, { menuClose: event.name });
198
+ break;
199
+
200
+ case 'menuHint':
201
+ this._pushPersonal(vote, event.id, {
202
+ menuHint: { name: event.name, on: event.on },
203
+ });
204
+ break;
205
+
185
206
  case 'custom':
186
207
  this._onCoreEvent?.(event.data, this._services);
187
208
  break;
@@ -189,6 +210,60 @@ export default class GameCoreAdapter {
189
210
  }
190
211
  }
191
212
 
213
+ // личное сообщение меню адресату; получатель — только человек с сокетом
214
+ // (бот и ушедший id отбрасываются: у них нет личной очереди)
215
+ _pushPersonal(vote, id, payload) {
216
+ const gameId = String(id);
217
+
218
+ if (vote && this._participants.get(gameId)?.isNetworked) {
219
+ vote.pushPersonal(gameId, payload);
220
+ }
221
+ }
222
+
223
+ // ***** опкоды игры ***** //
224
+
225
+ // ответ игрока на личное меню; ядро без опкода молча игнорирует
226
+ menuAnswer(gameId, name, path) {
227
+ this._op(
228
+ ABI_OP_MENU_ANSWER,
229
+ encodeJson({ id: Number(gameId), name, path }),
230
+ );
231
+ }
232
+
233
+ // участник покинул матч (в том числе наблюдатель)
234
+ forgetActor(gameId) {
235
+ this._op(ABI_OP_ACTOR_FORGET, encodeJson({ id: Number(gameId) }));
236
+ }
237
+
238
+ // новая игра: движок обнулил счёт
239
+ resetMatch() {
240
+ this._op(ABI_OP_MATCH_RESET, encodeJson({}));
241
+ }
242
+
243
+ /**
244
+ * Состояние игры между раундами для мягкой миграции и эстафеты.
245
+ * @param {{roundOver: boolean}} request - `roundOver` — раунд закончился,
246
+ * но ядро его ещё не подвело (эстафета на границе раунда).
247
+ * @returns {Object|null} JSON игры; `null` — ядро опкода не знает.
248
+ */
249
+ persistState({ roundOver }) {
250
+ const { handled, bytes } = this._op(
251
+ ABI_OP_STATE_PERSIST,
252
+ encodeJson({ roundOver }),
253
+ );
254
+
255
+ if (handled && bytes !== null) {
256
+ return JSON.parse(new TextDecoder().decode(bytes));
257
+ }
258
+
259
+ return null;
260
+ }
261
+
262
+ // отдаёт новому ядру JSON, снятый persistState
263
+ restorePersisted(state) {
264
+ this._op(ABI_OP_STATE_RESTORE, encodeJson(state));
265
+ }
266
+
192
267
  // ***** упаковка снапшота ***** //
193
268
 
194
269
  // пакует broadcast-тело кадра (один раз за отправляемый кадр)
@@ -333,6 +408,10 @@ export default class GameCoreAdapter {
333
408
  }
334
409
  }
335
410
 
411
+ function encodeJson(value) {
412
+ return new TextEncoder().encode(JSON.stringify(value));
413
+ }
414
+
336
415
  // JsError wasm-bindgen и обычный Error — у обоих есть message; строка из
337
416
  // throw внутри фейка — тоже ответ
338
417
  function errorText(e) {
@@ -15,6 +15,7 @@ import CommandProcessor from './meta/core/CommandProcessor.js';
15
15
  import { sanitizeMessage } from '../lib/sanitizers.js';
16
16
  import { parseFloorMessage } from '../lib/voiceMessages.js';
17
17
  import closeCodes from '../config/closeCodes.js';
18
+ import { MENU_NAME_PATTERN } from '../config/menuName.js';
18
19
  import clock from '../lib/clock.js';
19
20
  import { createResumeKey } from '../lib/resumeKey.js';
20
21
  import GameCoreAdapter from './GameCoreAdapter.js';
@@ -224,6 +225,7 @@ export default class HostGame {
224
225
  participants: this._participants,
225
226
  onCoreEvent: hostPlugin.onCoreEvent,
226
227
  });
228
+ this._participants.setAliveCheck(id => this._game.isAlive(id));
227
229
 
228
230
  this._panel = new Panel(data.panel);
229
231
  this._stat = new Stat(data.stat, this._teams);
@@ -307,6 +309,7 @@ export default class HostGame {
307
309
  vote: this._vote,
308
310
  chat: this._chat,
309
311
  timerManager: this._timerManager,
312
+ voteHoldTime: data.voteHoldTime,
310
313
  });
311
314
 
312
315
  this._roundManager = new RoundManager({
@@ -394,7 +397,11 @@ export default class HostGame {
394
397
 
395
398
  // внедрение зависимостей (ядро отдаёт панель/фасад события через адаптер)
396
399
  this._socketManager.injectServices(this._game, this._panel, this._stat);
397
- this._game.injectServices({ vimp: this, panel: this._panel });
400
+ this._game.injectServices({
401
+ vimp: this,
402
+ panel: this._panel,
403
+ vote: this._vote,
404
+ });
398
405
  this._panel.injectTimerManager(this._timerManager);
399
406
 
400
407
  this._timerManager.startIdleCheckTimer();
@@ -1264,6 +1271,20 @@ export default class HostGame {
1264
1271
  // преемник снимает их сам — иначе в мире остались бы бесхозные тела
1265
1272
  participants.dropped = dropped;
1266
1273
 
1274
+ // малое состояние игры между раундами (опкод state.persist): ядро в
1275
+ // точку не попало, а профили игры должны пережить мягкий перезапуск.
1276
+ // Эстафета стоит на границе раунда — игра подводит закончившийся раунд
1277
+ let persist = null;
1278
+
1279
+ if (core === null) {
1280
+ try {
1281
+ persist = this._game.persistState({ roundOver: kind === 'boundary' });
1282
+ } catch (e) {
1283
+ console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
1284
+ persist = null;
1285
+ }
1286
+ }
1287
+
1267
1288
  const meta = {
1268
1289
  version: HANDOFF_VERSION,
1269
1290
  kind,
@@ -1303,6 +1324,7 @@ export default class HostGame {
1303
1324
  panel: this._panel.serialize(),
1304
1325
  playerData: this._playerDataSync.serialize(),
1305
1326
  plugin: plugin ?? {},
1327
+ persist,
1306
1328
  // текст карточки лобби, выставленный модулем игры (lobby.setInfo):
1307
1329
  // модули нового Worker'а создаются с нуля и сами его не повторят
1308
1330
  lobbyInfo: this._lobbyInfoOverride,
@@ -1463,6 +1485,29 @@ export default class HostGame {
1463
1485
  this._restoreModules(meta.plugin);
1464
1486
  }
1465
1487
 
1488
+ if (!midRound && meta.persist !== undefined && meta.persist !== null) {
1489
+ try {
1490
+ this._game.restorePersisted?.(meta.persist);
1491
+ } catch (e) {
1492
+ console.warn(`[checkpoint] persisted state skipped: ${e.message}`);
1493
+ }
1494
+ }
1495
+
1496
+ // участник точки, которого восстановление не вернуло (не попал в точку,
1497
+ // restoreHuman/restoreScripted отказали), не должен оставить профиль в
1498
+ // ядре: его унаследовал бы новичок с тем же gameId
1499
+ const listed = [
1500
+ ...meta.participants.humans.map(record => record.gameId),
1501
+ ...meta.participants.scripted.map(record => record.gameId),
1502
+ ...(meta.participants.dropped ?? []),
1503
+ ];
1504
+
1505
+ for (const gameId of listed) {
1506
+ if (!this._participants.get(gameId)) {
1507
+ this._forgetActor(gameId);
1508
+ }
1509
+ }
1510
+
1466
1511
  this._restoreMode = midRound ? 'midRound' : 'soft';
1467
1512
  this._restoredTimers = meta.timers ?? {};
1468
1513
  this._restorePending = true;
@@ -1614,25 +1659,27 @@ export default class HostGame {
1614
1659
  return this._frozen;
1615
1660
  }
1616
1661
 
1617
- // меняет и возвращает gameId наблюдаемого игрока
1662
+ // gameId следующего (или предыдущего) наблюдаемого игрока по кругу. Остовы
1663
+ // погибших в списке активных остаются до конца раунда, но камера на них не
1664
+ // переключается; живых нет — наблюдатель остаётся на текущем
1618
1665
  _getNextActivePlayerForUser(gameId, back) {
1619
1666
  const currentId = this._participants.get(gameId)?.watchedGameId;
1620
1667
  const activeList = this._participants.getActiveList();
1621
- let key = activeList.indexOf(currentId);
1668
+ const size = activeList.length;
1669
+ const found = activeList.indexOf(currentId);
1670
+ // текущего нет в списке: счёт идёт с края, в нужную сторону
1671
+ const start = found === -1 ? (back ? 0 : -1) : found;
1672
+ const step = back ? -1 : 1;
1622
1673
 
1623
- if (key !== -1) {
1624
- key = back ? key - 1 : key + 1;
1674
+ for (let i = 1; i <= size; i += 1) {
1675
+ const key = (((start + step * i) % size) + size) % size;
1625
1676
 
1626
- if (key < 0) {
1627
- key = activeList.length - 1;
1628
- } else if (key >= activeList.length) {
1629
- key = 0;
1677
+ if (this._game.isAlive(activeList[key])) {
1678
+ return activeList[key];
1630
1679
  }
1631
-
1632
- return activeList[key];
1633
1680
  }
1634
1681
 
1635
- return activeList[0] || null;
1682
+ return currentId ?? null;
1636
1683
  }
1637
1684
 
1638
1685
  // активирует тряску камеры у игрока (из события ядра)
@@ -1736,6 +1783,10 @@ export default class HostGame {
1736
1783
  this._game.removePlayer(gameId);
1737
1784
  }
1738
1785
 
1786
+ // профиль игры (деньги и т.п.) живёт дольше танка: id ушедшего сразу
1787
+ // достаётся новичку
1788
+ this._forgetActor(gameId);
1789
+
1739
1790
  this._participants.remove(gameId);
1740
1791
 
1741
1792
  this._chat.pushSystem('USER_LEFT', [user.name]);
@@ -1869,7 +1920,9 @@ export default class HostGame {
1869
1920
  } else if (typeof data === 'object' && data !== null) {
1870
1921
  const [type, value] = data;
1871
1922
 
1872
- if (type === 'mapChange') {
1923
+ if (typeof type === 'string' && type.startsWith('menu:')) {
1924
+ this._menuAnswer(gameId, type.slice(5), value);
1925
+ } else if (type === 'mapChange') {
1873
1926
  if (this._participants.getHumans().length === 1) {
1874
1927
  this._roundManager.forceChangeMap(value);
1875
1928
  } else {
@@ -1884,6 +1937,29 @@ export default class HostGame {
1884
1937
  }
1885
1938
  }
1886
1939
 
1940
+ // исключение ядра не должно прервать уход участника или миграцию
1941
+ _forgetActor(gameId) {
1942
+ try {
1943
+ this._game.forgetActor?.(gameId);
1944
+ } catch (e) {
1945
+ console.warn(`[actor.forget] skipped: ${e.message}`);
1946
+ }
1947
+ }
1948
+
1949
+ // ответ игрока на личное меню: проверка формы и передача ядру игры;
1950
+ // `id` — всегда отправитель, клиент его не указывает
1951
+ _menuAnswer(gameId, name, path) {
1952
+ if (
1953
+ !MENU_NAME_PATTERN.test(name) ||
1954
+ typeof path !== 'string' ||
1955
+ !/^[1-9](\.[1-9])?$/.test(path)
1956
+ ) {
1957
+ return;
1958
+ }
1959
+
1960
+ this._game.menuAnswer(gameId, name, path);
1961
+ }
1962
+
1887
1963
  // rank/state участника (Этап B4) — для игровых модулей и чат-команды /rank
1888
1964
  // (Этап B5, CommandProcessor)
1889
1965
  getPlayerRank(gameId) {
@@ -398,6 +398,10 @@ class RoundManager {
398
398
  this._game.createMap(this._scaledMapData);
399
399
 
400
400
  for (const user of this._participants.getHumans()) {
401
+ // CLEAR уходит синхронно, личная очередь — по сообщению на кадр:
402
+ // меню и подсказка прошлого раунда не должны прийти после CLEAR
403
+ this._voteCoordinator.dropPersonal(user.gameId);
404
+
401
405
  if (user.isReady === false) {
402
406
  continue;
403
407
  }
@@ -579,6 +583,9 @@ class RoundManager {
579
583
  .filter(u => u.teamId !== this._spectatorId && u.gameId !== gameId)
580
584
  .length < 2
581
585
  ) {
586
+ // новая игра: ядро сбрасывает накопленное между раундами до
587
+ // removePlayersAndShots, который зовёт initiateNewRound
588
+ this._game.resetMatch?.();
582
589
  this._stat.reset();
583
590
  this.initiateNewRound();
584
591
  this._chat.pushSystemByUser(gameId, 'TEAMS_NEW_TEAM', [newTeam]);
@@ -649,7 +656,7 @@ class RoundManager {
649
656
 
650
657
  user.status = 'spectator';
651
658
  user.isWatching = true;
652
- user.watchedGameId = this._participants.getActiveList()[0] || null;
659
+ user.watchedGameId = this._participants.firstWatchable();
653
660
  user.forceCameraReset = true;
654
661
  // слот освобождается вместе с актором, иначе он держится за наблюдателем
655
662
  user.respawnIndex = null;
@@ -2,14 +2,36 @@
2
2
  // Инфраструктура поверх Vote + TimerManager; не знает о round/map-логике
3
3
  // (вызывающие передают готовые payload и resultFunc).
4
4
  class VoteCoordinator {
5
- constructor({ vote, chat, timerManager }) {
5
+ constructor({ vote, chat, timerManager, voteHoldTime = 0 }) {
6
6
  this._vote = vote;
7
7
  this._chat = chat;
8
8
  this._timerManager = timerManager;
9
+ this._voteHoldTime =
10
+ Number.isFinite(voteHoldTime) && voteHoldTime > 0 ? voteHoldTime : 0;
11
+ this._deferred = new Map(); // voteCategory → data голосования игры
12
+ }
13
+
14
+ // идут ли первые `voteHoldTime` мс раунда (удержание голосований)
15
+ isHeld() {
16
+ return (
17
+ this._voteHoldTime > 0 &&
18
+ this._timerManager.roundElapsed() < this._voteHoldTime
19
+ );
20
+ }
21
+
22
+ // убирает неотправленные личные сообщения меню игрока (старт раунда)
23
+ dropPersonal(gameId) {
24
+ this._vote.dropPersonal(gameId);
9
25
  }
10
26
 
11
27
  // проверяет возможность создать голосование в данной категории
12
28
  canCreateVote(voteCategory, gameId) {
29
+ if (gameId && this.isHeld()) {
30
+ this._chat.pushSystemByUser(gameId, 'VOTE_UNAVAILABLE');
31
+
32
+ return false;
33
+ }
34
+
13
35
  if (
14
36
  this._timerManager.isVoteBlocked(voteCategory) ||
15
37
  this._vote.hasVoteCategory(voteCategory)
@@ -29,6 +51,14 @@ class VoteCoordinator {
29
51
  const { voteName, voteCategory, payload, resultFunc, userList, gameId } =
30
52
  data;
31
53
 
54
+ // голосование игры откладывается до конца удержания; новое того же
55
+ // раздела заменяет отложенное
56
+ if (!gameId && this.isHeld()) {
57
+ this._defer(voteCategory, data);
58
+
59
+ return;
60
+ }
61
+
32
62
  // если голосование инициировано пользователем
33
63
  if (gameId) {
34
64
  this._chat.pushSystemByUser(gameId, 'VOTE_CREATED');
@@ -60,8 +90,33 @@ class VoteCoordinator {
60
90
  });
61
91
  }
62
92
 
93
+ // откладывает голосование игры на остаток удержания
94
+ _defer(voteCategory, data) {
95
+ this._deferred.set(voteCategory, data);
96
+
97
+ this._timerManager.startVoteHoldTimer(
98
+ () => this._releaseDeferred(),
99
+ this._voteHoldTime - this._timerManager.roundElapsed(),
100
+ );
101
+ }
102
+
103
+ // запускает отложенные голосования по окончании удержания
104
+ _releaseDeferred() {
105
+ const list = [...this._deferred];
106
+
107
+ this._deferred.clear();
108
+
109
+ for (const [category, data] of list) {
110
+ if (this.canCreateVote(category)) {
111
+ this.createVote(data);
112
+ }
113
+ }
114
+ }
115
+
63
116
  // сбрасывает активные и запланированные опросы и таймеры по ним
64
117
  reset() {
118
+ this._deferred.clear();
119
+ this._timerManager.stopVoteHoldTimer();
65
120
  this._timerManager.stopAllVoteTimers();
66
121
  this._timerManager.stopAllBlockedVoteTimers();
67
122
  this._vote.reset();
@@ -82,6 +82,7 @@ class TimerManager extends AbstractTimer {
82
82
  this._stopRoundRestartDelay();
83
83
  this._stopMapChangeDelay();
84
84
  this._stopRttPingTimer();
85
+ this.stopVoteHoldTimer();
85
86
  }
86
87
 
87
88
  // запускает таймер до конца текущей карты (duration — остаток времени
@@ -141,9 +142,12 @@ class TimerManager extends AbstractTimer {
141
142
 
142
143
  // проверяет возможно сменить команду игроку в текущем раунде
143
144
  canChangeTeamInCurrentRound() {
144
- const roundTime = this._now() - this._startRoundTime;
145
+ return this.roundElapsed() <= this._teamChangeGracePeriod;
146
+ }
145
147
 
146
- return roundTime <= this._teamChangeGracePeriod;
148
+ // миллисекунды от старта раунда
149
+ roundElapsed() {
150
+ return this._now() - this._startRoundTime;
147
151
  }
148
152
 
149
153
  // логика одного "тика" игрового цикла
@@ -230,6 +234,16 @@ class TimerManager extends AbstractTimer {
230
234
  this._startTimer(`vote:${voteName}`, onEndCallback, this._voteTime);
231
235
  }
232
236
 
237
+ // запускает таймер конца удержания голосований (A18)
238
+ startVoteHoldTimer(onEndCallback, duration) {
239
+ this._startTimer('voteHold', onEndCallback, duration);
240
+ }
241
+
242
+ // останавливает таймер удержания голосований
243
+ stopVoteHoldTimer() {
244
+ this._stopTimer('voteHold');
245
+ }
246
+
233
247
  // останавливает все активные таймеры голосования
234
248
  stopAllVoteTimers() {
235
249
  for (const key of this._timers.keys()) {