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.
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/Vote.js +26 -0
- package/src/client/components/model/Controls.js +9 -1
- package/src/client/components/model/Vote.js +202 -0
- package/src/client/components/view/Panel.js +176 -6
- package/src/client/components/view/Voice.js +6 -1
- package/src/client/components/view/Vote.js +71 -2
- package/src/client/main.js +4 -0
- package/src/client/style.css +81 -4
- package/src/client/voice/MicCapture.js +3 -1
- package/src/config/abiOps.js +42 -0
- package/src/config/menuName.js +3 -0
- package/src/host/GameCoreAdapter.js +83 -4
- package/src/host/HostGame.js +89 -13
- package/src/host/meta/core/RoundManager.js +8 -1
- package/src/host/meta/core/VoteCoordinator.js +56 -1
- package/src/host/meta/modules/TimerManager.js +16 -2
- package/src/host/meta/modules/Vote.js +65 -0
- package/src/host/meta/player/ParticipantManager.js +19 -3
- package/src/lib/capabilities.js +17 -0
- package/src/lib/gameConfigView.js +7 -0
package/src/client/style.css
CHANGED
|
@@ -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:
|
|
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:
|
|
382
|
-
top:
|
|
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
|
-
|
|
189
|
+
// эхоподавление включает системную обработку голоса (macOS) и
|
|
190
|
+
// приглушает весь остальной звук на время открытия и закрытия
|
|
191
|
+
echoCancellation: false,
|
|
190
192
|
noiseSuppression: true,
|
|
191
193
|
autoGainControl: true,
|
|
192
194
|
channelCount: 1,
|
package/src/config/abiOps.js
CHANGED
|
@@ -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;
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
// (spawn_scripted_actor/remove_scripted_actor), человек — только танк
|
|
11
11
|
// (spawn_actor/remove_actor).
|
|
12
12
|
|
|
13
|
-
import {
|
|
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) {
|
package/src/host/HostGame.js
CHANGED
|
@@ -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({
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1624
|
-
key =
|
|
1674
|
+
for (let i = 1; i <= size; i += 1) {
|
|
1675
|
+
const key = (((start + step * i) % size) + size) % size;
|
|
1625
1676
|
|
|
1626
|
-
if (key
|
|
1627
|
-
|
|
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
|
|
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 === '
|
|
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.
|
|
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
|
-
|
|
145
|
+
return this.roundElapsed() <= this._teamChangeGracePeriod;
|
|
146
|
+
}
|
|
145
147
|
|
|
146
|
-
|
|
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()) {
|