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 +1 -1
- package/src/client/components/model/Vote.js +4 -2
- package/src/client/style.css +33 -7
- package/src/client/views/gameShell.js +18 -1
- package/src/devtools/contract/rules/b3-game-config-shape.js +5 -4
- package/src/devtools/contract/rules/b4-teams.js +14 -0
- package/src/host/HostGame.js +44 -4
- package/src/host/meta/core/RoundManager.js +184 -15
- package/src/host/meta/modules/PlayerDataSync.js +10 -2
- package/src/host/meta/player/Participant.js +5 -0
- package/src/host/meta/player/ParticipantManager.js +24 -6
- package/src/lib/buildClientConfig.js +7 -2
- package/src/lib/gamePlugin.js +24 -2
- package/src/lib/packageLink.js +33 -17
package/package.json
CHANGED
|
@@ -14,8 +14,10 @@ export default class VoteModel {
|
|
|
14
14
|
|
|
15
15
|
this._formatMessage = data.formatMessage;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
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; // ожидания значений
|
package/src/client/style.css
CHANGED
|
@@ -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):
|
|
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
|
|
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 ` +
|
package/src/host/HostGame.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// прибавляет к рангу
|
|
113
|
-
//
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
//
|
|
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.
|
|
45
|
-
teamId: this.
|
|
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.
|
|
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
|
-
|
|
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
|
// и визуального спавна его снарядов)
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -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
|
|
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 ` +
|
package/src/lib/packageLink.js
CHANGED
|
@@ -30,9 +30,12 @@ export function resolveProjectUrl(pkg) {
|
|
|
30
30
|
|
|
31
31
|
// repository бывает и строкой ("github:user/repo"), и объектом {type, url}
|
|
32
32
|
const declared =
|
|
33
|
-
|
|
33
|
+
typeof repository === 'string' ? repository : repository?.url;
|
|
34
34
|
|
|
35
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
115
|
-
return 'GitHub';
|
|
116
|
-
}
|
|
128
|
+
let host;
|
|
117
129
|
|
|
118
|
-
|
|
130
|
+
try {
|
|
131
|
+
host = new URL(url).hostname.replace(/^www\./, '');
|
|
132
|
+
} catch {
|
|
133
|
+
return 'Website';
|
|
134
|
+
}
|
|
119
135
|
|
|
120
|
-
return host ?
|
|
136
|
+
return host === 'github.com' ? 'GitHub' : host;
|
|
121
137
|
}
|