vimp-engine 0.28.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/main.js +37 -8
- 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
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);
|
|
@@ -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,
|