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 +1 -1
- package/package.json +1 -1
- package/src/client/components/controller/Games.js +5 -0
- package/src/client/components/model/Games.js +36 -2
- package/src/client/components/view/Games.js +39 -25
- package/src/client/main.js +37 -8
- package/src/client/style.css +15 -24
- package/src/config/lobby.js +9 -2
- package/src/devtools/VirtualClient.js +5 -0
- package/src/devtools/contract/rules/e4-map-layers.js +196 -0
- package/src/devtools/contract/rules/index.js +2 -1
- package/src/host/GameCoreAdapter.js +17 -0
- package/src/host/meta/core/RoundManager.js +3 -1
- package/src/lib/buildClientConfig.js +5 -0
- package/src/lib/capabilities.js +3 -0
- package/src/lib/clientCoreConfig.js +10 -1
- package/src/lib/coreConfig.js +9 -1
package/core/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vimp-engine-core"
|
|
3
|
-
version = "0.
|
|
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
|
@@ -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
|
|
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.
|
|
292
|
-
|
|
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.
|
|
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
|
|
package/src/client/main.js
CHANGED
|
@@ -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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
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);
|
package/src/client/style.css
CHANGED
|
@@ -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:
|
|
220
|
+
overflow: auto;
|
|
221
221
|
width: 90%;
|
|
222
|
-
max-width:
|
|
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:
|
|
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:
|
|
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:
|
|
827
|
+
gap: 5px;
|
|
836
828
|
justify-content: center;
|
|
837
829
|
}
|
|
838
830
|
|
|
839
831
|
#lobby-setup-panel {
|
|
840
|
-
width: var(--lobby-
|
|
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-
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
color: #
|
|
1147
|
+
/* срок полного удаления — единственное, что в карточке удалённой игры
|
|
1148
|
+
ограничено во времени */
|
|
1149
|
+
.games-purge-line {
|
|
1150
|
+
color: #d66;
|
|
1160
1151
|
}
|
|
1161
1152
|
|
|
1162
1153
|
/* кнопки входа в панель стоят рядом с Sign out в бейдже пользователя */
|
package/src/config/lobby.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
// снапшот-схема игры: клиент собирает по ней конфиг клиентского ядра и
|
package/src/lib/capabilities.js
CHANGED
|
@@ -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,
|
package/src/lib/coreConfig.js
CHANGED
|
@@ -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,
|