vimp-engine 0.27.0 → 0.29.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.9.2"
3
+ version = "0.10.0"
4
4
  edition = "2024"
5
5
  description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,6 +29,7 @@ export default class GamesCtrl {
29
29
  vp.on('submit', 'submit', this);
30
30
  vp.on('update-version', 'updateVersion', this);
31
31
  vp.on('delete', 'remove', this);
32
+ vp.on('restore', 'restore', this);
32
33
  vp.on('filter', 'filter', this);
33
34
  vp.on('stage', 'stage', this);
34
35
  vp.on('approve', 'approve', this);
@@ -81,6 +82,10 @@ export default class GamesCtrl {
81
82
  this._model.remove(id, scope);
82
83
  }
83
84
 
85
+ restore({ id }) {
86
+ this._model.restore(id);
87
+ }
88
+
84
89
  filter(status) {
85
90
  this._model.setFilter(status);
86
91
  }
@@ -156,7 +156,11 @@ export default class GamesModel {
156
156
  }
157
157
 
158
158
  // удаление игры. scope решает, чей список перечитать: карточка автора и
159
- // очередь модерации показывают разные проекции одной строки
159
+ // очередь модерации показывают разные проекции одной строки.
160
+ //
161
+ // Удаление обратимо: игра уходит в графу Deleted очереди модерации и
162
+ // возвращается оттуда `restore` — подтверждения второй кнопкой оно
163
+ // поэтому не требует
160
164
  async remove(id, scope = 'mine') {
161
165
  const { ok, json } = await this._request(this._config.urls.remove(id), {
162
166
  method: 'DELETE',
@@ -169,12 +173,34 @@ export default class GamesModel {
169
173
 
170
174
  if (scope === 'admin') {
171
175
  await this.loadAdmin();
176
+
177
+ // после loadAdmin, а не до: renderAdmin чистит строку панели (см.
178
+ // moderate). Снятая с раздачи последняя игра оставляет лобби без
179
+ // каталога, и сказать об этом обязан тот же тик
180
+ if (json?.warning) {
181
+ this.publisher.emit('warning', { scope: 'admin', code: json.warning });
182
+ }
183
+
172
184
  return;
173
185
  }
174
186
 
175
187
  await this.loadMine();
176
188
  }
177
189
 
190
+ // возврат мягко удалённой игры из графы Deleted (только админ)
191
+ async restore(id) {
192
+ const { ok, json } = await this._request(this._config.urls.restore(id), {
193
+ method: 'POST',
194
+ });
195
+
196
+ if (!ok) {
197
+ this._fail('admin', json);
198
+ return;
199
+ }
200
+
201
+ await this.loadAdmin();
202
+ }
203
+
178
204
  // «Test»: мастер качает версию и кладёт её в каталог не раздаваемой.
179
205
  // Манифест едет наружу — по нему вкладка админа поднимает комнату
180
206
  async stage(id, version) {
@@ -218,9 +244,17 @@ export default class GamesModel {
218
244
  await this.moderate(id, { authorNick: nick === '' ? null : nick });
219
245
  }
220
246
 
247
+ // графа, в которой стоит игра. Мягко удалённая уходит в свою графу
248
+ // ЦЕЛИКОМ, сохраняя прежний статус: восстановление возвращает её туда,
249
+ // где она была, а показывать её ещё и в Pending/Published значило бы
250
+ // предлагать модерировать то, чего у игроков уже нет
251
+ _bucketOf(game) {
252
+ return game.deletedAt ? 'deleted' : game.status;
253
+ }
254
+
221
255
  _emitAdmin() {
222
256
  this.publisher.emit('admin-changed', {
223
- games: this._all.filter(game => game.status === this._filter),
257
+ games: this._all.filter(game => this._bucketOf(game) === this._filter),
224
258
  filter: this._filter,
225
259
  versions: this._versions,
226
260
  });
@@ -288,8 +288,10 @@ export default class GamesView {
288
288
  item.appendChild(version);
289
289
  item.appendChild(send);
290
290
  item.appendChild(
291
- this._deleteButton(() =>
292
- this.publisher.emit('delete', { id: game.id, scope: 'mine' }),
291
+ this._button(
292
+ 'Delete',
293
+ () => this.publisher.emit('delete', { id: game.id, scope: 'mine' }),
294
+ 'games-delete-btn',
293
295
  ),
294
296
  );
295
297
  this._mineList.appendChild(item);
@@ -337,6 +339,21 @@ export default class GamesView {
337
339
  'games-item-title',
338
340
  ),
339
341
  );
342
+
343
+ // Мягко удалённая игра модерации не подлежит: у игроков её уже нет, а
344
+ // решение по ней приняли. Из всей карточки ей нужны срок полного
345
+ // удаления и возврат — остальные кнопки правили бы то, чего не видно
346
+ if (game.deletedAt) {
347
+ item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
348
+ this._appendRepo(item, game.repoUrl);
349
+ item.appendChild(this._line(this._statusLine(game), 'games-purge-line'));
350
+ item.appendChild(
351
+ this._button('Restore', () => this.publisher.emit('restore', { id: game.id })),
352
+ );
353
+
354
+ return item;
355
+ }
356
+
340
357
  // у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
341
358
  // в строке печаталось бы литеральное "null"
342
359
  item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
@@ -408,7 +425,11 @@ export default class GamesView {
408
425
  ),
409
426
  );
410
427
  item.appendChild(
411
- this._deleteButton(() => this.publisher.emit('delete', { id: game.id, scope: 'admin' })),
428
+ this._button(
429
+ 'Delete',
430
+ () => this.publisher.emit('delete', { id: game.id, scope: 'admin' }),
431
+ 'games-delete-btn',
432
+ ),
412
433
  );
413
434
 
414
435
  return item;
@@ -438,6 +459,16 @@ export default class GamesView {
438
459
  const status = STATUS_TITLES[game.status] ?? game.status;
439
460
  const date = game.createdAt ? new Date(game.createdAt).toLocaleDateString() : '';
440
461
 
462
+ // у удалённой игры прежний статус — след того, куда её вернёт Restore, —
463
+ // а вести отсчёт человеку нужно от срока полного удаления: только он
464
+ // ограничен во времени
465
+ if (game.deletedAt) {
466
+ const deleted = new Date(game.deletedAt).toLocaleDateString();
467
+ const purge = game.purgeAt ? new Date(game.purgeAt).toLocaleDateString() : '—';
468
+
469
+ return `Deleted ${deleted} (was ${status}); removed for good on ${purge}`;
470
+ }
471
+
441
472
  return date ? `Status: ${status} (submitted ${date})` : `Status: ${status}`;
442
473
  }
443
474
 
@@ -459,34 +490,17 @@ export default class GamesView {
459
490
  });
460
491
  }
461
492
 
462
- // Удаление необратимо и уносит рейтинги игры, поэтому кнопка требует
463
- // второго нажатия: первое переводит её в «Confirm delete», второе публикует
464
- // событие. Повторная отрисовка списка возвращает её в исходное состояние —
465
- // незавершённое подтверждение не переживает перерисовку
466
- _deleteButton(onConfirm) {
467
- const btn = this._button('Delete', () => {
468
- if (btn.dataset.armed !== '1') {
469
- btn.dataset.armed = '1';
470
- btn.value = 'Confirm delete';
471
- btn.classList.add('games-delete-armed');
472
- return;
473
- }
474
-
475
- onConfirm();
476
- });
477
-
478
- btn.className = 'games-delete-btn';
479
-
480
- return btn;
481
- }
482
-
483
- _button(value, onclick) {
493
+ _button(value, onclick, className) {
484
494
  const btn = document.createElement('input');
485
495
 
486
496
  btn.type = 'button';
487
497
  btn.value = value;
488
498
  btn.onclick = onclick;
489
499
 
500
+ if (className) {
501
+ btn.className = className;
502
+ }
503
+
490
504
  return btn;
491
505
  }
492
506
 
@@ -586,6 +586,11 @@ function applyMapData(data, { notifyHost = true } = {}) {
586
586
  setId,
587
587
  physicsStatic,
588
588
  physicsDynamic: data.physicsDynamic,
589
+ // 2.5D: надземные уровни и переходы. Клиентское ядро строит из них
590
+ // ту же `MapLevels`, что и хост, — иначе предсказание уровня
591
+ // разъедется с авторитетным молча
592
+ levels: data.levels,
593
+ ramps: data.ramps,
589
594
  }),
590
595
  );
591
596
  } catch (e) {
@@ -625,23 +630,47 @@ function applyMapData(data, { notifyHost = true } = {}) {
625
630
  currentMapSetId = setId;
626
631
  };
627
632
 
628
- const staticData = Object.entries(layers).reduce(
629
- (acc, [layer, tiles], index) => {
630
- acc[`s${index}`] = {
633
+ // рендер-слои по уровням: уровень 0 — из `layers` над гридом `map`,
634
+ // надземные — из `levels[n].layers` над гридом `levels[n].map`.
635
+ // Ключи `s0..sN` сквозные: парт получает `level`, `solid` и `floor`
636
+ // своего уровня и не обязан ничего знать про соседний
637
+ const staticData = {};
638
+ let staticIndex = 0;
639
+
640
+ const pushLayers = (levelLayers, levelMap, level, solid, floor) => {
641
+ for (const [layer, tiles] of Object.entries(levelLayers || {})) {
642
+ staticData[`s${staticIndex}`] = {
631
643
  type: 'static',
632
644
  spriteSheet,
633
- map,
645
+ map: levelMap,
634
646
  step,
635
647
  layer,
636
648
  tiles,
649
+ level,
650
+ solid,
651
+ floor,
652
+ // прежнее имя оставлено для парта, который его уже читает
637
653
  physicsStatic,
638
654
  scale,
639
655
  };
640
656
 
641
- return acc;
642
- },
643
- {},
644
- );
657
+ staticIndex += 1;
658
+ }
659
+ };
660
+
661
+ pushLayers(layers, map, 0, physicsStatic, []);
662
+
663
+ for (const [key, levelData] of Object.entries(data.levels || {})) {
664
+ const level = Number(key);
665
+
666
+ pushLayers(
667
+ levelData.layers,
668
+ levelData.map,
669
+ level,
670
+ levelData.walls || [],
671
+ levelData.floor || [],
672
+ );
673
+ }
645
674
 
646
675
  removeMap(currentMapSetId);
647
676
  createMap(setId, staticData);
@@ -217,9 +217,9 @@ body.hide-cursor {
217
217
  background: rgba(25, 25, 25, 0.9);
218
218
  border-radius: 5px;
219
219
  border: 3px solid #fff;
220
- overflow: hidden;
220
+ overflow: auto;
221
221
  width: 90%;
222
- max-width: 550px;
222
+ max-width: 500px;
223
223
  font-size: 10px;
224
224
  line-height: 12px;
225
225
  color: #fff;
@@ -438,7 +438,7 @@ body.hide-cursor {
438
438
  background-color: #333;
439
439
  border: 1px solid #eee;
440
440
  padding: 10px;
441
- margin: 10px auto 5px;
441
+ margin: 5px auto 0;
442
442
  text-shadow: 1px 1px 1px #000;
443
443
  font-size: 12px;
444
444
  line-height: 15px;
@@ -549,15 +549,11 @@ body.hide-cursor {
549
549
  padding: 0 10px;
550
550
  }
551
551
 
552
- /* ширина — контентная область правой панели лобби (её ширина минус
553
- собственные поля .lobby-browser-inner), под которой футер и стоит;
554
- max-width, а не width: в одну колонку он обязан ужиматься по экрану */
555
552
  #lobby-footer {
556
- width: 100%;
557
- max-width: calc(var(--lobby-browser-width) - 2 * var(--lobby-panel-pad));
553
+ width: calc(var(--lobby-width) + var(--lobby-panel-pad));
558
554
  font-size: 10px;
559
555
  padding: 0;
560
- margin: 0 var(--lobby-panel-pad) 0 0;
556
+ margin: 0 calc(var(--lobby-panel-pad) / 2) 0 0;
561
557
  }
562
558
 
563
559
  /* три равные ячейки: середину держит не space-between, а сама равность —
@@ -670,11 +666,7 @@ body.hide-cursor {
670
666
  /*lobby*/
671
667
 
672
668
  #lobby {
673
- /* ширины панелей и их внутренние поля: по ним же считается ширина футера
674
- (секция auth выше), поэтому числа живут в одном месте — иначе смена
675
- ширины любой панели молча разъезжает вёрстку */
676
- --lobby-setup-width: 410px;
677
- --lobby-browser-width: 430px;
669
+ --lobby-width: 370px;
678
670
  --lobby-panel-pad: 10px;
679
671
 
680
672
  background-color: #222;
@@ -832,12 +824,12 @@ body.hide-cursor {
832
824
  высоты более высокой панели */
833
825
  .lobby-grid {
834
826
  display: flex;
835
- gap: 10px;
827
+ gap: 5px;
836
828
  justify-content: center;
837
829
  }
838
830
 
839
831
  #lobby-setup-panel {
840
- width: var(--lobby-setup-width);
832
+ width: var(--lobby-width);
841
833
  }
842
834
 
843
835
  /* высоту правой .card задаёт форма слева: обе панели тянутся до высоты
@@ -846,9 +838,8 @@ body.hide-cursor {
846
838
  вынесен в абсолютный слой .lobby-browser-inner (его высота не участвует в
847
839
  расчёте высоты строки) */
848
840
  #lobby-browser-panel {
849
- width: var(--lobby-browser-width);
841
+ width: var(--lobby-width);
850
842
  position: relative;
851
- padding: 0;
852
843
  }
853
844
 
854
845
  .lobby-browser-inner {
@@ -1147,16 +1138,16 @@ body.hide-cursor {
1147
1138
  color: #eee;
1148
1139
  }
1149
1140
 
1150
- /* удаление необратимо: взведённое подтверждение обязано отличаться от
1151
- первого нажатия не только подписью */
1141
+ /* удаление обратимо (игра уходит в графу Deleted на 30 суток), но выделяется
1142
+ среди соседних кнопок: рядом стоит правка версии, а не действие того же веса */
1152
1143
  .games-delete-btn {
1153
1144
  color: #d66;
1154
1145
  }
1155
1146
 
1156
- .games-delete-armed {
1157
- background: #802020;
1158
- border-color: #d66;
1159
- color: #fee;
1147
+ /* срок полного удаления — единственное, что в карточке удалённой игры
1148
+ ограничено во времени */
1149
+ .games-purge-line {
1150
+ color: #d66;
1160
1151
  }
1161
1152
 
1162
1153
  /* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
@@ -218,19 +218,26 @@ export default {
218
218
  staged: '/admin/games/manifest.json',
219
219
  // «Test»: скачать версию и положить её в каталог не раздаваемой
220
220
  stage: id => `/admin/games/${encodeURIComponent(id)}/stage`,
221
+ // возврат мягко удалённой игры из графы Deleted (только админ)
222
+ restore: id => `/admin/games/${encodeURIComponent(id)}/restore`,
221
223
  // решение модератора
222
224
  moderate: id => `/admin/games/${encodeURIComponent(id)}`,
223
225
  // что опубликовано в npm — индикатор «есть версия новее»
224
226
  versions: id => `/admin/games/${encodeURIComponent(id)}/versions`,
225
227
  },
226
228
 
227
- // фильтры очереди модерации: id статуса реестра -> подпись кнопки.
228
- // Значения обязаны совпадать со статусами auth-сервиса
229
+ // фильтры («графы») очереди модерации: id -> подпись кнопки. Первые
230
+ // четыре — статусы реестра, и значения обязаны совпадать со статусами
231
+ // auth-сервиса. Пятая графа стоит особняком: 'deleted' статусом НЕ
232
+ // является (мягко удалённая игра сохраняет свой прежний статус, иначе
233
+ // восстанавливать было бы не во что) — под неё попадает всё с
234
+ // непустым deletedAt, см. GamesModel._bucketOf
229
235
  statuses: [
230
236
  { id: 'pending', title: 'Pending' },
231
237
  { id: 'approved', title: 'Published' },
232
238
  { id: 'rejected', title: 'Rejected' },
233
239
  { id: 'disabled', title: 'Disabled' },
240
+ { id: 'deleted', title: 'Deleted' },
234
241
  ],
235
242
  defaultStatus: 'pending',
236
243
 
@@ -201,6 +201,11 @@ class VirtualClient {
201
201
  setId: data.setId,
202
202
  physicsStatic: data.physicsStatic,
203
203
  physicsDynamic: data.physicsDynamic,
204
+ // 2.5D: надземные уровни и переходы. Клиентское ядро строит из
205
+ // них ту же `MapLevels`, что и хост, — иначе предсказание уровня
206
+ // разъедется с авторитетным молча
207
+ levels: data.levels,
208
+ ramps: data.ramps,
204
209
  }),
205
210
  );
206
211
  } catch (e) {
@@ -0,0 +1,196 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Слоёная (2.5D) карта проверяется в ядре (`MapConfig::validate`), но там
4
+ // это ошибка загрузки уже запущенного матча. Здесь то же самое ловится до
5
+ // сборки и без ядра: рассинхрон размерностей гридов даёт танк,
6
+ // проваливающийся в пустоту, и ни одной строки в консоли.
7
+ //
8
+ // Правило безвредно для одноуровневой игры: без `levels` ни одна проверка
9
+ // не срабатывает.
10
+ export default {
11
+ id: 'E4',
12
+ name: 'mapLayers',
13
+ level: ERROR,
14
+ title: 'layered maps are structurally sound',
15
+
16
+ check(ctx) {
17
+ const maps = ctx.gameConfig?.maps;
18
+
19
+ if (!maps) {
20
+ return skip('no gameConfig.maps');
21
+ }
22
+
23
+ const layered = Object.entries(maps).filter(
24
+ ([, map]) => map?.levels && Object.keys(map.levels).length > 0,
25
+ );
26
+
27
+ if (!layered.length) {
28
+ return skip('no layered maps');
29
+ }
30
+
31
+ const violations = [];
32
+
33
+ for (const [name, map] of layered) {
34
+ checkMap(name, map, violations);
35
+ }
36
+
37
+ return verdict(violations);
38
+ },
39
+ };
40
+
41
+ const RAMP_DIRS = ['north', 'south', 'west', 'east'];
42
+
43
+ function checkMap(name, map, violations) {
44
+ const at = `map "${name}"`;
45
+ const levels = map.levels ?? {};
46
+ // уровень 0 живёт в map/physicsStatic/layers и в `levels` не значится
47
+ const levelCount = Object.keys(levels).length + 1;
48
+
49
+ // 1. ключи — целые >= 1, подряд от 1
50
+ const keys = Object.keys(levels).map(Number);
51
+
52
+ for (const key of Object.keys(levels)) {
53
+ if (!/^\d+$/.test(key) || Number(key) < 1) {
54
+ violations.push(
55
+ `${at}: levels key "${key}" is not a level number >= 1 ` +
56
+ '(level 0 lives in map/physicsStatic)',
57
+ );
58
+ }
59
+ }
60
+
61
+ const sorted = [...keys].sort((a, b) => a - b);
62
+
63
+ sorted.forEach((level, index) => {
64
+ if (level !== index + 1) {
65
+ violations.push(
66
+ `${at}: levels must run from 1 without gaps, got ${level} at ` +
67
+ `position ${index + 1}`,
68
+ );
69
+ }
70
+ });
71
+
72
+ const grid = map.map ?? [];
73
+
74
+ for (const [key, level] of Object.entries(levels)) {
75
+ // 2. размерность грида уровня совпадает с гридом уровня 0
76
+ const levelGrid = level?.map ?? [];
77
+
78
+ if (levelGrid.length !== grid.length) {
79
+ violations.push(
80
+ `${at}: level ${key} grid has ${levelGrid.length} rows, ` +
81
+ `map has ${grid.length}`,
82
+ );
83
+ }
84
+
85
+ levelGrid.forEach((row, y) => {
86
+ const expected = grid[y]?.length;
87
+
88
+ if (expected !== undefined && row.length !== expected) {
89
+ violations.push(
90
+ `${at}: level ${key} row ${y} has ${row.length} cells, ` +
91
+ `map has ${expected}`,
92
+ );
93
+ }
94
+ });
95
+
96
+ // 3. перила — часть плиты: тайл вне floor не экранирует луч снизу
97
+ const floor = level?.floor ?? [];
98
+
99
+ for (const tile of level?.walls ?? []) {
100
+ if (!floor.includes(tile)) {
101
+ violations.push(
102
+ `${at}: level ${key} wall tile ${tile} is not part of floor`,
103
+ );
104
+ }
105
+ }
106
+
107
+ // 8. тайл рендер-слоя обязан существовать в тайл-листе по индексу
108
+ checkLayerFrames(at, `level ${key}`, level?.layers, map.spriteSheet, violations);
109
+ }
110
+
111
+ for (const [index, ramp] of (map.ramps ?? []).entries()) {
112
+ const from = ramp?.from ?? 0;
113
+ const to = ramp?.to ?? 1;
114
+
115
+ // 5. направление и осмысленность перехода
116
+ if (!RAMP_DIRS.includes(ramp?.dir)) {
117
+ violations.push(
118
+ `${at}: ramp ${index} dir "${ramp?.dir}" is not one of ` +
119
+ RAMP_DIRS.join('|'),
120
+ );
121
+ }
122
+
123
+ if (from === to) {
124
+ violations.push(`${at}: ramp ${index} goes from level ${from} to itself`);
125
+ }
126
+
127
+ if (from >= levelCount || to >= levelCount) {
128
+ violations.push(
129
+ `${at}: ramp ${index} references a level out of range ` +
130
+ `(levels: ${levelCount})`,
131
+ );
132
+ }
133
+
134
+ // 4. тайл рампы обязан встречаться в гриде уровня, с которого она идёт
135
+ const fromGrid = from === 0 ? grid : levels[String(from)]?.map;
136
+ const found = (fromGrid ?? []).some(row => row.includes(ramp?.tile));
137
+
138
+ if (!found) {
139
+ violations.push(
140
+ `${at}: ramp ${index} tile ${ramp?.tile} is missing from ` +
141
+ `level ${from} grid`,
142
+ );
143
+ }
144
+ }
145
+
146
+ // 6. точка респауна: [x, y, angle] либо [x, y, angle, level]
147
+ for (const [team, points] of Object.entries(map.respawns ?? {})) {
148
+ for (const [index, point] of (points ?? []).entries()) {
149
+ if (point.length !== 3 && point.length !== 4) {
150
+ violations.push(
151
+ `${at}: respawns ${team}[${index}] has ${point.length} numbers, ` +
152
+ 'expected 3 or 4',
153
+ );
154
+ continue;
155
+ }
156
+
157
+ if (point.length === 4 && point[3] >= levelCount) {
158
+ violations.push(
159
+ `${at}: respawns ${team}[${index}] level ${point[3]} is out of ` +
160
+ `range (levels: ${levelCount})`,
161
+ );
162
+ }
163
+ }
164
+ }
165
+
166
+ // 7. уровень динамического тела
167
+ for (const [index, object] of (map.physicsDynamic ?? []).entries()) {
168
+ const level = object?.level ?? 0;
169
+
170
+ if (level >= levelCount) {
171
+ violations.push(
172
+ `${at}: physicsDynamic ${index} level ${level} is out of range ` +
173
+ `(levels: ${levelCount})`,
174
+ );
175
+ }
176
+ }
177
+ }
178
+
179
+ function checkLayerFrames(at, where, layers, spriteSheet, violations) {
180
+ const frames = spriteSheet?.frames;
181
+
182
+ if (!Array.isArray(frames)) {
183
+ return;
184
+ }
185
+
186
+ for (const [layer, tiles] of Object.entries(layers ?? {})) {
187
+ for (const tile of tiles ?? []) {
188
+ if (frames[tile] === undefined) {
189
+ violations.push(
190
+ `${at}: ${where} layer ${layer} names tile ${tile}, ` +
191
+ `spriteSheet.frames has ${frames.length}`,
192
+ );
193
+ }
194
+ }
195
+ }
196
+ }
@@ -32,6 +32,7 @@ import d3 from './d3-snapshot-interp.js';
32
32
  import e1 from './e1-sound-pairs.js';
33
33
  import e2 from './e2-map-images.js';
34
34
  import e3 from './e3-sound-registry.js';
35
+ import e4 from './e4-map-layers.js';
35
36
 
36
37
  // Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
37
38
  // D снапшот, E ассеты (plan/create-vimp-game/stage_1.md).
@@ -40,7 +41,7 @@ export const rules = [
40
41
  b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
41
42
  c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11,
42
43
  d1, d2, d3,
43
- e1, e2, e3,
44
+ e1, e2, e3, e4,
44
45
  ];
45
46
 
46
47
  export default rules;
@@ -74,6 +74,22 @@ export default class GameCoreAdapter {
74
74
  } else {
75
75
  this._core.spawn_actor(gameId, model, teamId, x, y, angle);
76
76
  }
77
+
78
+ this._applyActorLevel(gameId, data);
79
+ }
80
+
81
+ // явный уровень точки респауна на 2.5D-карте (respawns[i][3]); ядро,
82
+ // собранное до появления метода, его просто не экспортирует — тогда
83
+ // уровень выведется из геометрии внутри ядра
84
+ _applyActorLevel(gameId, data) {
85
+ const level = data[3];
86
+
87
+ if (
88
+ typeof level === 'number' &&
89
+ typeof this._core.set_actor_level === 'function'
90
+ ) {
91
+ this._core.set_actor_level(gameId, level);
92
+ }
77
93
  }
78
94
 
79
95
  // удаляет танк (Game.removePlayer). Scripted → удаляет и ИИ-контроллер
@@ -98,6 +114,7 @@ export default class GameCoreAdapter {
98
114
  const [x, y, angle] = data.respawnData;
99
115
 
100
116
  this._core.reset_actor(gameId, data.teamId, x, y, angle);
117
+ this._applyActorLevel(gameId, data.respawnData);
101
118
  }
102
119
 
103
120
  // ***** ввод ***** //
@@ -15,7 +15,9 @@ function scaleMapData(mapData) {
15
15
  const respawns = Object.fromEntries(
16
16
  Object.entries(mapData.respawns || {}).map(([team, arr]) => [
17
17
  team,
18
- arr.map(([x, y, angle]) => [x * scale, y * scale, angle]),
18
+ // 4-й элемент точки уровень 2.5D-карты; масштабируются ТОЛЬКО
19
+ // координаты, всё остальное едет как есть
20
+ arr.map(point => [point[0] * scale, point[1] * scale, ...point.slice(2)]),
19
21
  ]),
20
22
  );
21
23
 
@@ -43,6 +43,11 @@ export const buildClientConfig = (game, defaults, gameClient) => {
43
43
  playerKeys: game.playerKeys,
44
44
  models: game.parts.models,
45
45
  weapons: game.parts.weapons,
46
+ // собственные параметры ядра игры (те же, что едут в хостовое ядро
47
+ // через lib/coreConfig.js): движок их не читает и не валидирует, а
48
+ // клиентская реплика обязана видеть правила ровно те же, что хост —
49
+ // иначе предсказание разъедется с авторитетным молча
50
+ coreParams: game.coreParams,
46
51
  };
47
52
 
48
53
  // снапшот-схема игры: клиент собирает по ней конфиг клиентского ядра и
@@ -24,6 +24,9 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
24
24
  { value: 'accolades', since: '0.21.0' },
25
25
  // dispatch/abi_describe в ядре (этап 4): опциональные вызовы ядра
26
26
  { value: 'dispatch', since: '0.23.0' },
27
+ // слоёные карты: `levels`/`ramps` в формате карты, маски уровней в
28
+ // физике, уровень участника (`set_actor_level`), слоёный нав-граф
29
+ { value: 'map.layers', since: '0.25.0' },
27
30
  ]);
28
31
 
29
32
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`
@@ -13,7 +13,10 @@ import wsports from '../config/wsports.js';
13
13
  * игровая (models/weapons/playerKeys/seed трассеров).
14
14
  * @param {Object} options
15
15
  * @param {Object} options.prediction - Секция prediction CONFIG_DATA
16
- * (timeStep в мс, playerKeys, models, weapons).
16
+ * (timeStep в мс, playerKeys, models, weapons, coreParams). Поле
17
+ * `coreParams` (необязательное) — непрозрачный словарь параметров ядра
18
+ * игры: попадает в игровую половину как есть, движок его не читает;
19
+ * известные движку ключи имеют приоритет.
17
20
  * @param {Object} options.interpolation - Секция interpolation CONFIG_DATA
18
21
  * (delay, maxFrameAge в мс).
19
22
  * @param {Object} options.snapshot - Секция snapshot CONFIG_DATA —
@@ -32,6 +35,7 @@ export const buildClientCoreConfig = (
32
35
  playerKeys: prediction.playerKeys,
33
36
  models: prediction.models,
34
37
  weapons: prediction.weapons,
38
+ coreParams: prediction.coreParams,
35
39
  // keys — игровая схема из CONFIG_DATA; version/port — движковые
36
40
  snapshot: {
37
41
  version: SNAPSHOT_FORMAT_VERSION,
@@ -54,6 +58,11 @@ export const buildClientCoreConfig = (
54
58
  divergence: flat.divergence,
55
59
  },
56
60
  game: {
61
+ // собственные параметры ядра игры: движок их не читает и не
62
+ // валидирует — он лишь довозит их до `GameClientDef::new`. Известные
63
+ // ключи ниже перекрывают одноимённые, чтобы игра не могла подменить
64
+ // движковую часть контракта (то же правило, что в lib/coreConfig.js)
65
+ ...(flat.coreParams || {}),
57
66
  playerKeys: flat.playerKeys,
58
67
  models: flat.models,
59
68
  weapons: flat.weapons,
@@ -13,7 +13,10 @@ import wsports from '../config/wsports.js';
13
13
  * Собирает объект конфигурации ядра.
14
14
  * @param {Object} gameConfig - Представление HostPlugin.gameConfig
15
15
  * (lib/gameConfigView.js): поля игры плюс движковые умолчания. Прямой
16
- * gameConfig сюда не передаётся — он обходит умолчания (И2).
16
+ * gameConfig сюда не передаётся — он обходит умолчания (И2). Поле
17
+ * `coreParams` (необязательное) — непрозрачный словарь параметров
18
+ * ядра игры: попадает в игровую половину конфига как есть, движок его
19
+ * не читает и не валидирует; известные движку ключи имеют приоритет.
17
20
  * @param {Object} [overrides] - Переопределения плоским объектом (например,
18
21
  * seed для воспроизводимых прогонов или friendlyFire) — распределяются
19
22
  * по движковой/игровой половине автоматически.
@@ -50,6 +53,11 @@ export const buildCoreConfig = (gameConfig, overrides = {}) => {
50
53
  seed: flat.seed,
51
54
  },
52
55
  game: {
56
+ // собственные параметры ядра игры: движок их не читает и не
57
+ // валидирует — он лишь довозит их до `GameSim::new`. Известные ключи
58
+ // ниже перекрывают одноимённые, чтобы игра не могла подменить
59
+ // движковую часть контракта
60
+ ...(gameConfig.coreParams || {}),
53
61
  friendlyFire: flat.friendlyFire,
54
62
  models: flat.models,
55
63
  weapons: flat.weapons,