vimp-engine 0.29.0 → 0.30.1

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.10.0"
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.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -442,7 +442,6 @@ body.hide-cursor {
442
442
  text-shadow: 1px 1px 1px #000;
443
443
  font-size: 12px;
444
444
  line-height: 15px;
445
- border-radius: 4px;
446
445
  }
447
446
 
448
447
  .card h2 {
@@ -546,21 +545,27 @@ body.hide-cursor {
546
545
  #auth-link {
547
546
  font-size: 9px;
548
547
  margin: 0;
549
- padding: 0 10px;
548
+ padding: 0 5px;
550
549
  }
551
550
 
552
551
  #lobby-footer {
553
- width: calc(var(--lobby-width) + var(--lobby-panel-pad));
554
552
  font-size: 10px;
555
553
  padding: 0;
556
- margin: 0 calc(var(--lobby-panel-pad) / 2) 0 0;
554
+ }
555
+
556
+ #lobby-footer p {
557
+ margin: 0 5px;
558
+ }
559
+
560
+ #lobby-footer p:not(:last-child)::after {
561
+ content: '|';
562
+ padding-left: 10px;
557
563
  }
558
564
 
559
565
  /* три равные ячейки: середину держит не space-between, а сама равность —
560
566
  любая из крайних бывает пустой (у пакета игры может не быть метаданных),
561
567
  и при space-between версия съезжала бы с центра */
562
- #auth-link p,
563
- #lobby-footer p {
568
+ #auth-link p {
564
569
  margin: 0;
565
570
  flex: 1;
566
571
  }
@@ -888,7 +893,8 @@ body.hide-cursor {
888
893
  колонку не нужно: с ним align-items: center увёл бы его влево на
889
894
  половину поля */
890
895
  #lobby-footer {
891
- margin: 0 auto;
896
+ justify-content: flex-end;
897
+ width: calc(var(--lobby-width) + var(--lobby-panel-pad) * 2);
892
898
  }
893
899
 
894
900
  /* в столбик высоту строкой никто не задаёт — возвращаем слой в поток,
@@ -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
  // объявленном хуке имя вне движкового пула остаётся строкой отчёта, но
@@ -140,9 +140,27 @@ function checkMap(name, map, violations) {
140
140
  `${at}: ramp ${index} tile ${ramp?.tile} is missing from ` +
141
141
  `level ${from} grid`,
142
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
+ }
143
156
  }
144
157
  }
145
158
 
159
+ // 10. край плиты без перил — обрыв; приземлиться с него нужно на
160
+ // проходимую землю, иначе танк уезжает за карту (стены уровня 0 плите не
161
+ // преграда) или приземляется внутри стены
162
+ checkLevelEdges(at, map, violations);
163
+
146
164
  // 6. точка респауна: [x, y, angle] либо [x, y, angle, level]
147
165
  for (const [team, points] of Object.entries(map.respawns ?? {})) {
148
166
  for (const [index, point] of (points ?? []).entries()) {
@@ -176,6 +194,136 @@ function checkMap(name, map, violations) {
176
194
  }
177
195
  }
178
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
+
179
327
  function checkLayerFrames(at, where, layers, spriteSheet, violations) {
180
328
  const frames = spriteSheet?.frames;
181
329
 
@@ -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
+ }
@@ -33,6 +33,7 @@ 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
35
  import e4 from './e4-map-layers.js';
36
+ import e5 from './e5-map-radar-walls.js';
36
37
 
37
38
  // Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
38
39
  // D снапшот, E ассеты (plan/create-vimp-game/stage_1.md).
@@ -41,7 +42,7 @@ export const rules = [
41
42
  b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
42
43
  c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11,
43
44
  d1, d2, d3,
44
- e1, e2, e3, e4,
45
+ e1, e2, e3, e4, e5,
45
46
  ];
46
47
 
47
48
  export default rules;
@@ -34,6 +34,7 @@ export const buildCoreConfig = (gameConfig, overrides = {}) => {
34
34
  weapons,
35
35
  playerKeys: gameConfig.playerKeys,
36
36
  panel: gameConfig.panel.fields,
37
+ coreParams: gameConfig.coreParams,
37
38
  // keys — игровая схема (gameConfig.snapshot); version/port — движковые
38
39
  snapshot: {
39
40
  version: SNAPSHOT_FORMAT_VERSION,
@@ -57,7 +58,7 @@ export const buildCoreConfig = (gameConfig, overrides = {}) => {
57
58
  // валидирует — он лишь довозит их до `GameSim::new`. Известные ключи
58
59
  // ниже перекрывают одноимённые, чтобы игра не могла подменить
59
60
  // движковую часть контракта
60
- ...(gameConfig.coreParams || {}),
61
+ ...(flat.coreParams || {}),
61
62
  friendlyFire: flat.friendlyFire,
62
63
  models: flat.models,
63
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,