vimp-engine 0.18.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,8 +14,10 @@ export default class VoteModel {
14
14
 
15
15
  this._formatMessage = data.formatMessage;
16
16
 
17
- this._menu = data.menu; // меню
18
- this._templates = data.templates; // шаблоны голосований
17
+ // игра может не объявлять голосований вовсе (noSpectators): меню по 'm'
18
+ // тогда пустое, а не падение на длине undefined
19
+ this._menu = data.menu || []; // меню
20
+ this._templates = data.templates || {}; // шаблоны голосований
19
21
 
20
22
  this._type = ''; // тип ('menu', 'vote')
21
23
  this._waitingValues = false; // ожидания значений
@@ -32,6 +32,38 @@ body {
32
32
  display: none;
33
33
  }
34
34
 
35
+ /* Леттербокс по центру. `CanvasManagerModel.resize` считает размер полотна
36
+ под `aspectRatio` (видимая ширина мира при этом всегда `designWidth /
37
+ baseScale`, от размера окна она не зависит), но само полотно
38
+ `ensureCanvas` кладёт в контейнер обычным потоком — и чёрная полоса
39
+ оказывалась вся с одной стороны. Здесь позиционируется готовый элемент,
40
+ математику `resize` правило не трогает.
41
+
42
+ Целится в тот же маркер каркаса, что и `.vimp-shell > *`, чтобы в
43
+ standalone SDK не задеть встраивающую страницу. Раскладку контейнера
44
+ правило не меняет — центрируется само полотно (`inset` + `margin: auto`
45
+ на элементе известного размера), поэтому остальные экраны движка
46
+ (#panel, #stat, #chat, #auth, #lobby, информеры — все `position:
47
+ absolute`) остаются как были. Контейнер обязан быть позиционированным —
48
+ того же требуют #panel и #stat.
49
+
50
+ Класс обязателен, селектора по типу мало: `ensureCanvas` вешает
51
+ `vimp-letterbox` только на полотно без `fixSize`, то есть на то, размер
52
+ которого движок считает сам. Полотно фиксированного размера — оверлей
53
+ игры (радар в vimp-tanks: `position: absolute; right: 1%; top: 35px`), и
54
+ правило по типу `canvas` подмешивало бы ему `bottom`/`left`/`margin`,
55
+ которых в игровом правиле нет: при `top`/`bottom` и `left`/`right`
56
+ одновременно `margin: auto` центрирует элемент, и оверлей уезжал на
57
+ середину экрана. Правил на такие полотна у движка нет вовсе. */
58
+ .vimp-shell > canvas.vimp-letterbox {
59
+ position: absolute;
60
+ top: 0;
61
+ bottom: 0;
62
+ left: 0;
63
+ right: 0;
64
+ margin: auto;
65
+ }
66
+
35
67
  body.hide-cursor {
36
68
  cursor: none;
37
69
  }
@@ -466,7 +498,7 @@ body.hide-cursor {
466
498
  color: #e00;
467
499
  font-size: 12px;
468
500
  line-height: 15px;
469
- margin: 0 5px;
501
+ margin: 0 5px 10px;
470
502
  }
471
503
 
472
504
  /* пустой блок ошибок не должен занимать место: #lobby-error — флекс-элемент
@@ -549,12 +581,6 @@ body.hide-cursor {
549
581
  border: 1px solid #999;
550
582
  }
551
583
 
552
- /* вертикаль внутри #lobby-create держит его gap — собственный margin
553
- коробки удвоил бы отступ до соседей */
554
- #lobby-fields {
555
- margin: 0;
556
- }
557
-
558
584
  .auth-inform h4 {
559
585
  margin: 0 0 4px 0;
560
586
  text-align: center;
@@ -75,6 +75,14 @@ const SHELL = [
75
75
  // правило совпадает с прежним `body > *`; в SDK это чужой div
76
76
  export const SHELL_CLASS = 'vimp-shell';
77
77
 
78
+ // Класс полотна, которое движок сам кладёт по центру контейнера (леттербокс,
79
+ // см. `.vimp-shell > canvas.vimp-letterbox` в style.css). Он достаётся ТОЛЬКО
80
+ // полотну без `fixSize`: полотно фиксированного размера — это оверлей
81
+ // (радар в vimp-tanks), его раскладку держит игра, и общее правило по типу
82
+ // `canvas` подмешивало бы такому элементу `bottom`/`left`/`margin`, которых
83
+ // в игровом правиле нет, — оверлей уезжал в центр экрана
84
+ export const LETTERBOX_CLASS = 'vimp-letterbox';
85
+
78
86
  /**
79
87
  * Достраивает недостающие контейнеры игрового интерфейса.
80
88
  *
@@ -97,7 +105,8 @@ export function ensureGameShell(container = document.body) {
97
105
  * документе элемент переиспользуется как есть — если игра положила
98
106
  * <canvas id="vimp"> в свою разметку, движок его не переносит.
99
107
  * @param {string} canvasId
100
- * @param {Object} size - { width, height } из конфига канвасов.
108
+ * @param {Object} size - запись канваса из конфига: { width, height } и
109
+ * раскладочные ключи (`fixSize`, `aspectRatio`).
101
110
  * @param {HTMLElement} [container] - Точка монтирования (по умолчанию body).
102
111
  * @returns {HTMLCanvasElement}
103
112
  */
@@ -112,6 +121,14 @@ export function ensureCanvas(canvasId, size, container = document.body) {
112
121
  // палец на полотне ведёт игру, а не страницу: без этого браузер
113
122
  // смартфона забирает жест себе — прокрутка, pull-to-refresh, зум
114
123
  canvas.style.touchAction = 'none';
124
+
125
+ // центрирует движок, и только то полотно, которое он же и растягивает
126
+ // (`CanvasManagerModel.resize`): у `fixSize`-полотна размер задан игрой,
127
+ // и место на экране — тоже её дело
128
+ if (!size.fixSize) {
129
+ canvas.classList.add(LETTERBOX_CLASS);
130
+ }
131
+
115
132
  container.appendChild(canvas);
116
133
  }
117
134
 
@@ -2,14 +2,15 @@ import { assertGameConfigShape } from '../../../lib/gamePlugin.js';
2
2
  import { ERROR, skip, verdict } from '../result.js';
3
3
 
4
4
  // Тот же гейт, что стоит на боевом пути загрузки плагина
5
- // (lib/gamePlugin.js): девять обязательных путей gameConfig плюс связь
6
- // spectatorTeam ↔ teams. Дублировать его список здесь нельзя — он обязан
7
- // эволюционировать в одном месте.
5
+ // (lib/gamePlugin.js): обязательные пути gameConfig плюс связь
6
+ // spectatorTeam ↔ teams (под noSpectators «ровно одна команда»).
7
+ // Дублировать его список здесь нельзя — он обязан эволюционировать в одном
8
+ // месте.
8
9
  export default {
9
10
  id: 'B3',
10
11
  name: 'gameConfigShape',
11
12
  level: ERROR,
12
- title: 'gameConfig has the nine paths the engine reads before any logic',
13
+ title: 'gameConfig has the paths the engine reads before any logic',
13
14
 
14
15
  check(ctx) {
15
16
  if (!ctx.hostPlugin) {
@@ -3,6 +3,7 @@ import { ERROR, skip, verdict } from '../result.js';
3
3
  // teams/spectatorTeam. Опечатка в spectatorTeam ловится гейтом B3; здесь —
4
4
  // вторая половина контракта: играющая команда должна быть хотя бы одна,
5
5
  // иначе раунд некому начинать, а все участники висят наблюдателями.
6
+ // Под noSpectators наблюдателей нет вовсе: команда обязана быть ровно одна.
6
7
  export default {
7
8
  id: 'B4',
8
9
  name: 'teams',
@@ -19,6 +20,19 @@ export default {
19
20
  const { spectatorTeam } = ctx.gameConfig;
20
21
  const violations = [];
21
22
 
23
+ if (ctx.gameConfig.noSpectators === true) {
24
+ const names = Object.keys(teams);
25
+
26
+ if (names.length !== 1) {
27
+ violations.push(
28
+ `noSpectators requires exactly one team, got ${names.length} ` +
29
+ `(${names.join(', ')})`,
30
+ );
31
+ }
32
+
33
+ return verdict(violations);
34
+ }
35
+
22
36
  if (teams[spectatorTeam] === undefined) {
23
37
  violations.push(
24
38
  `spectatorTeam "${spectatorTeam}" is not a key of teams ` +
@@ -111,8 +111,16 @@ export default class HostGame {
111
111
  this._idleTimeoutForSpectator = data.idleKickTimeout?.spectator || null;
112
112
 
113
113
  this._teams = data.teams;
114
- this._spectatorTeam = data.spectatorTeam;
115
- this._spectatorId = this._teams[this._spectatorTeam];
114
+
115
+ // opt-in флаги игры: noSpectators — наблюдателей нет как концепции
116
+ // (spectatorTeam/spectatorId равны null, вход ведёт прямо в игру),
117
+ // endlessRound — раунд не перезапускается сам. Флаги независимы
118
+ this._noSpectators = data.noSpectators === true;
119
+ this._endlessRound = data.endlessRound === true;
120
+ this._spectatorTeam = this._noSpectators ? null : data.spectatorTeam;
121
+ this._spectatorId = this._noSpectators
122
+ ? null
123
+ : this._teams[this._spectatorTeam];
116
124
 
117
125
  // единый реестр участников (игроки + scripted)
118
126
  this._participants = new ParticipantManager(
@@ -168,7 +176,7 @@ export default class HostGame {
168
176
 
169
177
  this._timerManager = new TimerManager(data.timers, {
170
178
  onMapTimeEnd: () => this._roundManager.onMapTimeEnd(),
171
- onRoundTimeEnd: () => this._roundManager.initiateNewRound(),
179
+ onRoundTimeEnd: () => this._roundManager.onRoundTimeEnd(),
172
180
  onShotTick: dt => this._onShotTick(dt),
173
181
  onIdleCheck: () => this._kickIdleUsers(),
174
182
  onSendPing: () => this._sendPing(),
@@ -195,6 +203,8 @@ export default class HostGame {
195
203
  teams: this._teams,
196
204
  spectatorTeam: this._spectatorTeam,
197
205
  spectatorId: this._spectatorId,
206
+ noSpectators: this._noSpectators,
207
+ endlessRound: this._endlessRound,
198
208
  maps: data.maps,
199
209
  mapList: this._mapList,
200
210
  mapsInVote: data.mapsInVote,
@@ -501,6 +511,13 @@ export default class HostGame {
501
511
  this._socketManager.sendTechInform(socketId);
502
512
  this._socketManager.sendFirstVote(socketId);
503
513
  this._chat.pushSystem('USER_JOINED', [user.name]);
514
+
515
+ // noSpectators: голосования за вход нет — участник уже в играющей
516
+ // команде, осталось выдать ему актора. Именно здесь, а не в createUser:
517
+ // sendFirstShot выше шлёт keyset наблюдателя и затёр бы клавиши игрока
518
+ if (this._noSpectators) {
519
+ this._roundManager.admitPlayer(gameId);
520
+ }
504
521
  }
505
522
 
506
523
  // обрабатывает уничтожение игрока (прокси к RoundManager; из событий ядра)
@@ -836,7 +853,9 @@ export default class HostGame {
836
853
 
837
854
  this._chat.addUser(gameId);
838
855
  this._vote.addUser(gameId);
839
- this._stat.addUser(gameId, this._spectatorId, { name });
856
+ // строка stat заводится в той команде, куда участник и попал:
857
+ // наблюдатели, а под noSpectators — сразу играющая команда
858
+ this._stat.addUser(gameId, this._participants.joinTeamId, { name });
840
859
  this._panel.addUser(gameId);
841
860
  this._RTTManager.addUser(gameId);
842
861
 
@@ -1015,6 +1034,13 @@ export default class HostGame {
1015
1034
  return this._playerDataSync.getRank(gameId);
1016
1035
  }
1017
1036
 
1037
+ // загружен ли ранг с мастера. getPlayerRank отвечает 0 и для незнакомого
1038
+ // id, и до ответа auth — игре, которая пишет ранг в stat колонкой '=',
1039
+ // нужно отличать «ранг 0» от «ранга ещё нет»
1040
+ isPlayerRankLoaded(gameId) {
1041
+ return this._playerDataSync.isRankLoaded(gameId);
1042
+ }
1043
+
1018
1044
  getPlayerState(gameId) {
1019
1045
  return this._playerDataSync.getState(gameId);
1020
1046
  }
@@ -1023,6 +1049,20 @@ export default class HostGame {
1023
1049
  this._playerDataSync.setState(gameId, state);
1024
1050
  }
1025
1051
 
1052
+ // карта, по которой движок расставляет участников на старте раунда — для
1053
+ // игр, которые пересобирают геометрию на лету, минуя смену карты (прокси к
1054
+ // RoundManager; карта комнаты и текущий раунд не трогаются)
1055
+ overrideMapData(mapData) {
1056
+ this._roundManager.overrideMapData(mapData);
1057
+ }
1058
+
1059
+ // прибавка к рангу для игр, которые не эмитят CoreEvent::Death и потому
1060
+ // никогда не проходят через RoundManager.reportKill: прямая прокладка к
1061
+ // PlayerDataSync, без раунд-логики и без проверки team-wipe
1062
+ addPlayerRank(gameId, delta) {
1063
+ this._playerDataSync.addRank(gameId, delta);
1064
+ }
1065
+
1026
1066
  // hostId + per-room секрет комнаты, подтверждённые мастером при
1027
1067
  // register_host (кодревью №1) — не известны при создании HostGame (Worker
1028
1068
  // стартует раньше ответа мастера); нужны PlayerDataSync для атрибуции
@@ -49,6 +49,13 @@ class RoundManager {
49
49
  this._teams = deps.teams;
50
50
  this._spectatorTeam = deps.spectatorTeam;
51
51
  this._spectatorId = deps.spectatorId;
52
+
53
+ // opt-in флаги игры (gameConfig): noSpectators — наблюдателей нет как
54
+ // концепции (spectatorTeam/spectatorId равны null, вход ведёт прямо в
55
+ // игру); endlessRound — раунд не перезапускается сам, только по явной
56
+ // команде игры или игрока. Флаги независимы
57
+ this._noSpectators = deps.noSpectators === true;
58
+ this._endlessRound = deps.endlessRound === true;
52
59
  this._maps = deps.maps;
53
60
  this._mapList = deps.mapList;
54
61
  this._mapsInVote = deps.mapsInVote;
@@ -79,6 +86,16 @@ class RoundManager {
79
86
  return this._removedPlayersList;
80
87
  }
81
88
 
89
+ // истечение времени раунда. Под endlessRound раунд не заканчивается сам:
90
+ // явные перезапуски (/nr, смена карты) продолжают работать
91
+ onRoundTimeEnd() {
92
+ if (this._endlessRound) {
93
+ return;
94
+ }
95
+
96
+ this.initiateNewRound();
97
+ }
98
+
82
99
  // запускает голосование за смену карты по истечении времени карты
83
100
  onMapTimeEnd() {
84
101
  this._voteCoordinator.reset();
@@ -147,18 +164,24 @@ class RoundManager {
147
164
  this._socketManager.sendSpectatorDefaultShot(user.socketId);
148
165
  this._socketManager.sendClear(user.socketId);
149
166
 
150
- // перемещение пользователя в наблюдатели
151
- this._stat.moveUser(gameId, user.teamId, this._spectatorId);
167
+ // перемещение пользователя в наблюдатели. Под noSpectators переводить
168
+ // некуда: участник остаётся в своей играющей команде и получит актора
169
+ // заново, как только загрузит карту (admitPlayer)
170
+ if (!this._noSpectators) {
171
+ this._stat.moveUser(gameId, user.teamId, this._spectatorId);
172
+ user.team = this._spectatorTeam;
173
+ user.teamId = this._spectatorId;
174
+ }
152
175
 
153
176
  // обнулить параметры
154
- user.team = this._spectatorTeam;
155
- user.teamId = this._spectatorId;
156
177
  user.status = 'spectator';
157
178
  user.isWatching = true;
179
+ // акторов на новой карте ещё нет, значит и занятых слотов нет
180
+ user.respawnIndex = null;
158
181
  user.watchedGameId = this._participants.getActiveList()[0] || null;
159
182
  user.forceCameraReset = true;
160
183
 
161
- this._participants.addToTeam(gameId, this._spectatorTeam);
184
+ this._participants.addToTeam(gameId, user.team);
162
185
 
163
186
  this.sendMap(gameId);
164
187
  }
@@ -184,6 +207,20 @@ class RoundManager {
184
207
  this._socketManager.sendMap(socketId, this._currentMapData);
185
208
  }
186
209
 
210
+ // Подменяет карту, которую движок раздаёт при старте раунда, НЕ меняя
211
+ // карту комнаты и не начиная раунд.
212
+ //
213
+ // Для игр, которые пересобирают геометрию на лету и не могут пройти через
214
+ // `forceChangeMap` (он чистит мир, панель и таблицу): без этого
215
+ // `_startRound` раздавал бы точки респауна карты каталога, то есть исходного
216
+ // размера, и расставлял всех по геометрии, которой в ядре уже нет
217
+ // (`@vimp-games/snakes`, `src/host/ArenaScaler.js`).
218
+ overrideMapData(mapData) {
219
+ if (mapData) {
220
+ this._scaledMapData = mapData;
221
+ }
222
+ }
223
+
187
224
  // немедленная смена карты (когда голосование не требуется)
188
225
  forceChangeMap(mapName) {
189
226
  this._currentMap = mapName;
@@ -228,17 +265,25 @@ class RoundManager {
228
265
  .filter(key => key !== this._spectatorTeam)
229
266
  .reduce((acc, key) => ({ ...acc, [key]: 0 }), {});
230
267
 
231
- function getRespawnData(team) {
268
+ // индекс точки, а не сама точка: занятый слот запоминается на участнике
269
+ // (`Participant.respawnIndex`), и `admitPlayer` потом раздаёт свободные,
270
+ // а не «размер команды минус один»
271
+ function getRespawnIndex(team) {
232
272
  const number = respId[team];
233
273
 
234
274
  respId[team] += 1;
235
275
 
236
- return respawns[team][number];
276
+ return number;
237
277
  }
238
278
 
239
- // очищение списка играющих
279
+ // очищение списка играющих: акторов сняли со всех, поэтому и слоты
280
+ // респауна свободны — раздача ниже начинается с чистого листа
240
281
  this._participants.clearActive();
241
282
 
283
+ for (const participant of this._participants.getAll()) {
284
+ participant.respawnIndex = null;
285
+ }
286
+
242
287
  this._panel.reset();
243
288
 
244
289
  const setIdList = this._game.removePlayersAndShots();
@@ -258,9 +303,12 @@ class RoundManager {
258
303
  if (team === this._spectatorTeam) {
259
304
  user.isWatching = true;
260
305
  user.forceCameraReset = true;
306
+ user.respawnIndex = null;
261
307
  this._socketManager.sendSpectatorDefaultShot(socketId);
262
308
  } else {
263
- this._setActivePlayer(user, getRespawnData(team));
309
+ const index = getRespawnIndex(team);
310
+
311
+ this._setActivePlayer(user, respawns[team][index], index);
264
312
  }
265
313
 
266
314
  this._socketManager.sendSoundCue(socketId, 'roundStart');
@@ -269,8 +317,100 @@ class RoundManager {
269
317
 
270
318
  // размещение scripted-участников на карте
271
319
  for (const participant of this._participants.getScripted()) {
272
- this._setActivePlayer(participant, getRespawnData(participant.team));
320
+ const team = participant.team;
321
+ const index = getRespawnIndex(team);
322
+
323
+ this._setActivePlayer(participant, respawns[team][index], index);
324
+ }
325
+ }
326
+
327
+ // Первый не занятый слот респауна команды, или -1, если свободных нет.
328
+ //
329
+ // Занятость считается по участникам, у которых актор УЖЕ есть
330
+ // (`Participant.respawnIndex`), а не по размеру команды: между попаданием
331
+ // в команду (`createUser`) и выдачей актора (`firstShotReady`) проходит вся
332
+ // загрузка карты клиентом, а команда за это время успевает вырасти. Двое
333
+ // зашедших одновременно получали от «размера минус один» ОДИН индекс, и
334
+ // после смены карты его получали разом вообще все — `createMap` добавляет
335
+ // в команду всех до того, как хоть кто-то дойдёт до первого кадра.
336
+ //
337
+ // `exceptGameId` — участник, чей собственный слот занятостью не считается
338
+ // (changeTeam: переходящий ищет место в новой команде, ещё держа старое).
339
+ // Иначе его пришлось бы обнулять ДО проверки, и неудачный переход оставлял
340
+ // бы активного игрока с `respawnIndex === null` — его физически занятая
341
+ // точка выглядела бы свободной для следующего вызова.
342
+ _freeRespawnIndex(team, exceptGameId = null) {
343
+ const respawns = this._scaledMapData?.respawns?.[team] ?? [];
344
+ const taken = new Set();
345
+
346
+ for (const participant of this._participants.getAll()) {
347
+ if (
348
+ participant.team === team &&
349
+ participant.respawnIndex !== null &&
350
+ participant.gameId !== exceptGameId
351
+ ) {
352
+ taken.add(participant.respawnIndex);
353
+ }
354
+ }
355
+
356
+ for (let i = 0; i < respawns.length; i += 1) {
357
+ if (!taken.has(i)) {
358
+ return i;
359
+ }
360
+ }
361
+
362
+ return -1;
363
+ }
364
+
365
+ // Впускает участника прямо в играющую команду — путь входа игр с
366
+ // noSpectators: ни голосования, ни смены команды, ни правила «<2 игроков».
367
+ // Команду участник получил ещё в ParticipantManager.createHuman, здесь
368
+ // выдаётся только актор.
369
+ //
370
+ // Если свободной точки нет, освобождается одна за счёт scripted-участника —
371
+ // то же правило, что и в changeTeam: человек важнее бота. Если и это не
372
+ // помогло, возвращается false и игрок узнаёт об этом из чата: под
373
+ // noSpectators попросить место больше негде — голосования нет, а changeTeam
374
+ // в единственную команду отвечает «это ваша текущая команда». Место
375
+ // освободит чужой выход, а актора выдаст следующий _startRound.
376
+ admitPlayer(gameId) {
377
+ const user = this._participants.get(gameId);
378
+
379
+ if (!user || user.team === this._spectatorTeam) {
380
+ return false;
381
+ }
382
+
383
+ const respawns = this._scaledMapData?.respawns?.[user.team];
384
+ let index = this._freeRespawnIndex(user.team);
385
+
386
+ // бот вытесняется только там, где вытеснение помогает: `index === -1` —
387
+ // это «слоты кончились», тогда как пустой (или ещё не загруженный) список
388
+ // точек даёт то же самое отсутствие respawnData, но освободившееся место
389
+ // в нём всё равно не появится
390
+ const hasSlots = Boolean(respawns?.length);
391
+
392
+ if (
393
+ hasSlots &&
394
+ index === -1 &&
395
+ this._scripted.removeOneForHuman?.(user.team)
396
+ ) {
397
+ index = this._freeRespawnIndex(user.team);
398
+ }
399
+
400
+ const respawnData = respawns?.[index];
401
+
402
+ if (!respawnData) {
403
+ this._chat.pushSystemByUser(gameId, 'TEAMS_TEAM_FULL', [
404
+ user.team,
405
+ user.team,
406
+ ]);
407
+
408
+ return false;
273
409
  }
410
+
411
+ this._setActivePlayer(user, respawnData, index);
412
+
413
+ return true;
274
414
  }
275
415
 
276
416
  // меняет ник игрока
@@ -332,8 +472,10 @@ class RoundManager {
332
472
 
333
473
  this._stat.moveUser(gameId, oldTeamId, newTeamId);
334
474
 
335
- // если активных игроков меньше 2-х, рестарт раунда
475
+ // если активных игроков меньше 2-х, рестарт раунда (под endlessRound
476
+ // правило выключено: оно обнуляло бы stat на каждом входе и выходе)
336
477
  if (
478
+ !this._endlessRound &&
337
479
  this._participants
338
480
  .getHumans()
339
481
  .filter(u => u.teamId !== this._spectatorId && u.gameId !== gameId)
@@ -368,12 +510,29 @@ class RoundManager {
368
510
  }
369
511
  // игра активным игроком возможна в текущем раунде
370
512
  } else {
371
- const respawnIndex = this._participants.getTeamSize(newTeam) - 1;
513
+ // тот же аллокатор, что и у admitPlayer: слот берётся свободный, а не
514
+ // «размер команды минус один» — серия входов и выходов сдвигает размер
515
+ // на уже занятые точки. Свой собственный слот при этом не считается
516
+ // занятым, а старый не обнуляется до успеха: ранний return ниже уходит
517
+ // уже после смены команды, и участник остался бы активным игроком без
518
+ // записанного слота
519
+ const respawnIndex = this._freeRespawnIndex(newTeam, gameId);
372
520
  const respawnData = respawns[newTeam][respawnIndex];
373
521
 
522
+ if (!respawnData) {
523
+ this._chat.pushSystemByUser(gameId, 'TEAMS_TEAM_FULL', [
524
+ newTeam,
525
+ currentTeam,
526
+ ]);
527
+
528
+ return;
529
+ }
530
+
531
+ user.respawnIndex = respawnIndex;
532
+
374
533
  // переход из наблюдателя в игрока
375
534
  if (oldTeamId === this._spectatorId) {
376
- this._setActivePlayer(user, respawnData);
535
+ this._setActivePlayer(user, respawnData, respawnIndex);
377
536
  // смена игровой команды
378
537
  } else {
379
538
  this._game.changePlayerData(gameId, {
@@ -394,6 +553,8 @@ class RoundManager {
394
553
  user.isWatching = true;
395
554
  user.watchedGameId = this._participants.getActiveList()[0] || null;
396
555
  user.forceCameraReset = true;
556
+ // слот освобождается вместе с актором, иначе он держится за наблюдателем
557
+ user.respawnIndex = null;
397
558
 
398
559
  this._participants.removeActive(gameId);
399
560
  this._removedPlayersList.push({ gameId, model });
@@ -401,8 +562,9 @@ class RoundManager {
401
562
  this._socketManager.sendSpectatorDefaultShot(user.socketId);
402
563
  }
403
564
 
404
- // перевод игрока в активные игроки
405
- _setActivePlayer(user, respawnData) {
565
+ // перевод игрока в активные игроки. `respawnIndex` — слот, который участник
566
+ // с этого момента занимает (см. `_freeRespawnIndex`)
567
+ _setActivePlayer(user, respawnData, respawnIndex = null) {
406
568
  const gameId = user.gameId;
407
569
  const teamId = user.teamId;
408
570
  const name = user.name;
@@ -412,6 +574,7 @@ class RoundManager {
412
574
  user.isWatching = false;
413
575
  user.watchedGameId = null;
414
576
  user.forceCameraReset = true;
577
+ user.respawnIndex = respawnIndex;
415
578
 
416
579
  // если это реальный игрок (есть сокет)
417
580
  if (user.isNetworked) {
@@ -489,6 +652,12 @@ class RoundManager {
489
652
 
490
653
  // проверяет уничтожение всей команды
491
654
  _checkTeamWipe(victimTeamId, killerTeamId) {
655
+ // под endlessRound раунд не заканчивается вообще: вычищенная команда —
656
+ // не повод для результата и отложенного перезапуска
657
+ if (this._endlessRound) {
658
+ return;
659
+ }
660
+
492
661
  // если раунд уже в процессе завершения
493
662
  if (this._isRoundEnding) {
494
663
  return;
@@ -105,12 +105,20 @@ export default class PlayerDataSync {
105
105
  return this._entries.get(participantId)?.rank ?? 0;
106
106
  }
107
107
 
108
+ // приехал ли rank с мастера. getRank отвечает 0 и незнакомому id, и
109
+ // знакомому, чей load() ещё не вернулся — игре, которая пишет ранг в stat
110
+ // колонкой '=', нужно отличать «ранг 0» от «ранга ещё нет»
111
+ isRankLoaded(participantId) {
112
+ return this._entries.get(participantId)?.rankLoaded === true;
113
+ }
114
+
108
115
  getState(participantId) {
109
116
  return this._entries.get(participantId)?.state ?? this._defaultState;
110
117
  }
111
118
 
112
- // прибавляет к рангу игрока (вызывается из RoundManager.reportKill
113
- // тот же чокпоинт, что и Stat score)
119
+ // прибавляет к рангу игрока. Два вызывающих: RoundManager.reportKill (тот
120
+ // же чокпоинт, что и Stat score) и HostGame.addPlayerRank — прямой путь для
121
+ // игр, которые не эмитят CoreEvent::Death и до reportKill не доходят
114
122
  addRank(participantId, delta) {
115
123
  const entry = this._entries.get(participantId);
116
124
 
@@ -7,6 +7,11 @@ class Participant {
7
7
  this.team = team;
8
8
  this.teamId = teamId;
9
9
  this.status = 'spectator'; // 'spectator' | 'active' | 'dead'
10
+ // индекс точки респауна, занятой этим участником, или null. Раздаёт и
11
+ // освобождает его RoundManager: размер команды для этого не годится —
12
+ // он меняется на входах и выходах, а между попаданием в команду и
13
+ // выдачей актора может пройти вся загрузка карты клиентом
14
+ this.respawnIndex = null;
10
15
  }
11
16
 
12
17
  // участник, управляемый кодом (ИИ), а не сетевым игроком
@@ -9,8 +9,15 @@ class ParticipantManager {
9
9
  // { namePrefix, defaultModel }
10
10
  constructor(teams, spectatorTeam, maxPlayers, scripted = {}) {
11
11
  this._teams = teams; // { team1: 1, team2: 2, spectators: 3 }
12
- this._spectatorTeam = spectatorTeam;
13
- this._spectatorId = teams[spectatorTeam];
12
+
13
+ // noSpectators (opt-in движка): наблюдателей нет как концепции, и
14
+ // spectatorTeam приходит null. Команда входа тогда — единственная
15
+ // играющая, а не резервная: человек заходит сразу в игру
16
+ this._spectatorTeam = spectatorTeam ?? null;
17
+ this._spectatorId =
18
+ this._spectatorTeam === null ? null : teams[spectatorTeam];
19
+ this._joinTeam = this._spectatorTeam ?? Object.keys(teams)[0];
20
+ this._joinTeamId = teams[this._joinTeam];
14
21
  this._maxPlayers = maxPlayers;
15
22
  this._scripted = scripted;
16
23
 
@@ -32,7 +39,18 @@ class ParticipantManager {
32
39
  return counter.toString(10);
33
40
  }
34
41
 
35
- // создаёт участника-человека (спектатор), возвращает gameId
42
+ // команда, в которую попадает подключившийся человек: наблюдатели, а под
43
+ // noSpectators — единственная играющая команда
44
+ get joinTeam() {
45
+ return this._joinTeam;
46
+ }
47
+
48
+ get joinTeamId() {
49
+ return this._joinTeamId;
50
+ }
51
+
52
+ // создаёт участника-человека (наблюдателя, а под noSpectators — игрока),
53
+ // возвращает gameId
36
54
  createHuman(params, socketId) {
37
55
  const gameId = this._nextGameId();
38
56
  const name = this.checkName(params.name);
@@ -41,15 +59,15 @@ class ParticipantManager {
41
59
  gameId,
42
60
  name,
43
61
  model: params.model,
44
- team: this._spectatorTeam,
45
- teamId: this._spectatorId,
62
+ team: this._joinTeam,
63
+ teamId: this._joinTeamId,
46
64
  socketId,
47
65
  watchedGameId: this._activePlayersList[0] || null,
48
66
  token: params.token,
49
67
  });
50
68
 
51
69
  this._participants.set(gameId, participant);
52
- this._teamSizes[this._spectatorTeam].add(gameId);
70
+ this._teamSizes[this._joinTeam].add(gameId);
53
71
 
54
72
  return gameId;
55
73
  }
@@ -28,8 +28,13 @@ export const buildClientConfig = (game, defaults, gameClient) => {
28
28
  structuredClone(gameClient),
29
29
  );
30
30
 
31
- // время ожидания vote-модуля
32
- config.modules.vote.params.time = game.timers.voteTime;
31
+ // время ожидания vote-модуля. Игра без голосований (noSpectators) не
32
+ // объявляет modules.vote.params вовсе — движковые дефолты дают только
33
+ // elems, поэтому params здесь и заводится
34
+ config.modules.vote.params = {
35
+ ...config.modules.vote.params,
36
+ time: game.timers.voteTime,
37
+ };
33
38
 
34
39
  // данные для client-side prediction (реплика движения своего танка
35
40
  // и визуального спавна его снарядов)
@@ -54,9 +54,12 @@ const REQUIRED_GAME_CONFIG_PATHS = [
54
54
  // без них HostGame разыменовывает undefined (this._teams[spectatorTeam])
55
55
  // и игра умирает тремя разными сообщениями вместо одного контрактного
56
56
  'teams',
57
- 'spectatorTeam',
58
57
  ];
59
58
 
59
+ // spectatorTeam обязателен ровно до тех пор, пока игра не объявила
60
+ // noSpectators: там наблюдателей нет как концепции и ключа тоже нет
61
+ const SPECTATOR_CONFIG_PATH = 'spectatorTeam';
62
+
60
63
  function getPath(obj, dottedPath) {
61
64
  return dottedPath
62
65
  .split('.')
@@ -68,7 +71,12 @@ export function assertGameConfigShape(hostPlugin) {
68
71
  // null проходил бы проверку присутствия, хотя ни одно из этих полей не
69
72
  // бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
70
73
  // заведённый ради текста вместо TypeError, сам отвечал бы TypeError
71
- const missing = REQUIRED_GAME_CONFIG_PATHS.filter(p => {
74
+ const noSpectators = hostPlugin.gameConfig?.noSpectators === true;
75
+ const required = noSpectators
76
+ ? REQUIRED_GAME_CONFIG_PATHS
77
+ : [...REQUIRED_GAME_CONFIG_PATHS, SPECTATOR_CONFIG_PATH];
78
+
79
+ const missing = required.filter(p => {
72
80
  const value = getPath(hostPlugin.gameConfig, p);
73
81
 
74
82
  return value === undefined || value === null;
@@ -87,6 +95,20 @@ export function assertGameConfigShape(hostPlugin) {
87
95
  // (ParticipantManager.createHuman валится на её счётчике)
88
96
  const { teams, spectatorTeam } = hostPlugin.gameConfig;
89
97
 
98
+ // noSpectators: связывать нечего — зато команда обязана быть ровно одна.
99
+ // Вторая играющая команда без наблюдателей означала бы вход «куда-нибудь»,
100
+ // а ParticipantManager выбирает команду входа однозначно
101
+ if (noSpectators) {
102
+ if (Object.keys(teams).length !== 1) {
103
+ throw new Error(
104
+ `game "${hostPlugin.id}": noSpectators requires exactly one team, ` +
105
+ `got ${Object.keys(teams).length} (${Object.keys(teams).join(', ')})`,
106
+ );
107
+ }
108
+
109
+ return;
110
+ }
111
+
90
112
  if (teams[spectatorTeam] === undefined) {
91
113
  throw new Error(
92
114
  `game "${hostPlugin.id}": spectatorTeam '${spectatorTeam}' is not a ` +
@@ -30,9 +30,12 @@ export function resolveProjectUrl(pkg) {
30
30
 
31
31
  // repository бывает и строкой ("github:user/repo"), и объектом {type, url}
32
32
  const declared =
33
- (typeof repository === 'string' ? repository : repository?.url) ?? homepage;
33
+ typeof repository === 'string' ? repository : repository?.url;
34
34
 
35
- return normalize(declared);
35
+ // объявленный, но не приводящийся к http(s) repository (пустая строка,
36
+ // внутренний git-хост) не повод терять валидный homepage: поле проверяется
37
+ // по результату, а не по факту наличия
38
+ return normalizeRepository(declared) ?? normalize(homepage);
36
39
  }
37
40
 
38
41
  /**
@@ -41,11 +44,26 @@ export function resolveProjectUrl(pkg) {
41
44
  * @returns {{url: string, label: string}|null} null, если адреса нет
42
45
  */
43
46
  export function projectLink(url) {
44
- if (typeof url !== 'string' || !url.trim()) {
47
+ // адрес перепроверяется, хотя свои вызовы приходят уже из
48
+ // resolveProjectUrl: функция — публичный экспорт ("./lib/*"), а результат
49
+ // уходит прямо в href якоря (client/lib/footerLink.js)
50
+ const value = typeof url === 'string' ? url.trim() : '';
51
+
52
+ if (!/^https?:\/\//.test(value)) {
45
53
  return null;
46
54
  }
47
55
 
48
- return { url, label: labelFor(url) };
56
+ return { url: value, label: labelFor(value) };
57
+ }
58
+
59
+ // шорткат "user/repo" — семантика поля repository (так его читает npm), в
60
+ // homepage такая строка означала бы относительный путь, а не адрес на GitHub
61
+ function normalizeRepository(raw) {
62
+ const value = typeof raw === 'string' ? raw.trim() : '';
63
+
64
+ return /^[\w.-]+\/[\w.-]+$/.test(value)
65
+ ? `https://github.com/${value}`
66
+ : normalize(raw);
49
67
  }
50
68
 
51
69
  // приводит объявленный адрес к https-виду; всё, что после нормализации не
@@ -61,14 +79,7 @@ function normalize(raw) {
61
79
  return null;
62
80
  }
63
81
 
64
- // npm-шорткаты: "github:user/repo", "gitlab:...", "bitbucket:...", а также
65
- // голое "user/repo" — его принимает флаг --repository скаффолдера
66
- const bare = /^([\w.-]+\/[\w.-]+)$/.exec(url);
67
-
68
- if (bare) {
69
- return `https://github.com/${bare[1]}`;
70
- }
71
-
82
+ // npm-шорткаты: "github:user/repo", "gitlab:...", "bitbucket:..."
72
83
  const shorthand = /^(github|gitlab|bitbucket):([\w.-]+\/[\w.-]+)$/.exec(url);
73
84
 
74
85
  if (shorthand) {
@@ -101,6 +112,9 @@ function normalize(raw) {
101
112
 
102
113
  return (
103
114
  url
115
+ // логин (и тем более пароль) из ssh-формы адреса — не часть проекта, а
116
+ // в href он уехал бы в DOM и историю браузера
117
+ .replace(/^(https?:\/\/)[^/@]*@/, '$1')
104
118
  .replace(/\.git(?=$|[#?])/, '')
105
119
  // npm сам дописывает "#readme" в homepage при генерации package.json
106
120
  .replace(/#readme$/, '')
@@ -111,11 +125,13 @@ function normalize(raw) {
111
125
  // подпись ячейки: у наших пакетов это всегда GitHub, у чужого хостинга —
112
126
  // его хост, чтобы подпись не врала об источнике
113
127
  function labelFor(url) {
114
- if (/^https?:\/\/(www\.)?github\.com\//.test(url)) {
115
- return 'GitHub';
116
- }
128
+ let host;
117
129
 
118
- const host = /^https?:\/\/(?:www\.)?([^/:]+)/.exec(url);
130
+ try {
131
+ host = new URL(url).hostname.replace(/^www\./, '');
132
+ } catch {
133
+ return 'Website';
134
+ }
119
135
 
120
- return host ? host[1] : 'Website';
136
+ return host === 'github.com' ? 'GitHub' : host;
121
137
  }