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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.5"
3
+ version = "0.24.0"
4
4
  edition = "2024"
5
5
  description = "VIMP engine simulation core: physics, map, snapshot framing, interpolation/prediction/raycast primitives, navigation utilities. No wasm-bindgen — the game crate builds the WASM ABI wrappers."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,27 @@ export default class VoteCtrl {
24
24
  // {name: 'templateName', params: ['p1',..], values: ['v1',..] || 'values'}
25
25
  // ['val1', 'val2'];
26
26
  open(data) {
27
+ // личное меню хоста: проверяется раньше шаблона голосования (тоже объект)
28
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
29
+ if (Object.hasOwn(data, 'menu')) {
30
+ this._model.openPersonal(data.menu);
31
+
32
+ return;
33
+ }
34
+
35
+ if (Object.hasOwn(data, 'menuClose')) {
36
+ this._model.closePersonal(data.menuClose);
37
+
38
+ return;
39
+ }
40
+
41
+ if (Object.hasOwn(data, 'menuHint')) {
42
+ this._model.setHint(data.menuHint?.name, data.menuHint?.on);
43
+
44
+ return;
45
+ }
46
+ }
47
+
27
48
  // если данные - массив (values для созданного голосования)
28
49
  if (Array.isArray(data)) {
29
50
  this._model.updateValues(data);
@@ -64,6 +85,11 @@ export default class VoteCtrl {
64
85
  this._model.closeEngineVote(name);
65
86
  }
66
87
 
88
+ // CLEAR: личное меню закрывается, подсказки гаснут
89
+ clearPersonal() {
90
+ this._model.clearPersonal();
91
+ }
92
+
67
93
  // голосования хоста не пережили переподключение
68
94
  removeHostVotes() {
69
95
  this._model.removeHostVotes();
@@ -1,4 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
+ import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
2
3
 
3
4
  // Singleton ControlsModel
4
5
 
@@ -120,8 +121,15 @@ export default class ControlsModel {
120
121
  return;
121
122
  }
122
123
 
124
+ // цифры открытого окна голосования/меню — только ответ окну, в игру
125
+ // не уходят; прочие клавиши идут и в игру (танк ездит при меню)
126
+ const isVoteAnswer =
127
+ this._currentModes.vote &&
128
+ keyCode >= VOTE_ANSWER_KEYS[0] &&
129
+ keyCode <= VOTE_ANSWER_KEYS[1];
130
+
123
131
  // если клавиша ещё не зажата
124
- if (!this._pressedKeys[keyCode]) {
132
+ if (!isVoteAnswer && !this._pressedKeys[keyCode]) {
125
133
  const name = this._currentKeySet[keyCode];
126
134
 
127
135
  if (name) {
@@ -1,5 +1,6 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
2
  import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
3
+ import { MENU_NAME_PATTERN } from '../../../config/menuName.js';
3
4
 
4
5
  // Singleton VoteModel
5
6
 
@@ -7,6 +8,44 @@ import { VOTE_ANSWER_KEYS } from '../../../config/voteKeys.js';
7
8
  // его ведёт мастер, а не хост, и игра таких имён не объявляет
8
9
  const isEngineVote = name => typeof name === 'string' && name.startsWith('@');
9
10
 
11
+ // ограничения данных личного меню: хост недоверенный
12
+ const PERSONAL_MAX_ITEMS = 7;
13
+ const PERSONAL_MAX_DEPTH = 2;
14
+ const PERSONAL_MAX_TEXT = 64;
15
+
16
+ // узлы личного меню: усечённые копии, глубина ≤ 2, пунктов ≤ 7
17
+ function sanitizeNodes(nodes, depth) {
18
+ if (!Array.isArray(nodes)) {
19
+ return [];
20
+ }
21
+
22
+ if (nodes.length > PERSONAL_MAX_ITEMS) {
23
+ console.warn(
24
+ `[vote] personal menu level has ${nodes.length} items, max ${PERSONAL_MAX_ITEMS}`,
25
+ );
26
+ }
27
+
28
+ return nodes.slice(0, PERSONAL_MAX_ITEMS).map(node => {
29
+ const out = {
30
+ text: String(node?.text ?? '').slice(0, PERSONAL_MAX_TEXT),
31
+ disabled: node?.disabled === true,
32
+ };
33
+
34
+ if (depth < PERSONAL_MAX_DEPTH) {
35
+ const children = sanitizeNodes(node?.children, depth + 1);
36
+
37
+ if (children.length > 0) {
38
+ out.children = children;
39
+ } else if (node?.children !== undefined) {
40
+ // объявленная группа без валидных детей не должна стать листом
41
+ out.disabled = true;
42
+ }
43
+ }
44
+
45
+ return out;
46
+ });
47
+ }
48
+
10
49
  let voteModel;
11
50
 
12
51
  export default class VoteModel {
@@ -41,6 +80,11 @@ export default class VoteModel {
41
80
  // затирает чужое и не затирается им (этап 10)
42
81
  this._queue = [];
43
82
 
83
+ // личное меню хоста (type 'personal'): { name, style, title, items,
84
+ // path }, path — индексы открытых групп; подсказки — имя → горит ли
85
+ this._personal = null;
86
+ this._hints = new Map();
87
+
44
88
  this._title = null; // заголовок голосования
45
89
  this._values = []; // все значения голосования
46
90
 
@@ -124,6 +168,137 @@ export default class VoteModel {
124
168
  if (!(this._type === 'vote' && isEngineVote(this._voteName))) {
125
169
  this.complete();
126
170
  }
171
+
172
+ this._clearHints();
173
+ }
174
+
175
+ /**
176
+ * Личное меню хоста: заменяет любое содержимое окна и начинается с
177
+ * 1-го уровня; открытое голосование движка уходит в начало очереди.
178
+ * @param {Object} menu - { name, style, title, items }.
179
+ * @returns {boolean} данные приняты.
180
+ */
181
+ openPersonal(menu) {
182
+ if (
183
+ !menu ||
184
+ !MENU_NAME_PATTERN.test(menu.name) ||
185
+ !MENU_NAME_PATTERN.test(menu.style)
186
+ ) {
187
+ return false;
188
+ }
189
+
190
+ if (
191
+ this._showing &&
192
+ this._type === 'vote' &&
193
+ isEngineVote(this._voteName)
194
+ ) {
195
+ this._queue.unshift({
196
+ engine: {
197
+ name: this._voteName,
198
+ title: this._title,
199
+ values: this._values,
200
+ deadline: this._deadline,
201
+ },
202
+ });
203
+ }
204
+
205
+ this._waitingValues = false;
206
+ this._timeOff = true;
207
+ this._deadline = null;
208
+ this._type = 'personal';
209
+ this._voteName = '';
210
+ this._personal = {
211
+ name: menu.name,
212
+ style: menu.style,
213
+ title: String(menu.title ?? '').slice(0, PERSONAL_MAX_TEXT),
214
+ items: sanitizeNodes(menu.items, 1),
215
+ path: [],
216
+ };
217
+
218
+ this.show();
219
+ this.open();
220
+
221
+ return true;
222
+ }
223
+
224
+ // закрывает окно, если оно показывает личное меню `name`
225
+ closePersonal(name) {
226
+ if (
227
+ this._showing &&
228
+ this._type === 'personal' &&
229
+ this._personal.name === name
230
+ ) {
231
+ this.complete();
232
+ }
233
+ }
234
+
235
+ // зажигает/гасит подсказку меню
236
+ setHint(name, on) {
237
+ if (!MENU_NAME_PATTERN.test(name)) {
238
+ return;
239
+ }
240
+
241
+ this._hints.set(name, on === true);
242
+ this.publisher.emit('hint', { name, on: on === true });
243
+ }
244
+
245
+ // CLEAR: личное меню закрывается, подсказки гаснут
246
+ clearPersonal() {
247
+ if (this._showing && this._type === 'personal') {
248
+ this.complete();
249
+ }
250
+
251
+ this._clearHints();
252
+ }
253
+
254
+ _clearHints() {
255
+ for (const [name, on] of this._hints) {
256
+ if (on) {
257
+ this.setHint(name, false);
258
+ }
259
+ }
260
+ }
261
+
262
+ // пункты текущего уровня личного меню
263
+ _personalLevel() {
264
+ let nodes = this._personal.items;
265
+
266
+ for (const index of this._personal.path) {
267
+ nodes = nodes[index].children;
268
+ }
269
+
270
+ return nodes;
271
+ }
272
+
273
+ // цифра в личном меню: 0 — выход, 8/9 — страниц нет, 1–7 — пункт
274
+ _updatePersonal(number) {
275
+ if (number === 0) {
276
+ this.complete();
277
+
278
+ return;
279
+ }
280
+
281
+ const node = this._personalLevel()[number - 1];
282
+
283
+ if (number >= 8 || !node || node.disabled) {
284
+ return;
285
+ }
286
+
287
+ if (node.children) {
288
+ this._personal.path.push(number - 1);
289
+ this.show();
290
+
291
+ return;
292
+ }
293
+
294
+ const answer = [...this._personal.path, number - 1]
295
+ .map(index => index + 1)
296
+ .join('.');
297
+ const name = this._personal.name;
298
+
299
+ // окно закрывается ДО отправки (синхронный транспорт, см. ниже)
300
+ this.complete();
301
+ this.publisher.emit('socket', [`menu:${name}`, answer]);
127
302
  }
128
303
 
129
304
  _startEngineVote({ name, title, values, deadline }) {
@@ -220,6 +395,12 @@ export default class VoteModel {
220
395
  number = String.fromCharCode(keyCode);
221
396
  number = parseInt(number, 10);
222
397
 
398
+ if (this._type === 'personal') {
399
+ this._updatePersonal(number);
400
+
401
+ return;
402
+ }
403
+
223
404
  // exit
224
405
  if (number === 0) {
225
406
  this.complete();
@@ -277,6 +458,27 @@ export default class VoteModel {
277
458
 
278
459
  // отображает голосование
279
460
  show() {
461
+ if (this._type === 'personal') {
462
+ this._showing = true;
463
+ this.publisher.emit('clear', this._timerId);
464
+
465
+ this.publisher.emit('vote', {
466
+ title: this._personal.title,
467
+ list: this._personalLevel().map(node => ({
468
+ text: node.text,
469
+ disabled: node.disabled,
470
+ group: Boolean(node.children),
471
+ })),
472
+ back: false,
473
+ more: false,
474
+ time: null,
475
+ style: this._personal.style,
476
+ level: this._personal.path.length + 1,
477
+ });
478
+
479
+ return;
480
+ }
481
+
280
482
  const begin = this._currentPage * 7;
281
483
  const max = begin + 7;
282
484
  let currentValues = [];
@@ -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
- if (this._fields[name]?.type === 'bar') {
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
- elem.style.display = 'table-cell';
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].style.display = 'none';
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 {
@@ -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
- own.append(createIcon(), ring, this._timerElem, this._textElem);
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
- li.textContent = item;
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
+ }
@@ -1045,6 +1045,10 @@ socketMethods[PS_CLEAR] = function (setIdList) {
1045
1045
  }
1046
1046
  }
1047
1047
 
1048
+ // личное меню и подсказки прошлого раунда гаснут; ядро игры зажжёт
1049
+ // подсказку заново при спавне
1050
+ modules.vote?.clearPersonal();
1051
+
1048
1052
  clientCore?.reset();
1049
1053
  soundManager.reset();
1050
1054
  };