vimp-engine 0.28.0 → 0.30.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.11.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.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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);
@@ -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) {
@@ -9,6 +9,11 @@ import { SERVICES } from '../../../config/clientServices.js';
9
9
  // assetsBase выглядит как чистый холст без единой строки в консоли, ради
10
10
  // этого правило и существует. Правило остаётся ERROR: оно работает на этапе
11
11
  // разработки игры, а не в рантайме.
12
+ //
13
+ // Что именно возвращает `hooks.services(core)`, статически не видно — хук
14
+ // требует живого ядра. Поэтому у плагина есть необязательное поле
15
+ // `serviceNames`: объявленный список имён снимает догадку, и правило снова
16
+ // умеет краснеть (без него оно опускается до WARN и пропускает опечатку).
12
17
  export { SERVICES };
13
18
 
14
19
  export default {
@@ -24,16 +29,39 @@ export default {
24
29
  return skip('no client parts.componentDependencies');
25
30
  }
26
31
 
32
+ const declared = ctx.clientPlugin?.serviceNames;
33
+ const listed = Array.isArray(declared) ? declared : [];
34
+
35
+ if (declared !== undefined && !Array.isArray(declared)) {
36
+ return verdict([
37
+ `ClientPlugin.serviceNames is ${typeof declared}, expected an array ` +
38
+ 'of service names returned by hooks.services(core)',
39
+ ]);
40
+ }
41
+
27
42
  // раскладка «сервис → парты, которым он нужен» (client.js игры)
28
43
  const unknown = Object.keys(deps).filter(
29
- service => !SERVICES.includes(service),
44
+ service => !SERVICES.includes(service) && !listed.includes(service),
30
45
  );
31
46
 
32
47
  if (unknown.length === 0) {
33
48
  return verdict([]);
34
49
  }
35
50
 
36
- const pool = SERVICES.join(', ');
51
+ const pool = [...SERVICES, ...listed].join(', ');
52
+
53
+ // список игровых сервисов объявлен — гадать больше не о чем: имя вне
54
+ // объединённого пула не даст парту ничего, кроме undefined
55
+ if (listed.length) {
56
+ return verdict(
57
+ unknown.map(
58
+ service =>
59
+ `componentDependencies declares service "${service}" — neither ` +
60
+ `the engine nor ClientPlugin.serviceNames provides it (${pool}), ` +
61
+ 'so it is silently undefined in the part',
62
+ ),
63
+ );
64
+ }
37
65
  // hooks.services(core) требует живой инстанс ядра, а игровой код чекер не
38
66
  // запускает — статически перечислить игровые сервисы нечем. Поэтому при
39
67
  // объявленном хуке имя вне движкового пула остаётся строкой отчёта, но
@@ -0,0 +1,344 @@
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
+ continue;
144
+ }
145
+
146
+ // 9. рампа, ведущая в пустоту: за верхним концом прогона обязана быть
147
+ // поверхность уровня `to`. Иначе танк доезжает до вершины и в тот же шаг
148
+ // срывается вниз — молча, потому что и подъём, и падение штатные правила
149
+ for (const [x, y] of rampRunExits(fromGrid ?? [], ramp?.tile, ramp?.dir)) {
150
+ if (!walkable(map, to, x, y)) {
151
+ violations.push(
152
+ `${at}: ramp ${index} run ends at (${x}, ${y}), which is not ` +
153
+ `walkable ground of level ${to}`,
154
+ );
155
+ }
156
+ }
157
+ }
158
+
159
+ // 10. край плиты без перил — обрыв; приземлиться с него нужно на
160
+ // проходимую землю, иначе танк уезжает за карту (стены уровня 0 плите не
161
+ // преграда) или приземляется внутри стены
162
+ checkLevelEdges(at, map, violations);
163
+
164
+ // 6. точка респауна: [x, y, angle] либо [x, y, angle, level]
165
+ for (const [team, points] of Object.entries(map.respawns ?? {})) {
166
+ for (const [index, point] of (points ?? []).entries()) {
167
+ if (point.length !== 3 && point.length !== 4) {
168
+ violations.push(
169
+ `${at}: respawns ${team}[${index}] has ${point.length} numbers, ` +
170
+ 'expected 3 or 4',
171
+ );
172
+ continue;
173
+ }
174
+
175
+ if (point.length === 4 && point[3] >= levelCount) {
176
+ violations.push(
177
+ `${at}: respawns ${team}[${index}] level ${point[3]} is out of ` +
178
+ `range (levels: ${levelCount})`,
179
+ );
180
+ }
181
+ }
182
+ }
183
+
184
+ // 7. уровень динамического тела
185
+ for (const [index, object] of (map.physicsDynamic ?? []).entries()) {
186
+ const level = object?.level ?? 0;
187
+
188
+ if (level >= levelCount) {
189
+ violations.push(
190
+ `${at}: physicsDynamic ${index} level ${level} is out of range ` +
191
+ `(levels: ${levelCount})`,
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ // --- геометрия уровней (зеркало vimp_engine_core::map::validate_levels) ---
198
+
199
+ function cellAt(grid, x, y) {
200
+ if (x < 0 || y < 0) {
201
+ return undefined;
202
+ }
203
+
204
+ return grid?.[y]?.[x];
205
+ }
206
+
207
+ // земля проходима, если клетка есть в гриде и её тайл не объявлен стеной
208
+ function groundWalkable(map, x, y) {
209
+ const tile = cellAt(map.map ?? [], x, y);
210
+
211
+ return tile !== undefined && !(map.physicsStatic ?? []).includes(tile);
212
+ }
213
+
214
+ function walkable(map, level, x, y) {
215
+ if (level === 0) {
216
+ return groundWalkable(map, x, y);
217
+ }
218
+
219
+ const cfg = (map.levels ?? {})[String(level)];
220
+
221
+ if (!cfg) {
222
+ return false;
223
+ }
224
+
225
+ const tile = cellAt(cfg.map ?? [], x, y);
226
+
227
+ return (
228
+ tile !== undefined &&
229
+ (cfg.floor ?? []).includes(tile) &&
230
+ !(cfg.walls ?? []).includes(tile)
231
+ );
232
+ }
233
+
234
+ // клетки, следующие за верхними концами прогонов рампы (прогон —
235
+ // непрерывная линия одинаковых тайлов вдоль оси рампы)
236
+ function rampRunExits(grid, tile, dir) {
237
+ const sign = dir === 'south' || dir === 'east' ? 1 : -1;
238
+ const vertical = dir === 'north' || dir === 'south';
239
+ const out = [];
240
+
241
+ if (vertical) {
242
+ const cols = Math.max(0, ...grid.map(row => row.length));
243
+
244
+ for (let x = 0; x < cols; x += 1) {
245
+ let y = 0;
246
+
247
+ while (y < grid.length) {
248
+ if (grid[y][x] !== tile) {
249
+ y += 1;
250
+ continue;
251
+ }
252
+
253
+ const y0 = y;
254
+
255
+ while (y < grid.length && grid[y][x] === tile) {
256
+ y += 1;
257
+ }
258
+
259
+ out.push([x, sign > 0 ? y : y0 - 1]);
260
+ }
261
+ }
262
+
263
+ return out;
264
+ }
265
+
266
+ grid.forEach((row, y) => {
267
+ let x = 0;
268
+
269
+ while (x < row.length) {
270
+ if (row[x] !== tile) {
271
+ x += 1;
272
+ continue;
273
+ }
274
+
275
+ const x0 = x;
276
+
277
+ while (x < row.length && row[x] === tile) {
278
+ x += 1;
279
+ }
280
+
281
+ out.push([sign > 0 ? x : x0 - 1, y]);
282
+ }
283
+ });
284
+
285
+ return out;
286
+ }
287
+
288
+ const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]];
289
+
290
+ function checkLevelEdges(at, map, violations) {
291
+ for (const [key, level] of Object.entries(map.levels ?? {})) {
292
+ const floor = level?.floor ?? [];
293
+ const walls = level?.walls ?? [];
294
+ const grid = level?.map ?? [];
295
+
296
+ grid.forEach((row, y) => {
297
+ row.forEach((tile, x) => {
298
+ // перила закрывают край сами; клетка вне плиты краем не бывает
299
+ if (!floor.includes(tile) || walls.includes(tile)) {
300
+ return;
301
+ }
302
+
303
+ for (const [dx, dy] of NEIGHBOURS) {
304
+ const nx = x + dx;
305
+ const ny = y + dy;
306
+ const neighbour = cellAt(grid, nx, ny);
307
+
308
+ if (neighbour !== undefined && floor.includes(neighbour)) {
309
+ continue;
310
+ }
311
+
312
+ if (groundWalkable(map, nx, ny)) {
313
+ continue;
314
+ }
315
+
316
+ violations.push(
317
+ `${at}: level ${key} floor cell (${x}, ${y}) has an open edge ` +
318
+ `at (${nx}, ${ny}) with no walkable ground below — close it ` +
319
+ 'with a wall tile',
320
+ );
321
+ }
322
+ });
323
+ });
324
+ }
325
+ }
326
+
327
+ function checkLayerFrames(at, where, layers, spriteSheet, violations) {
328
+ const frames = spriteSheet?.frames;
329
+
330
+ if (!Array.isArray(frames)) {
331
+ return;
332
+ }
333
+
334
+ for (const [layer, tiles] of Object.entries(layers ?? {})) {
335
+ for (const tile of tiles ?? []) {
336
+ if (frames[tile] === undefined) {
337
+ violations.push(
338
+ `${at}: ${where} layer ${layer} names tile ${tile}, ` +
339
+ `spriteSheet.frames has ${frames.length}`,
340
+ );
341
+ }
342
+ }
343
+ }
344
+ }
@@ -0,0 +1,79 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Радар рисует стены не из физики, а из рендер-слоя: парт слоя берётся за
4
+ // работу, только если его тайл-лист (`layers`) называет хотя бы один тайл из
5
+ // `physicsStatic`/`walls`. Карта, где тайл стены не назван ни в одном слое,
6
+ // физически стены имеет, а на радаре — нет, и ни одной строки в консоли при
7
+ // этом не появляется. Правило безвредно для карты без `layers`: проверять
8
+ // тогда нечего.
9
+ export default {
10
+ id: 'E5',
11
+ name: 'mapRadarWalls',
12
+ level: ERROR,
13
+ title: 'solid tiles are named by a render layer',
14
+
15
+ check(ctx) {
16
+ const maps = ctx.gameConfig?.maps;
17
+
18
+ if (!maps) {
19
+ return skip('no gameConfig.maps');
20
+ }
21
+
22
+ const violations = [];
23
+ let checked = 0;
24
+
25
+ for (const [name, map] of Object.entries(maps)) {
26
+ checked += checkMap(`map "${name}"`, map, violations);
27
+ }
28
+
29
+ if (!checked) {
30
+ return skip('no map declares render layers');
31
+ }
32
+
33
+ return verdict(violations);
34
+ },
35
+ };
36
+
37
+ /**
38
+ * @param {string} at - Человекочитаемое место нарушения.
39
+ * @param {Object} map - Карта из gameConfig.maps.
40
+ * @param {Array<string>} violations - Копилка нарушений.
41
+ * @returns {number} Сколько гридов удалось проверить (0 — слоёв нет).
42
+ */
43
+ function checkMap(at, map, violations) {
44
+ let checked = 0;
45
+
46
+ checked += checkGrid(at, 'level 0', map?.layers, map?.physicsStatic, violations);
47
+
48
+ for (const [key, level] of Object.entries(map?.levels ?? {})) {
49
+ checked += checkGrid(
50
+ at,
51
+ `level ${key}`,
52
+ level?.layers,
53
+ level?.walls,
54
+ violations,
55
+ );
56
+ }
57
+
58
+ return checked;
59
+ }
60
+
61
+ function checkGrid(at, where, layers, solid, violations) {
62
+ if (!layers || !Object.keys(layers).length) {
63
+ return 0;
64
+ }
65
+
66
+ const named = new Set(Object.values(layers).flat());
67
+
68
+ for (const tile of solid ?? []) {
69
+ if (!named.has(tile)) {
70
+ violations.push(
71
+ `${at}: ${where} solid tile ${tile} is not named by any render ` +
72
+ 'layer — MapRadar draws walls only from a layer that lists a ' +
73
+ 'solid tile, so the wall silently disappears from the radar',
74
+ );
75
+ }
76
+ }
77
+
78
+ return 1;
79
+ }
@@ -32,6 +32,8 @@ 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';
36
+ import e5 from './e5-map-radar-walls.js';
35
37
 
36
38
  // Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
37
39
  // D снапшот, E ассеты (plan/create-vimp-game/stage_1.md).
@@ -40,7 +42,7 @@ export const rules = [
40
42
  b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
41
43
  c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11,
42
44
  d1, d2, d3,
43
- e1, e2, e3,
45
+ e1, e2, e3, e4, e5,
44
46
  ];
45
47
 
46
48
  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
  * по движковой/игровой половине автоматически.
@@ -31,6 +34,7 @@ export const buildCoreConfig = (gameConfig, overrides = {}) => {
31
34
  weapons,
32
35
  playerKeys: gameConfig.playerKeys,
33
36
  panel: gameConfig.panel.fields,
37
+ coreParams: gameConfig.coreParams,
34
38
  // keys — игровая схема (gameConfig.snapshot); version/port — движковые
35
39
  snapshot: {
36
40
  version: SNAPSHOT_FORMAT_VERSION,
@@ -50,6 +54,11 @@ export const buildCoreConfig = (gameConfig, overrides = {}) => {
50
54
  seed: flat.seed,
51
55
  },
52
56
  game: {
57
+ // собственные параметры ядра игры: движок их не читает и не
58
+ // валидирует — он лишь довозит их до `GameSim::new`. Известные ключи
59
+ // ниже перекрывают одноимённые, чтобы игра не могла подменить
60
+ // движковую часть контракта
61
+ ...(flat.coreParams || {}),
53
62
  friendlyFire: flat.friendlyFire,
54
63
  models: flat.models,
55
64
  weapons: flat.weapons,
@@ -48,8 +48,16 @@ export default {
48
48
  // имя файла — инертные данные: фикстура host-only, рендера в ней нет
49
49
  // и картинку никто не грузит. Реальные тайлы везёт пакет игры
50
50
  // (`${assetsBase}img/`), движок картинок не раздаёт
51
- spriteSheet: { img: 'fixture-tiles.png', frames: [[0, 0, 32, 32]] },
52
- layers: { 1: [0] },
51
+ spriteSheet: {
52
+ img: 'fixture-tiles.png',
53
+ frames: [
54
+ [0, 0, 32, 32],
55
+ [32, 0, 32, 32],
56
+ ],
57
+ },
58
+ // тайл стены обязан быть назван рендер-слоем: радар рисует стены
59
+ // только из слоя, который их перечисляет (правило E5)
60
+ layers: { 1: [0, 1] },
53
61
  physicsStatic: [1],
54
62
  physicsDynamic: [],
55
63
  step: 32,