vimp-engine 0.40.0 → 0.42.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/RoomMenu.js +38 -10
- package/src/client/components/controller/Vote.js +26 -0
- package/src/client/components/model/Controls.js +9 -1
- package/src/client/components/model/RoomMenu.js +42 -57
- package/src/client/components/model/Vote.js +202 -0
- package/src/client/components/view/Panel.js +176 -6
- package/src/client/components/view/RoomMenu.js +78 -24
- package/src/client/components/view/Voice.js +6 -1
- package/src/client/components/view/Vote.js +71 -2
- package/src/client/lib/autoRejoin.js +161 -0
- package/src/client/lib/hostUnloadGuard.js +40 -15
- package/src/client/lib/roomLink.js +32 -0
- package/src/client/main.js +120 -36
- package/src/client/network/HostConnectionManager.js +27 -1
- package/src/client/network/HostController.js +1 -30
- package/src/client/network/HostHealthPolicy.js +14 -59
- package/src/client/network/PlannedHandoff.js +15 -100
- package/src/client/network/SessionSupervisor.js +58 -9
- package/src/client/network/SignalingClient.js +1 -1
- package/src/client/network/WebRtcManager.js +38 -3
- package/src/client/session/GuestSession.js +42 -7
- package/src/client/session/HandoffFlow.js +26 -51
- package/src/client/session/HostRole.js +8 -6
- package/src/client/session/PromotionFlow.js +28 -1
- package/src/client/session/RouteBoot.js +28 -9
- package/src/client/style.css +112 -20
- package/src/client/views/includes/roomMenu.pug +7 -8
- package/src/client/voice/MicCapture.js +3 -1
- package/src/config/abiOps.js +42 -0
- package/src/config/chatMessages.js +1 -1
- package/src/config/lobby.js +22 -23
- package/src/config/menuName.js +3 -0
- package/src/devtools/ScenarioRunner.js +1 -0
- package/src/devtools/invariants.js +24 -2
- package/src/devtools/report.js +2 -2
- package/src/host/GameCoreAdapter.js +83 -4
- package/src/host/HostGame.js +103 -39
- package/src/host/PortMachine.js +34 -17
- package/src/host/host.worker.js +1 -13
- package/src/host/meta/core/RoundManager.js +8 -22
- 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/modules/chat/systemMessages.js +1 -1
- package/src/host/meta/player/ParticipantManager.js +19 -3
- package/src/lib/capabilities.js +19 -4
- package/src/lib/gameConfigView.js +6 -5
|
@@ -11,6 +11,19 @@ const DEFAULT_BAR_MAX = 100;
|
|
|
11
11
|
const EMPTY_BLOCK_COLOR = '#888';
|
|
12
12
|
const BAR_FILL_DELAY_MS = 500;
|
|
13
13
|
const BAR_FILL_DURATION_MS = 500;
|
|
14
|
+
// длительность подсветки is-up / is-down у flashOnChange-поля
|
|
15
|
+
const FLASH_DURATION_MS = 400;
|
|
16
|
+
// свойства поля, при которых ячейка строится как span-разметка, а не голый
|
|
17
|
+
// textContent (поле без них работает как раньше)
|
|
18
|
+
const RICH_PROPS = [
|
|
19
|
+
'label',
|
|
20
|
+
'labelFrom',
|
|
21
|
+
'format',
|
|
22
|
+
'prefix',
|
|
23
|
+
'suffix',
|
|
24
|
+
'emptyWhen',
|
|
25
|
+
'flashOnChange',
|
|
26
|
+
];
|
|
14
27
|
|
|
15
28
|
// системная настройка «уменьшить движение» — округление в один момент
|
|
16
29
|
// времени: matchMedia недоступен в тестовой среде без happy-dom-полифилла
|
|
@@ -22,7 +35,10 @@ export default class PanelView {
|
|
|
22
35
|
// config — { containerId (движок), fields (схема игры: массив
|
|
23
36
|
// { name, elem, type: 'bar'|'value'|'time'|'weapon', max?, blocks? }) }
|
|
24
37
|
// Семантику ячейки задаёт type, а не имя поля: игра с полем `energy`
|
|
25
|
-
// вместо `health` получает тот же
|
|
38
|
+
// вместо `health` получает тот же бар. Необязательные свойства поля:
|
|
39
|
+
// label, labelFrom { field, labels }, hidden, format ('int' | 'money' |
|
|
40
|
+
// 'tenths' | 'fraction'), prefix, suffix, emptyWhen, flashOnChange,
|
|
41
|
+
// countdown { for }
|
|
26
42
|
constructor(model, config) {
|
|
27
43
|
if (panelView) {
|
|
28
44
|
return panelView;
|
|
@@ -35,6 +51,12 @@ export default class PanelView {
|
|
|
35
51
|
this._panels = {};
|
|
36
52
|
this._fields = {};
|
|
37
53
|
this._bars = {};
|
|
54
|
+
// последние значения полей: нужны labelFrom и пересчёту зависимых ячеек
|
|
55
|
+
this._values = {};
|
|
56
|
+
this._rich = {};
|
|
57
|
+
this._flashTimers = {};
|
|
58
|
+
// имя поля-источника → имена полей, чья подпись берётся по его значению
|
|
59
|
+
this._dependents = {};
|
|
38
60
|
this._weaponList = fields
|
|
39
61
|
.filter(field => field.type === 'weapon')
|
|
40
62
|
.map(field => field.name);
|
|
@@ -54,22 +76,60 @@ export default class PanelView {
|
|
|
54
76
|
_buildPanel(container, fields) {
|
|
55
77
|
const table = document.createElement('table');
|
|
56
78
|
const row = table.insertRow(-1);
|
|
79
|
+
const gaugeTargets = new Set(
|
|
80
|
+
fields.map(field => field.countdown?.for).filter(Boolean),
|
|
81
|
+
);
|
|
57
82
|
|
|
58
83
|
for (const field of fields) {
|
|
84
|
+
this._fields[field.name] = field;
|
|
85
|
+
|
|
86
|
+
if (field.labelFrom) {
|
|
87
|
+
const { field: source } = field.labelFrom;
|
|
88
|
+
|
|
89
|
+
(this._dependents[source] ??= []).push(field.name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// служебное поле хранит значение, но ячейки у него нет
|
|
93
|
+
if (field.hidden) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
59
97
|
const cell = row.insertCell(-1);
|
|
60
98
|
|
|
61
99
|
cell.setAttribute('id', field.elem);
|
|
62
100
|
this._panels[field.name] = cell;
|
|
63
|
-
this._fields[field.name] = field;
|
|
64
101
|
|
|
65
102
|
if (field.type === 'bar') {
|
|
66
103
|
this._initBar(field, cell);
|
|
104
|
+
} else if (
|
|
105
|
+
RICH_PROPS.some(prop => prop in field) ||
|
|
106
|
+
gaugeTargets.has(field.name)
|
|
107
|
+
) {
|
|
108
|
+
this._initRichCell(field, cell);
|
|
67
109
|
}
|
|
68
110
|
}
|
|
69
111
|
|
|
70
112
|
container.appendChild(table);
|
|
71
113
|
}
|
|
72
114
|
|
|
115
|
+
// разметка ячейки с подписью: span.panel-label + span.panel-value +
|
|
116
|
+
// div.panel-gauge
|
|
117
|
+
_initRichCell(field, cell) {
|
|
118
|
+
cell.textContent = '';
|
|
119
|
+
|
|
120
|
+
const label = document.createElement('span');
|
|
121
|
+
const value = document.createElement('span');
|
|
122
|
+
const gauge = document.createElement('div');
|
|
123
|
+
|
|
124
|
+
label.className = 'panel-label';
|
|
125
|
+
value.className = 'panel-value';
|
|
126
|
+
gauge.className = 'panel-gauge';
|
|
127
|
+
gauge.style.display = 'none';
|
|
128
|
+
|
|
129
|
+
cell.append(label, value, gauge);
|
|
130
|
+
this._rich[field.name] = { label, value, gauge };
|
|
131
|
+
}
|
|
132
|
+
|
|
73
133
|
// инициализирует полосу-бар поля type: 'bar'
|
|
74
134
|
_initBar(field, cell) {
|
|
75
135
|
cell.textContent = '';
|
|
@@ -148,15 +208,117 @@ export default class PanelView {
|
|
|
148
208
|
// обновляет пользовательскую панель
|
|
149
209
|
update(data) {
|
|
150
210
|
const { name, value } = data;
|
|
211
|
+
const field = this._fields[name];
|
|
151
212
|
const elem = this._panels[name];
|
|
213
|
+
const previous = this._values[name];
|
|
152
214
|
|
|
153
|
-
|
|
215
|
+
this._values[name] = value;
|
|
216
|
+
|
|
217
|
+
if (field?.type === 'bar') {
|
|
154
218
|
this._updateBar(name, value);
|
|
155
|
-
} else {
|
|
219
|
+
} else if (this._rich[name]) {
|
|
220
|
+
this._renderRich(name);
|
|
221
|
+
|
|
222
|
+
if (field.flashOnChange && previous !== undefined && value !== previous) {
|
|
223
|
+
this._flash(name, value > previous ? 'is-up' : 'is-down');
|
|
224
|
+
}
|
|
225
|
+
} else if (elem) {
|
|
156
226
|
elem.textContent = value;
|
|
157
227
|
}
|
|
158
228
|
|
|
159
|
-
|
|
229
|
+
// подпись зависимых ячеек берётся по значению этого поля
|
|
230
|
+
for (const dependent of this._dependents[name] ?? []) {
|
|
231
|
+
if (this._rich[dependent]) {
|
|
232
|
+
this._renderRich(dependent);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (field?.countdown) {
|
|
237
|
+
this._runCountdown(field.countdown.for, value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (elem) {
|
|
241
|
+
elem.style.display = 'table-cell';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// подпись и значение ячейки: объектная форма записи labelFrom.labels
|
|
246
|
+
// перекрывает format / prefix / suffix / max самого поля
|
|
247
|
+
_renderRich(name) {
|
|
248
|
+
const field = this._fields[name];
|
|
249
|
+
const { label, value } = this._rich[name];
|
|
250
|
+
const raw = this._values[name];
|
|
251
|
+
const entry =
|
|
252
|
+
field.labelFrom?.labels?.[this._values[field.labelFrom.field]];
|
|
253
|
+
const spec = typeof entry === 'object' && entry !== null ? entry : {};
|
|
254
|
+
const text = typeof entry === 'string' ? entry : spec.text;
|
|
255
|
+
|
|
256
|
+
label.textContent = [field.label, text].filter(Boolean).join(' ');
|
|
257
|
+
// значение поля-источника может прийти раньше собственного
|
|
258
|
+
value.textContent =
|
|
259
|
+
raw === undefined
|
|
260
|
+
? ''
|
|
261
|
+
: this._format(raw, {
|
|
262
|
+
format: spec.format ?? field.format,
|
|
263
|
+
prefix: spec.prefix ?? field.prefix,
|
|
264
|
+
suffix: spec.suffix ?? field.suffix,
|
|
265
|
+
max: spec.max,
|
|
266
|
+
});
|
|
267
|
+
this._panels[name].classList.toggle(
|
|
268
|
+
'is-empty',
|
|
269
|
+
'emptyWhen' in field && raw === field.emptyWhen,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_format(raw, { format = 'int', prefix = '', suffix = '', max }) {
|
|
274
|
+
let text;
|
|
275
|
+
|
|
276
|
+
if (typeof raw === 'string') {
|
|
277
|
+
text = raw; // time уже отформатирован моделью
|
|
278
|
+
} else if (format === 'money') {
|
|
279
|
+
text = `$ ${raw}`;
|
|
280
|
+
} else if (format === 'tenths') {
|
|
281
|
+
text = raw.toFixed(1);
|
|
282
|
+
} else if (format === 'fraction') {
|
|
283
|
+
text = max === undefined ? `${raw}` : `${raw}/${max}`;
|
|
284
|
+
} else {
|
|
285
|
+
text = `${raw}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return `${prefix}${text}${suffix}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// кратко подсвечивает ячейку классом is-up / is-down
|
|
292
|
+
_flash(name, className) {
|
|
293
|
+
const cell = this._panels[name];
|
|
294
|
+
|
|
295
|
+
clearTimeout(this._flashTimers[name]);
|
|
296
|
+
cell.classList.remove('is-up', 'is-down');
|
|
297
|
+
cell.classList.add(className);
|
|
298
|
+
this._flashTimers[name] = setTimeout(() => {
|
|
299
|
+
cell.classList.remove(className);
|
|
300
|
+
}, FLASH_DURATION_MS);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// шкала div.panel-gauge ячейки target заполняется от 0 до 100 % за
|
|
304
|
+
// duration мс (повторное значение перезапускает её); 0 — скрывает
|
|
305
|
+
_runCountdown(target, duration) {
|
|
306
|
+
const gauge = this._rich[target]?.gauge;
|
|
307
|
+
|
|
308
|
+
if (!gauge) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
gauge.style.animation = 'none';
|
|
313
|
+
|
|
314
|
+
if (!(duration > 0)) {
|
|
315
|
+
gauge.style.display = 'none';
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
gauge.style.display = 'block';
|
|
320
|
+
void gauge.offsetWidth; // перезапуск CSS-анимации
|
|
321
|
+
gauge.style.animation = `panel-gauge-fill ${duration}ms linear forwards`;
|
|
160
322
|
}
|
|
161
323
|
|
|
162
324
|
// перерисовывает бар по текущему значению
|
|
@@ -263,7 +425,11 @@ export default class PanelView {
|
|
|
263
425
|
}
|
|
264
426
|
|
|
265
427
|
hidePanel(name) {
|
|
266
|
-
this._panels[name]
|
|
428
|
+
const elem = this._panels[name];
|
|
429
|
+
|
|
430
|
+
if (elem) {
|
|
431
|
+
elem.style.display = 'none';
|
|
432
|
+
}
|
|
267
433
|
}
|
|
268
434
|
|
|
269
435
|
// с задержкой BAR_FILL_DELAY_MS заполняет bar-поля (например, здоровье)
|
|
@@ -303,6 +469,10 @@ export default class PanelView {
|
|
|
303
469
|
for (const weaponName of this._weaponList) {
|
|
304
470
|
const elem = this._panels[weaponName];
|
|
305
471
|
|
|
472
|
+
if (!elem) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
306
476
|
if (weaponName === activeWeaponName) {
|
|
307
477
|
elem.classList.add('active');
|
|
308
478
|
} else {
|
|
@@ -5,8 +5,8 @@ import Publisher from '../../../lib/Publisher.js';
|
|
|
5
5
|
let roomMenuView;
|
|
6
6
|
|
|
7
7
|
export default class RoomMenuView {
|
|
8
|
-
// config — lobbyConfig.roomMenu: { elems: { panelId, menuId,
|
|
9
|
-
//
|
|
8
|
+
// config — lobbyConfig.roomMenu: { elems: { panelId, menuId, exitId,
|
|
9
|
+
// statusId, confirmId, confirmYesId, confirmCancelId } }
|
|
10
10
|
constructor(model, config) {
|
|
11
11
|
if (roomMenuView) {
|
|
12
12
|
return roomMenuView;
|
|
@@ -17,39 +17,36 @@ export default class RoomMenuView {
|
|
|
17
17
|
const { elems } = config;
|
|
18
18
|
|
|
19
19
|
this._menu = document.getElementById(elems.menuId);
|
|
20
|
-
this.
|
|
21
|
-
this._leave = document.getElementById(elems.leaveId);
|
|
22
|
-
this._handover = document.getElementById(elems.handoverId);
|
|
20
|
+
this._exit = document.getElementById(elems.exitId);
|
|
23
21
|
this._status = document.getElementById(elems.statusId);
|
|
22
|
+
this._confirm = document.getElementById(elems.confirmId);
|
|
24
23
|
this._panel = document.getElementById(elems.panelId);
|
|
25
24
|
this._home = this._menu.parentElement;
|
|
25
|
+
this._onKeyDown = event => this._keyDown(event);
|
|
26
|
+
this._listening = false;
|
|
26
27
|
|
|
27
28
|
this.publisher = new Publisher();
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
this._leave.addEventListener('click', () => this.publisher.emit('leave'));
|
|
33
|
-
this._handover.addEventListener('click', () =>
|
|
34
|
-
this.publisher.emit('handover'),
|
|
35
|
-
);
|
|
30
|
+
this._bindButton(this._exit, 'exit');
|
|
31
|
+
this._bindButton(document.getElementById(elems.confirmYesId), 'yes');
|
|
32
|
+
this._bindButton(document.getElementById(elems.confirmCancelId), 'cancel');
|
|
36
33
|
|
|
37
34
|
this._mPublic = model.publisher;
|
|
38
35
|
this._mPublic.on('state', 'showState', this);
|
|
39
|
-
this._mPublic.on('open', 'showOpen', this);
|
|
40
36
|
}
|
|
41
37
|
|
|
42
|
-
showState({ visible,
|
|
38
|
+
showState({ visible, exit, status, confirm }) {
|
|
43
39
|
this._menu.style.display = visible ? 'block' : 'none';
|
|
44
|
-
|
|
45
|
-
showItem(this._handover, handover);
|
|
40
|
+
this._exit.disabled = exit.disabled;
|
|
46
41
|
this._status.textContent = status ?? '';
|
|
47
42
|
this._status.style.display = status ? 'block' : 'none';
|
|
43
|
+
this._confirm.style.display = confirm ? 'flex' : 'none';
|
|
44
|
+
this._listen(confirm);
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
//
|
|
51
|
-
// панели общий со standalone SDK, где
|
|
52
|
-
// initIdList держит её скрытой — тогда
|
|
47
|
+
// кнопка — часть панели (справа от таблицы), но разметка отдельная: каркас
|
|
48
|
+
// панели общий со standalone SDK, где выхода из комнаты нет. Игра без
|
|
49
|
+
// панели в initIdList держит её скрытой — тогда кнопка остаётся в углу
|
|
53
50
|
placeInPanel(inPanel) {
|
|
54
51
|
const parent = inPanel && this._panel ? this._panel : this._home;
|
|
55
52
|
|
|
@@ -58,12 +55,69 @@ export default class RoomMenuView {
|
|
|
58
55
|
}
|
|
59
56
|
}
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
// фокус с кнопки снимается: иначе Space/Enter в бою нажали бы её снова
|
|
59
|
+
_bindButton(elem, type) {
|
|
60
|
+
elem.addEventListener('click', () => {
|
|
61
|
+
elem.blur();
|
|
62
|
+
this.publisher.emit(type);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// фаза захвата, только пока окно открыто: Enter/Esc окна не доходят до
|
|
67
|
+
// игры
|
|
68
|
+
_listen(on) {
|
|
69
|
+
if (on === this._listening) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this._listening = on;
|
|
74
|
+
|
|
75
|
+
if (on) {
|
|
76
|
+
document.addEventListener('keydown', this._onKeyDown, true);
|
|
77
|
+
} else {
|
|
78
|
+
document.removeEventListener('keydown', this._onKeyDown, true);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_keyDown(event) {
|
|
83
|
+
// Enter/Esc в поле ввода (чат) принадлежат ему
|
|
84
|
+
if (isTextInput(event.target)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const type =
|
|
89
|
+
event.key === 'Enter' ? 'yes' : event.key === 'Escape' ? 'cancel' : null;
|
|
90
|
+
|
|
91
|
+
if (!type) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
event.stopPropagation();
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
this.publisher.emit(type);
|
|
63
98
|
}
|
|
64
99
|
}
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
101
|
+
const TEXT_INPUT_TYPES = new Set([
|
|
102
|
+
'text',
|
|
103
|
+
'search',
|
|
104
|
+
'email',
|
|
105
|
+
'password',
|
|
106
|
+
'url',
|
|
107
|
+
'tel',
|
|
108
|
+
'number',
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
function isTextInput(elem) {
|
|
112
|
+
if (!elem || elem.nodeType !== 1) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (elem.isContentEditable || elem.tagName === 'TEXTAREA') {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
elem.tagName === 'INPUT' && TEXT_INPUT_TYPES.has(elem.type.toLowerCase())
|
|
122
|
+
);
|
|
69
123
|
}
|
|
@@ -65,7 +65,12 @@ export default class VoiceView {
|
|
|
65
65
|
this._nameElem = document.createElement('span');
|
|
66
66
|
this._nameElem.className = 'voice-name';
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// кольцо центрируется на значке, а не на крайнем элементе строки
|
|
69
|
+
const mark = document.createElement('span');
|
|
70
|
+
|
|
71
|
+
mark.className = 'voice-mark';
|
|
72
|
+
mark.append(createIcon(), ring);
|
|
73
|
+
own.append(mark, this._timerElem, this._textElem);
|
|
69
74
|
incoming.append(createIcon(), this._nameElem);
|
|
70
75
|
this._hud.append(own, incoming);
|
|
71
76
|
container.appendChild(this._hud);
|
|
@@ -28,11 +28,42 @@ export default class VoteView {
|
|
|
28
28
|
|
|
29
29
|
this._mPublic.on('vote', 'createVote', this);
|
|
30
30
|
this._mPublic.on('clear', 'removeVote', this);
|
|
31
|
+
this._mPublic.on('hint', 'setHint', this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// подсказка меню: иконка под значком рации (`#menu-hints`), класс
|
|
35
|
+
// `is-on` у горящей. Узлы строятся без innerHTML — имя пришло от хоста
|
|
36
|
+
setHint({ name, on }) {
|
|
37
|
+
let box = document.getElementById('menu-hints');
|
|
38
|
+
|
|
39
|
+
if (!box) {
|
|
40
|
+
box = document.createElement('div');
|
|
41
|
+
box.setAttribute('id', 'menu-hints');
|
|
42
|
+
this._container.appendChild(box);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let hint = null;
|
|
46
|
+
|
|
47
|
+
for (const child of box.children) {
|
|
48
|
+
if (child.dataset.name === name) {
|
|
49
|
+
hint = child;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!hint) {
|
|
54
|
+
hint = document.createElement('div');
|
|
55
|
+
hint.setAttribute('class', 'menu-hint');
|
|
56
|
+
hint.dataset.name = name;
|
|
57
|
+
hint.appendChild(createCartIcon());
|
|
58
|
+
box.appendChild(hint);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
hint.classList.toggle('is-on', on === true);
|
|
31
62
|
}
|
|
32
63
|
|
|
33
64
|
// создает окно голосования
|
|
34
65
|
createVote(data) {
|
|
35
|
-
const { title, list, back, more, time } = data;
|
|
66
|
+
const { title, list, back, more, time, style, level } = data;
|
|
36
67
|
const vote = document.createElement('div');
|
|
37
68
|
const p = document.createElement('p');
|
|
38
69
|
const ol = document.createElement('ol');
|
|
@@ -44,6 +75,10 @@ export default class VoteView {
|
|
|
44
75
|
|
|
45
76
|
vote.setAttribute('id', this._voteId);
|
|
46
77
|
|
|
78
|
+
if (style) {
|
|
79
|
+
vote.classList.add(`vote-style-${style}`, `vote-level-${level}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
p.setAttribute('class', this._titleClass);
|
|
48
83
|
p.textContent = title;
|
|
49
84
|
|
|
@@ -51,7 +86,22 @@ export default class VoteView {
|
|
|
51
86
|
|
|
52
87
|
list.forEach(item => {
|
|
53
88
|
const li = document.createElement('li');
|
|
54
|
-
|
|
89
|
+
|
|
90
|
+
// личное меню передаёт объекты { text, disabled, group }
|
|
91
|
+
if (typeof item === 'object') {
|
|
92
|
+
li.textContent = item.text;
|
|
93
|
+
|
|
94
|
+
if (item.disabled) {
|
|
95
|
+
li.classList.add('disabled');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (item.group) {
|
|
99
|
+
li.classList.add('group');
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
li.textContent = item;
|
|
103
|
+
}
|
|
104
|
+
|
|
55
105
|
ol.appendChild(li);
|
|
56
106
|
});
|
|
57
107
|
|
|
@@ -106,3 +156,22 @@ export default class VoteView {
|
|
|
106
156
|
}
|
|
107
157
|
}
|
|
108
158
|
}
|
|
159
|
+
|
|
160
|
+
// SVG-заглушка корзины: подсказка работает без игровых стилей и файлов
|
|
161
|
+
function createCartIcon() {
|
|
162
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
163
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
164
|
+
const path = document.createElementNS(ns, 'path');
|
|
165
|
+
|
|
166
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
167
|
+
svg.setAttribute('width', '24');
|
|
168
|
+
svg.setAttribute('height', '24');
|
|
169
|
+
path.setAttribute('fill', 'currentColor');
|
|
170
|
+
path.setAttribute(
|
|
171
|
+
'd',
|
|
172
|
+
'M7 18a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM1 2v2h2l3.6 7.6-1.4 2.4A2 2 0 0 0 7 17h12v-2H7.4l1.1-2h7.5a2 2 0 0 0 1.7-1l3.6-6.5A1 1 0 0 0 20.4 4H5.2l-.9-2H1Z',
|
|
173
|
+
);
|
|
174
|
+
svg.appendChild(path);
|
|
175
|
+
|
|
176
|
+
return svg;
|
|
177
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Автовход после перезагрузки в ту же комнату: место в матче потеряно
|
|
2
|
+
// (отказ возобновления, холодный рестарт, ошибка протокола), страница
|
|
3
|
+
// перезагружается на ссылку комнаты — и форма входа игры не показывается,
|
|
4
|
+
// клиент отвечает хосту сохранёнными значениями формы.
|
|
5
|
+
//
|
|
6
|
+
// Метка — в sessionStorage: живёт только в этой вкладке и переживает
|
|
7
|
+
// перезагрузку. Забирается (и удаляется) при разборе маршрута: взводится в
|
|
8
|
+
// памяти, только если маршрут ведёт в ту же комнату. Схема — как у
|
|
9
|
+
// отложенного промоушена (network/Promotion.js).
|
|
10
|
+
|
|
11
|
+
export const AUTO_REJOIN_STORAGE_KEY = 'vimp.autoRejoin';
|
|
12
|
+
|
|
13
|
+
// перезагрузка с загрузкой ассетов укладывается с запасом; столько же после
|
|
14
|
+
// автовхода повторная метка не ставится (цикл перезагрузок)
|
|
15
|
+
export const AUTO_REJOIN_TTL_MS = 60000;
|
|
16
|
+
|
|
17
|
+
const isString = value => typeof value === 'string' && value !== '';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ставит метку автовхода перед перезагрузкой.
|
|
21
|
+
* @param {Storage|null} storage - sessionStorage.
|
|
22
|
+
* @param {{ gameId: string, roomId: string }} target
|
|
23
|
+
* @param {number} now
|
|
24
|
+
* @returns {boolean} false — сохранить не удалось.
|
|
25
|
+
*/
|
|
26
|
+
export function saveAutoRejoin(storage, { gameId, roomId } = {}, now) {
|
|
27
|
+
if (!storage || !isString(gameId) || !isString(roomId)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
storage.setItem(
|
|
33
|
+
AUTO_REJOIN_STORAGE_KEY,
|
|
34
|
+
JSON.stringify({ gameId, roomId, until: now + AUTO_REJOIN_TTL_MS }),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Забирает метку: удаляется при любом исходе.
|
|
45
|
+
* @param {Storage|null} storage - sessionStorage.
|
|
46
|
+
* @param {number} now
|
|
47
|
+
* @returns {{ gameId: string, roomId: string, until: number }|null}
|
|
48
|
+
* null — метки нет, она битая или просрочена.
|
|
49
|
+
*/
|
|
50
|
+
export function takeAutoRejoin(storage, now) {
|
|
51
|
+
if (!storage) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let raw = null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
raw = storage.getItem(AUTO_REJOIN_STORAGE_KEY);
|
|
59
|
+
storage.removeItem(AUTO_REJOIN_STORAGE_KEY);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!raw) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let value;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (
|
|
77
|
+
!isString(value?.gameId) ||
|
|
78
|
+
!isString(value.roomId) ||
|
|
79
|
+
!Number.isFinite(value.until) ||
|
|
80
|
+
now >= value.until
|
|
81
|
+
) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { gameId: value.gameId, roomId: value.roomId, until: value.until };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Автовход в пределах одной загрузки страницы: взвод маршрутом, ответ без
|
|
90
|
+
* формы, отказ хоста и запрет повторной метки сразу после автовхода.
|
|
91
|
+
*/
|
|
92
|
+
export class AutoRejoin {
|
|
93
|
+
/**
|
|
94
|
+
* @param {Object} [options]
|
|
95
|
+
* @param {Function} [options.now]
|
|
96
|
+
*/
|
|
97
|
+
constructor({ now = () => Date.now() } = {}) {
|
|
98
|
+
this._now = now;
|
|
99
|
+
// { roomId, until } — маршрут ведёт в комнату метки
|
|
100
|
+
this._armed = null;
|
|
101
|
+
// ответ без формы ушёл хосту, AUTH_RESULT ещё не пришёл
|
|
102
|
+
this._pending = false;
|
|
103
|
+
// когда эта загрузка вошла автовходом
|
|
104
|
+
this._usedAt = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Маршрут после перезагрузки входит в комнату метки.
|
|
109
|
+
* @param {{ roomId: string, until: number }} mark
|
|
110
|
+
*/
|
|
111
|
+
arm({ roomId, until }) {
|
|
112
|
+
this._armed = { roomId, until };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* PS_AUTH_DATA комнаты: true — ответить без формы. Взвод снимается при
|
|
117
|
+
* любом исходе.
|
|
118
|
+
* @param {string|null} roomId
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
begin(roomId) {
|
|
122
|
+
const armed = this._armed;
|
|
123
|
+
const now = this._now();
|
|
124
|
+
|
|
125
|
+
this._armed = null;
|
|
126
|
+
|
|
127
|
+
if (!armed || armed.roomId !== roomId || now >= armed.until) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this._pending = true;
|
|
132
|
+
this._usedAt = now;
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* PS_AUTH_RESULT: true — хост отбил автовход, нужна форма с ошибкой.
|
|
139
|
+
* @param {*} err
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
result(err) {
|
|
143
|
+
const pending = this._pending;
|
|
144
|
+
|
|
145
|
+
this._pending = false;
|
|
146
|
+
|
|
147
|
+
return pending && Boolean(err);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Можно ли ставить метку перед перезагрузкой: сразу после автовхода —
|
|
152
|
+
* нет, иначе повторяющийся сбой (ошибка протокола) перезагружал бы
|
|
153
|
+
* страницу без конца; следующий круг покажет форму.
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
canMark() {
|
|
157
|
+
return (
|
|
158
|
+
this._usedAt === null || this._now() - this._usedAt >= AUTO_REJOIN_TTL_MS
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|