vimp-engine 0.18.1 → 0.20.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/controller/Lobby.js +42 -1
- package/src/client/components/model/Vote.js +4 -2
- package/src/client/components/view/Lobby.js +32 -1
- package/src/client/main.js +19 -7
- package/src/client/style.css +58 -0
- package/src/client/views/gameShell.js +18 -1
- package/src/client/views/includes/lobby.pug +6 -0
- package/src/config/lobby.js +17 -0
- 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 +57 -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/package.json
CHANGED
|
@@ -27,6 +27,7 @@ export default class LobbyCtrl {
|
|
|
27
27
|
this._model = model;
|
|
28
28
|
this._view = view;
|
|
29
29
|
this._clock = clock;
|
|
30
|
+
this._periods = [];
|
|
30
31
|
|
|
31
32
|
this.publisher = new Publisher();
|
|
32
33
|
|
|
@@ -37,6 +38,12 @@ export default class LobbyCtrl {
|
|
|
37
38
|
vp.on('visible', 'pingHost', this);
|
|
38
39
|
vp.on('join', 'join', this);
|
|
39
40
|
vp.on('show-tab', 'showTab', this);
|
|
41
|
+
vp.on('show-period', 'showPeriod', this);
|
|
42
|
+
|
|
43
|
+
// активная игра и открытый срез рейтинга (rank-periods): запрос за
|
|
44
|
+
// лидербордом всегда несёт обе координаты, а меняется каждый раз одна
|
|
45
|
+
this._gameId = null;
|
|
46
|
+
this._period = null;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
// показывает лобби и запрашивает первую страницу
|
|
@@ -69,13 +76,47 @@ export default class LobbyCtrl {
|
|
|
69
76
|
this._view.showTab(tab);
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
// срез рейтинга: подсветить кнопку и перезапросить список. Данных за день
|
|
80
|
+
// и за месяц у клиента нет — это другой ответ сервера, а не другая
|
|
81
|
+
// сортировка того же (rank-periods)
|
|
82
|
+
showPeriod(period) {
|
|
83
|
+
if (period === this._period) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._period = period;
|
|
88
|
+
this._view.setPeriod(period, this._titleOf(period));
|
|
89
|
+
|
|
90
|
+
if (this._gameId !== null) {
|
|
91
|
+
this.publisher.emit('leaderboard-needed', {
|
|
92
|
+
gameId: this._gameId,
|
|
93
|
+
period,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// подпись среза для заголовка списка; неизвестный id остаётся без подписи
|
|
99
|
+
_titleOf(period) {
|
|
100
|
+
return this._periods.find(item => item.id === period)?.title ?? '';
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
// вызывается main.js при смене #lobby-game (селектор игры вне этого MVC),
|
|
73
104
|
// а также один раз при открытии лобби для активной по умолчанию игры —
|
|
74
105
|
// Leaderboard всегда готов заранее, до того как пользователь откроет
|
|
75
106
|
// вкладку (см. класс-комментарий выше)
|
|
76
107
|
gameChanged(gameId, title) {
|
|
108
|
+
this._gameId = gameId;
|
|
77
109
|
this._view.setGameTitle(title);
|
|
78
110
|
|
|
79
|
-
this.publisher.emit('leaderboard-needed', gameId);
|
|
111
|
+
this.publisher.emit('leaderboard-needed', { gameId, period: this._period });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// срезы рейтинга и открытый по умолчанию (main.js передаёт их из
|
|
115
|
+
// lobbyConfig): контроллер не знает их наизусть, чтобы список срезов
|
|
116
|
+
// правился в конфиге, а не здесь
|
|
117
|
+
setPeriods(periods, defaultPeriod) {
|
|
118
|
+
this._periods = periods || [];
|
|
119
|
+
this._period = defaultPeriod;
|
|
120
|
+
this._view.setPeriod(defaultPeriod, this._titleOf(defaultPeriod));
|
|
80
121
|
}
|
|
81
122
|
}
|
|
@@ -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; // ожидания значений
|
|
@@ -36,6 +36,15 @@ export default class LobbyView {
|
|
|
36
36
|
this._leaderboardTotal = document.getElementById(elems.leaderboardTotalId);
|
|
37
37
|
this._myPlacement = document.getElementById(elems.myPlacementId);
|
|
38
38
|
|
|
39
|
+
// кнопки срезов рейтинга (rank-periods): id периода -> элемент. Кнопки
|
|
40
|
+
// объявлены конфигом, а не захардкожены здесь, чтобы срез можно было
|
|
41
|
+
// убрать или добавить в одном месте
|
|
42
|
+
this._periodBtns = new Map(
|
|
43
|
+
Object.entries(elems.periodBtnIds || {})
|
|
44
|
+
.map(([period, id]) => [period, document.getElementById(id)])
|
|
45
|
+
.filter(([, el]) => el),
|
|
46
|
+
);
|
|
47
|
+
|
|
39
48
|
// футер лобби: содержимое статично на всё время жизни вкладки, поэтому
|
|
40
49
|
// пишется здесь, а не через модель и publisher
|
|
41
50
|
const version = document.getElementById(elems.versionId);
|
|
@@ -56,6 +65,10 @@ export default class LobbyView {
|
|
|
56
65
|
// игроков, чем лимит)
|
|
57
66
|
this._leaderboardLimit = 0;
|
|
58
67
|
|
|
68
|
+
// заголовок открытого среза ("<TITLE> TOP-N — TODAY"): подпись, а не
|
|
69
|
+
// источник правды, срез хранит модель
|
|
70
|
+
this._periodTitle = '';
|
|
71
|
+
|
|
59
72
|
// ник вызывающего (lobby-page-plan, code review M4-остаток) — задаётся
|
|
60
73
|
// main.js один раз при открытии лобби; используется, чтобы решить,
|
|
61
74
|
// виден ли вызывающий уже в отрисованном топе (по нику, не по числу
|
|
@@ -86,6 +99,10 @@ export default class LobbyView {
|
|
|
86
99
|
this._tabServersBtn.onclick = () => this.publisher.emit('show-tab', 'servers');
|
|
87
100
|
this._tabLeaderboardBtn.onclick = () => this.publisher.emit('show-tab', 'leaderboard');
|
|
88
101
|
|
|
102
|
+
for (const [period, btn] of this._periodBtns) {
|
|
103
|
+
btn.onclick = () => this.publisher.emit('show-period', period);
|
|
104
|
+
}
|
|
105
|
+
|
|
89
106
|
this._mPublic = model.publisher;
|
|
90
107
|
this._mPublic.on('list', 'renderList', this);
|
|
91
108
|
this._mPublic.on('ping-update', 'updatePing', this);
|
|
@@ -118,6 +135,17 @@ export default class LobbyView {
|
|
|
118
135
|
this._selfNick = nick || '';
|
|
119
136
|
}
|
|
120
137
|
|
|
138
|
+
// подсвечивает открытый срез рейтинга и запоминает его подпись
|
|
139
|
+
// (rank-periods). Рисовать список здесь нечем: данные среза приезжают
|
|
140
|
+
// отдельным запросом, и до ответа список чистится моделью
|
|
141
|
+
setPeriod(period, title) {
|
|
142
|
+
this._periodTitle = title || '';
|
|
143
|
+
|
|
144
|
+
for (const [id, btn] of this._periodBtns) {
|
|
145
|
+
btn.classList.toggle('active', id === period);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
// переключает вкладки Active Servers / Leaderboard (lobby-page-plan)
|
|
122
150
|
showTab(tab) {
|
|
123
151
|
const showLeaderboard = tab === 'leaderboard';
|
|
@@ -130,7 +158,10 @@ export default class LobbyView {
|
|
|
130
158
|
|
|
131
159
|
// рендер топ-N + позиции вызывающего (lobby-page-plan)
|
|
132
160
|
renderLeaderboard({ leaderboard, total, myPlacement, loaded = true }) {
|
|
133
|
-
|
|
161
|
+
const period = this._periodTitle ? ` — ${this._periodTitle}` : '';
|
|
162
|
+
|
|
163
|
+
this._leaderboardTitle.textContent =
|
|
164
|
+
`${this._gameTitle.toUpperCase()} TOP-${this._leaderboardLimit}${period}`;
|
|
134
165
|
this._leaderboardTotal.textContent = `Total: ${total} players`;
|
|
135
166
|
|
|
136
167
|
this._leaderboardList.textContent = '';
|
package/src/client/main.js
CHANGED
|
@@ -1790,10 +1790,13 @@ async function fetchServers({ offset, limit, search }) {
|
|
|
1790
1790
|
|
|
1791
1791
|
// топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
|
|
1792
1792
|
// логина, поэтому без Authorization
|
|
1793
|
-
async function fetchLeaderboard(gameId) {
|
|
1793
|
+
async function fetchLeaderboard(gameId, period) {
|
|
1794
1794
|
const params = new URLSearchParams({
|
|
1795
1795
|
game: gameId,
|
|
1796
1796
|
limit: lobbyConfig.leaderboardLimit,
|
|
1797
|
+
// rank-periods: срез времени. Мастер отвечает 400 на незнакомый —
|
|
1798
|
+
// значение всегда из lobbyConfig.leaderboardPeriods
|
|
1799
|
+
period,
|
|
1797
1800
|
});
|
|
1798
1801
|
|
|
1799
1802
|
try {
|
|
@@ -1807,7 +1810,7 @@ async function fetchLeaderboard(gameId) {
|
|
|
1807
1810
|
|
|
1808
1811
|
// позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
|
|
1809
1812
|
// токена, как и остальные /auth/* запросы игрока (rank/state)
|
|
1810
|
-
async function fetchPlacement(gameId) {
|
|
1813
|
+
async function fetchPlacement(gameId, period) {
|
|
1811
1814
|
const token = lobbyAuthModel.getToken();
|
|
1812
1815
|
|
|
1813
1816
|
if (!token) {
|
|
@@ -1816,7 +1819,9 @@ async function fetchPlacement(gameId) {
|
|
|
1816
1819
|
|
|
1817
1820
|
try {
|
|
1818
1821
|
const res = await fetch(
|
|
1819
|
-
|
|
1822
|
+
// тот же срез, что и у списка рядом: плашка позиции, посчитанная за
|
|
1823
|
+
// всё время под заголовком «сегодня», противоречила бы списку
|
|
1824
|
+
`${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId, period })}`,
|
|
1820
1825
|
{ headers: { authorization: `Bearer ${token}` } },
|
|
1821
1826
|
);
|
|
1822
1827
|
|
|
@@ -1954,6 +1959,13 @@ function initLobby() {
|
|
|
1954
1959
|
|
|
1955
1960
|
lobby = new LobbyCtrl(lobbyModel, lobbyView);
|
|
1956
1961
|
|
|
1962
|
+
// срезы рейтинга (rank-periods) — сразу после создания контроллера и до
|
|
1963
|
+
// первого gameChanged: тот уже несёт открытый срез в запросе
|
|
1964
|
+
lobby.setPeriods(
|
|
1965
|
+
lobbyConfig.leaderboardPeriods,
|
|
1966
|
+
lobbyConfig.defaultLeaderboardPeriod,
|
|
1967
|
+
);
|
|
1968
|
+
|
|
1957
1969
|
// список серверов — REST-запросом к мастеру
|
|
1958
1970
|
lobbyModel.publisher.on('fetch', async query => {
|
|
1959
1971
|
const list = await fetchServers(query);
|
|
@@ -1989,17 +2001,17 @@ function initLobby() {
|
|
|
1989
2001
|
// под заголовком новой, пока не пришёл ответ (и остаются навсегда при
|
|
1990
2002
|
// сетевом сбое); reqId отбрасывает ответ устаревшего запроса, если игру
|
|
1991
2003
|
// переключили быстрее, чем пришёл ответ (latest-wins)
|
|
1992
|
-
lobby.publisher.on('leaderboard-needed', async gameId => {
|
|
2004
|
+
lobby.publisher.on('leaderboard-needed', async ({ gameId, period }) => {
|
|
1993
2005
|
lobbyModel.clearLeaderboard();
|
|
1994
2006
|
|
|
1995
2007
|
const reqId = ++leaderboardReqId;
|
|
1996
2008
|
const [leaderboard, placement] = await Promise.all([
|
|
1997
|
-
fetchLeaderboard(gameId),
|
|
1998
|
-
fetchPlacement(gameId),
|
|
2009
|
+
fetchLeaderboard(gameId, period),
|
|
2010
|
+
fetchPlacement(gameId, period),
|
|
1999
2011
|
]);
|
|
2000
2012
|
|
|
2001
2013
|
if (reqId !== leaderboardReqId) {
|
|
2002
|
-
return; // игру уже переключили ещё раз —
|
|
2014
|
+
return; // игру или срез уже переключили ещё раз — ответ устарел
|
|
2003
2015
|
}
|
|
2004
2016
|
|
|
2005
2017
|
if (leaderboard) {
|
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
|
}
|
|
@@ -876,6 +908,32 @@ body.hide-cursor {
|
|
|
876
908
|
border-bottom: 2px solid #fff;
|
|
877
909
|
}
|
|
878
910
|
|
|
911
|
+
/* срезы рейтинга (rank-periods): день/месяц/всё время. Ряд под вкладками
|
|
912
|
+
правой панели — это выбор ДАННЫХ, а не вкладка, поэтому кнопки выглядят
|
|
913
|
+
как переключатель, а не как заголовок раздела */
|
|
914
|
+
.lobby-period-tabs {
|
|
915
|
+
display: flex;
|
|
916
|
+
gap: 4px;
|
|
917
|
+
margin-bottom: 10px;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.lobby-period-btn {
|
|
921
|
+
flex: 1 1 0;
|
|
922
|
+
background: rgb(255 255 255 / 6%);
|
|
923
|
+
border: none;
|
|
924
|
+
color: #888;
|
|
925
|
+
font-size: 12px;
|
|
926
|
+
font-weight: bold;
|
|
927
|
+
padding: 5px 4px;
|
|
928
|
+
margin: 0;
|
|
929
|
+
text-shadow: none;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
.lobby-period-btn.active {
|
|
933
|
+
background: rgb(255 255 255 / 18%);
|
|
934
|
+
color: #fff;
|
|
935
|
+
}
|
|
936
|
+
|
|
879
937
|
.lobby-leaderboard-header {
|
|
880
938
|
display: flex;
|
|
881
939
|
justify-content: space-between;
|
|
@@ -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
|
|
|
@@ -50,6 +50,12 @@ div#lobby(style='display:none')
|
|
|
50
50
|
p#lobby-empty= 'No servers available'
|
|
51
51
|
input#lobby-more(type='button', value='Load more')
|
|
52
52
|
div#lobby-leaderboard-content(style='display:none')
|
|
53
|
+
//- срез рейтинга (rank-periods): день/месяц считаются из леджера
|
|
54
|
+
//- дельт, all-time — из кэша ratings
|
|
55
|
+
div.lobby-period-tabs
|
|
56
|
+
input#btn-period-day.lobby-period-btn(type='button', value='Daily')
|
|
57
|
+
input#btn-period-month.lobby-period-btn(type='button', value='Monthly')
|
|
58
|
+
input#btn-period-all.lobby-period-btn.active(type='button', value='All-Time')
|
|
53
59
|
div.lobby-leaderboard-header
|
|
54
60
|
span#leaderboard-title
|
|
55
61
|
span#leaderboard-total
|
package/src/config/lobby.js
CHANGED
|
@@ -50,6 +50,17 @@ export default {
|
|
|
50
50
|
placementUrl: '/auth/placement',
|
|
51
51
|
leaderboardLimit: 10,
|
|
52
52
|
|
|
53
|
+
// rank-periods: срезы рейтинга и тот, что открыт по умолчанию. Порядок
|
|
54
|
+
// здесь — порядок кнопок; `id` едет в auth как ?period=, `title` идёт в
|
|
55
|
+
// заголовок списка. Значения должны совпадать с RANK_PERIODS auth-сервиса:
|
|
56
|
+
// на всё прочее он отвечает 400
|
|
57
|
+
leaderboardPeriods: [
|
|
58
|
+
{ id: 'day', title: 'TODAY' },
|
|
59
|
+
{ id: 'month', title: 'THIS MONTH' },
|
|
60
|
+
{ id: 'all', title: 'ALL-TIME' },
|
|
61
|
+
],
|
|
62
|
+
defaultLeaderboardPeriod: 'all',
|
|
63
|
+
|
|
53
64
|
// переподключение сигнального WS хоста (комната без него выпадает из
|
|
54
65
|
// выдачи мастера): экспоненциальный бэкофф от baseDelay до maxDelay (мс)
|
|
55
66
|
reconnect: {
|
|
@@ -93,6 +104,12 @@ export default {
|
|
|
93
104
|
serversContentId: 'lobby-servers-content',
|
|
94
105
|
leaderboardContentId: 'lobby-leaderboard-content',
|
|
95
106
|
leaderboardListId: 'lobby-leaderboard-list',
|
|
107
|
+
// кнопки срезов (rank-periods): id периода -> id элемента
|
|
108
|
+
periodBtnIds: {
|
|
109
|
+
day: 'btn-period-day',
|
|
110
|
+
month: 'btn-period-month',
|
|
111
|
+
all: 'btn-period-all',
|
|
112
|
+
},
|
|
96
113
|
leaderboardTitleId: 'leaderboard-title',
|
|
97
114
|
leaderboardTotalId: 'leaderboard-total',
|
|
98
115
|
myPlacementId: 'lobby-my-placement',
|
|
@@ -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,33 @@ 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
|
+
|
|
1066
|
+
// синхронизация профилей (rank/state) всех участников на мастер прямо
|
|
1067
|
+
// сейчас — для игр, у которых нет ни конца раунда, ни смены карты
|
|
1068
|
+
// (`endlessRound` + карта, пересобираемая через overrideMapData): обе
|
|
1069
|
+
// штатные границы flushAll живут в RoundManager, и такая игра не проходит
|
|
1070
|
+
// ни через одну из них. Без этого накопленный за матч ранг уезжает в auth
|
|
1071
|
+
// только на выходе участника, а закрытая вкладка хоста теряет его вовсе.
|
|
1072
|
+
//
|
|
1073
|
+
// Best-effort, как и весь PlayerDataSync: промис не отвергается, сбой
|
|
1074
|
+
// логируется в `[playerData]` и повторится следующим flush'ем.
|
|
1075
|
+
flushPlayerData() {
|
|
1076
|
+
return this._playerDataSync.flushAll();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1026
1079
|
// hostId + per-room секрет комнаты, подтверждённые мастером при
|
|
1027
1080
|
// register_host (кодревью №1) — не известны при создании HostGame (Worker
|
|
1028
1081
|
// стартует раньше ответа мастера); нужны 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 ` +
|