vimp-engine 0.7.2 → 0.8.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 +11 -4
- package/src/client/InputListener.js +36 -0
- package/src/client/SoundManager.js +498 -0
- package/src/client/boot.js +113 -0
- package/src/client/components/controller/Auth.js +44 -0
- package/src/client/components/controller/CanvasManager.js +26 -0
- package/src/client/components/controller/Chat.js +56 -0
- package/src/client/components/controller/Controls.js +51 -0
- package/src/client/components/controller/Game.js +37 -0
- package/src/client/components/controller/Lobby.js +81 -0
- package/src/client/components/controller/LobbyAuth.js +41 -0
- package/src/client/components/controller/Panel.js +23 -0
- package/src/client/components/controller/Stat.js +33 -0
- package/src/client/components/controller/Vote.js +55 -0
- package/src/client/components/model/Auth.js +92 -0
- package/src/client/components/model/CanvasManager.js +319 -0
- package/src/client/components/model/Chat.js +119 -0
- package/src/client/components/model/Controls.js +120 -0
- package/src/client/components/model/Game.js +172 -0
- package/src/client/components/model/Lobby.js +226 -0
- package/src/client/components/model/LobbyAuth.js +161 -0
- package/src/client/components/model/Panel.js +70 -0
- package/src/client/components/model/Stat.js +77 -0
- package/src/client/components/model/Vote.js +216 -0
- package/src/client/components/view/Auth.js +167 -0
- package/src/client/components/view/CanvasManager.js +44 -0
- package/src/client/components/view/Chat.js +90 -0
- package/src/client/components/view/Controls.js +30 -0
- package/src/client/components/view/Game.js +48 -0
- package/src/client/components/view/Lobby.js +303 -0
- package/src/client/components/view/LobbyAuth.js +111 -0
- package/src/client/components/view/Panel.js +314 -0
- package/src/client/components/view/Stat.js +238 -0
- package/src/client/components/view/Vote.js +108 -0
- package/src/client/debug.js +158 -0
- package/src/client/lib/autostart.js +61 -0
- package/src/client/lib/contextTracker.js +46 -0
- package/src/client/lib/formBuilder.js +280 -0
- package/src/client/lib/hostGate.js +16 -0
- package/src/client/main.js +2011 -0
- package/src/client/network/HostConnectionManager.js +196 -0
- package/src/client/network/HostController.js +422 -0
- package/src/client/network/InlineHostBridge.js +152 -0
- package/src/client/network/LoopbackTransport.js +51 -0
- package/src/client/network/SignalingClient.js +146 -0
- package/src/client/network/WebRtcManager.js +147 -0
- package/src/client/network/WebSocketTransport.js +78 -0
- package/src/client/network/policyClose.js +54 -0
- package/src/client/network/workerSupport.js +34 -0
- package/src/client/providers/BakingProvider.js +88 -0
- package/src/client/providers/DependencyProvider.js +41 -0
- package/src/client/style.css +898 -0
- package/src/client/views/gameShell.js +190 -0
- package/src/client/views/includes/auth.pug +20 -0
- package/src/client/views/includes/chat.pug +5 -0
- package/src/client/views/includes/informer.pug +2 -0
- package/src/client/views/includes/lobby.pug +48 -0
- package/src/client/views/includes/lobbyAuth.pug +19 -0
- package/src/client/views/includes/panel.pug +3 -0
- package/src/client/views/includes/stat.pug +2 -0
- package/src/client/views/index.pug +8 -0
- package/src/config/closeCodes.js +18 -0
- package/src/config/env.js +61 -0
- package/src/devtools/ScenarioRunner.js +3 -8
- package/src/devtools/pluginLoader.js +8 -93
- package/src/host/HostGame.js +41 -3
- package/src/host/PortMachine.js +294 -0
- package/src/host/host.worker.js +29 -246
- package/src/host/identity.js +100 -0
- package/src/lib/clientIp.js +39 -0
- package/src/lib/loadGamePackage.js +122 -0
- package/src/lib/offlinePlayerData.js +18 -0
- package/src/standalone/index.js +125 -0
|
@@ -0,0 +1,2011 @@
|
|
|
1
|
+
import './style.css';
|
|
2
|
+
import 'pixi.js/unsafe-eval';
|
|
3
|
+
import { Application, Ticker } from 'pixi.js';
|
|
4
|
+
import InputListener from './InputListener.js';
|
|
5
|
+
import AuthModel from './components/model/Auth.js';
|
|
6
|
+
import AuthView from './components/view/Auth.js';
|
|
7
|
+
import AuthCtrl from './components/controller/Auth.js';
|
|
8
|
+
import CanvasManagerModel from './components/model/CanvasManager.js';
|
|
9
|
+
import CanvasManagerView from './components/view/CanvasManager.js';
|
|
10
|
+
import CanvasManagerCtrl from './components/controller/CanvasManager.js';
|
|
11
|
+
import ControlsModel from './components/model/Controls.js';
|
|
12
|
+
import ControlsView from './components/view/Controls.js';
|
|
13
|
+
import ControlsCtrl from './components/controller/Controls.js';
|
|
14
|
+
import GameModel from './components/model/Game.js';
|
|
15
|
+
import GameView from './components/view/Game.js';
|
|
16
|
+
import GameCtrl from './components/controller/Game.js';
|
|
17
|
+
import ChatModel from './components/model/Chat.js';
|
|
18
|
+
import ChatView from './components/view/Chat.js';
|
|
19
|
+
import ChatCtrl from './components/controller/Chat.js';
|
|
20
|
+
import PanelModel from './components/model/Panel.js';
|
|
21
|
+
import PanelView from './components/view/Panel.js';
|
|
22
|
+
import PanelCtrl from './components/controller/Panel.js';
|
|
23
|
+
import StatModel from './components/model/Stat.js';
|
|
24
|
+
import StatView from './components/view/Stat.js';
|
|
25
|
+
import StatCtrl from './components/controller/Stat.js';
|
|
26
|
+
import VoteModel from './components/model/Vote.js';
|
|
27
|
+
import VoteView from './components/view/Vote.js';
|
|
28
|
+
import VoteCtrl from './components/controller/Vote.js';
|
|
29
|
+
import {
|
|
30
|
+
buildForm,
|
|
31
|
+
mergeRoomDefaults,
|
|
32
|
+
reportFormValidity,
|
|
33
|
+
} from './lib/formBuilder.js';
|
|
34
|
+
import { getHostGateState } from './lib/hostGate.js';
|
|
35
|
+
import createAutostart from './lib/autostart.js';
|
|
36
|
+
import { getBootConfig, resolveBootConfig } from './boot.js';
|
|
37
|
+
import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
|
|
38
|
+
import { createContextTracker } from './lib/contextTracker.js';
|
|
39
|
+
import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
|
|
40
|
+
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
41
|
+
import {
|
|
42
|
+
buildSnapshotKeysById,
|
|
43
|
+
reconstructHot,
|
|
44
|
+
} from '../lib/reconstructHot.js';
|
|
45
|
+
import Factory from '../lib/factory.js';
|
|
46
|
+
import { formatMessage } from '../lib/formatters.js';
|
|
47
|
+
import { sanitizeMessage } from '../lib/sanitizers.js';
|
|
48
|
+
import { validateAuth } from '../lib/validators.js';
|
|
49
|
+
import SoundManager from './SoundManager.js';
|
|
50
|
+
import SignalingClient from './network/SignalingClient.js';
|
|
51
|
+
import WebRtcManager from './network/WebRtcManager.js';
|
|
52
|
+
import HostController from './network/HostController.js';
|
|
53
|
+
import HostConnectionManager from './network/HostConnectionManager.js';
|
|
54
|
+
import LoopbackTransport from './network/LoopbackTransport.js';
|
|
55
|
+
import WebSocketTransport from './network/WebSocketTransport.js';
|
|
56
|
+
import InlineHostBridge from './network/InlineHostBridge.js';
|
|
57
|
+
import { supportsModuleWorker } from './network/workerSupport.js';
|
|
58
|
+
import {
|
|
59
|
+
POLICY_CLOSE_INFORMS,
|
|
60
|
+
shouldReloadAfterClose,
|
|
61
|
+
} from './network/policyClose.js';
|
|
62
|
+
import LobbyModel from './components/model/Lobby.js';
|
|
63
|
+
import LobbyView from './components/view/Lobby.js';
|
|
64
|
+
import LobbyCtrl from './components/controller/Lobby.js';
|
|
65
|
+
import LobbyAuthModel from './components/model/LobbyAuth.js';
|
|
66
|
+
import LobbyAuthView from './components/view/LobbyAuth.js';
|
|
67
|
+
import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
|
|
68
|
+
import BakingProvider from './providers/BakingProvider.js';
|
|
69
|
+
import DependencyProvider from './providers/DependencyProvider.js';
|
|
70
|
+
import { HOT_FLAGS } from '../config/opcodes.js';
|
|
71
|
+
import wsports from '../config/wsports.js';
|
|
72
|
+
import GAME_CODES from '../config/gameCodes.js';
|
|
73
|
+
import {
|
|
74
|
+
fetchGamesManifest,
|
|
75
|
+
fetchGameManifest as fetchGamePluginManifest,
|
|
76
|
+
loadClientPlugin,
|
|
77
|
+
} from '../lib/gamePlugin.js';
|
|
78
|
+
import lobbyConfig from '../config/lobby.js';
|
|
79
|
+
import authClientConfig from '../config/authClient.js';
|
|
80
|
+
import clientDefaults from '../config/clientDefaults.js';
|
|
81
|
+
|
|
82
|
+
// Динамическая загрузка игры по каталогу мастера (Этап 6.3): активная для
|
|
83
|
+
// host остаётся первой записью каталога (граница lobby-page-plan — динамика
|
|
84
|
+
// загрузки ClientPlugin выбранной игры откладывается до появления второй
|
|
85
|
+
// игры), но сам каталог манифестов (gamesManifest) теперь используется
|
|
86
|
+
// целиком — селектором игр и Leaderboard (populateGameSelect/gamesById в
|
|
87
|
+
// initLobby). ClientPlugin (parts, bakers, игровой CSS, хуки ядра) грузится
|
|
88
|
+
// по entries.client её манифеста — движок больше не импортирует игру статически
|
|
89
|
+
let activeGameManifest;
|
|
90
|
+
let clientPlugin;
|
|
91
|
+
let parts;
|
|
92
|
+
let gamesManifest;
|
|
93
|
+
|
|
94
|
+
// режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
|
|
95
|
+
// solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
|
|
96
|
+
// Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
|
|
97
|
+
// авто-аутентификация и точка монтирования канвасов.
|
|
98
|
+
//
|
|
99
|
+
// каталог манифестов нужен только лобби-контуру, а его запрос не зависит от
|
|
100
|
+
// ответа /config — пускаем оба в полёт разом, иначе старт лобби платит лишний
|
|
101
|
+
// последовательный round-trip
|
|
102
|
+
const injectedBoot = getBootConfig();
|
|
103
|
+
const manifestPromise = injectedBoot
|
|
104
|
+
? null
|
|
105
|
+
: fetchGamesManifest(lobbyConfig.gamesManifestUrl).catch(err => err);
|
|
106
|
+
|
|
107
|
+
const boot = injectedBoot ?? (await resolveBootConfig());
|
|
108
|
+
const bootMode = boot.mode;
|
|
109
|
+
const isLobbyMode = bootMode === 'lobby';
|
|
110
|
+
|
|
111
|
+
// точка монтирования игрового интерфейса: в lobby-режиме разметку даёт pug и
|
|
112
|
+
// каркас ничего не делает, в solo — собирается в контейнере SDK
|
|
113
|
+
const gameContainer = boot.container ?? document.body;
|
|
114
|
+
|
|
115
|
+
ensureGameShell(gameContainer);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (boot.manifest) {
|
|
119
|
+
// SDK передаёт манифест-подобный объект в памяти — каталога мастера нет
|
|
120
|
+
activeGameManifest = boot.manifest;
|
|
121
|
+
gamesManifest = [activeGameManifest];
|
|
122
|
+
} else {
|
|
123
|
+
gamesManifest = await manifestPromise;
|
|
124
|
+
|
|
125
|
+
// отказ запроса доехал значением (промис стартовал раньше try) —
|
|
126
|
+
// возвращаем его в обычный поток ошибок загрузки
|
|
127
|
+
if (gamesManifest instanceof Error) {
|
|
128
|
+
throw gamesManifest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
activeGameManifest = boot.gameId
|
|
132
|
+
? gamesManifest.find(manifest => manifest.id === boot.gameId)
|
|
133
|
+
: gamesManifest[0];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!activeGameManifest) {
|
|
137
|
+
throw new Error('master has no games in its catalog');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
clientPlugin = boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest));
|
|
141
|
+
parts = clientPlugin.parts;
|
|
142
|
+
|
|
143
|
+
const gameStyle = document.createElement('style');
|
|
144
|
+
gameStyle.textContent = clientPlugin.styles;
|
|
145
|
+
document.head.append(gameStyle);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
document.body.textContent = `Failed to load the game: ${e.message}`;
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// PS (server ports): порты получения данные от сервера
|
|
152
|
+
const PS_CONFIG_DATA = wsports.server.CONFIG_DATA;
|
|
153
|
+
const PS_AUTH_DATA = wsports.server.AUTH_DATA;
|
|
154
|
+
const PS_AUTH_RESULT = wsports.server.AUTH_RESULT;
|
|
155
|
+
const PS_MAP_DATA = wsports.server.MAP_DATA;
|
|
156
|
+
const PS_FIRST_SHOT_DATA = wsports.server.FIRST_SHOT_DATA;
|
|
157
|
+
const PS_SOUND_DATA = wsports.server.SOUND_DATA;
|
|
158
|
+
const PS_GAME_INFORM_DATA = wsports.server.GAME_INFORM_DATA;
|
|
159
|
+
const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
|
|
160
|
+
const PS_MISC = wsports.server.MISC;
|
|
161
|
+
const PS_PING = wsports.server.PING;
|
|
162
|
+
const PS_CLEAR = wsports.server.CLEAR;
|
|
163
|
+
const PS_CONSOLE = wsports.server.CONSOLE;
|
|
164
|
+
const PS_PANEL_DATA = wsports.server.PANEL_DATA;
|
|
165
|
+
const PS_STAT_DATA = wsports.server.STAT_DATA;
|
|
166
|
+
const PS_CHAT_DATA = wsports.server.CHAT_DATA;
|
|
167
|
+
const PS_VOTE_DATA = wsports.server.VOTE_DATA;
|
|
168
|
+
const PS_KEYSET_DATA = wsports.server.KEYSET_DATA;
|
|
169
|
+
|
|
170
|
+
// PC (client ports): порты получения данных от клиента
|
|
171
|
+
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
172
|
+
const PC_AUTH_RESPONSE = wsports.client.AUTH_RESPONSE;
|
|
173
|
+
const PC_MODULES_READY = wsports.client.MODULES_READY;
|
|
174
|
+
const PC_MAP_READY = wsports.client.MAP_READY;
|
|
175
|
+
const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
|
|
176
|
+
const PC_KEYS_DATA = wsports.client.KEYS_DATA;
|
|
177
|
+
const PC_CHAT_DATA = wsports.client.CHAT_DATA;
|
|
178
|
+
const PC_VOTE_DATA = wsports.client.VOTE_DATA;
|
|
179
|
+
const PC_PONG = wsports.client.PONG;
|
|
180
|
+
|
|
181
|
+
// сигнальный WebSocket мастера (лобби + установка P2P); игровой трафик идёт
|
|
182
|
+
// по WebRTC (transport), не через мастер
|
|
183
|
+
// (только lobby: solo и dedicated мастера не имеют вовсе)
|
|
184
|
+
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
185
|
+
const signaling = isLobbyMode
|
|
186
|
+
? new SignalingClient(`${wsProtocol}//${location.host}/`)
|
|
187
|
+
: null;
|
|
188
|
+
|
|
189
|
+
// активное P2P-соединение с хостом (создаётся при выборе сервера в лобби)
|
|
190
|
+
let transport = null;
|
|
191
|
+
|
|
192
|
+
// hostId комнаты, к которой подключён гость (для /like·/unlike напрямую
|
|
193
|
+
// мастеру). У хоста-игрока (своя вкладка) остаётся null — за себя не проголосовать
|
|
194
|
+
let currentHostId = null;
|
|
195
|
+
|
|
196
|
+
const modules = {};
|
|
197
|
+
|
|
198
|
+
// создание и инициализация SoundManager
|
|
199
|
+
const soundManager = new SoundManager();
|
|
200
|
+
let soundData = {};
|
|
201
|
+
|
|
202
|
+
const inputListener = new InputListener();
|
|
203
|
+
|
|
204
|
+
let modulesConfig = {};
|
|
205
|
+
let initIdList = [];
|
|
206
|
+
const apps = {};
|
|
207
|
+
|
|
208
|
+
// контекст рендера каждого полотна: нужен для перепечки ассетов после
|
|
209
|
+
// восстановления WebGL-контекста (весь видимый контент — RenderTexture без
|
|
210
|
+
// CPU-источника, сами по себе они не воскресают)
|
|
211
|
+
const renderContexts = {};
|
|
212
|
+
|
|
213
|
+
// последний payload MAP_DATA: карта пересобирается из него при восстановлении
|
|
214
|
+
// контекста (хост её повторно не пришлёт)
|
|
215
|
+
let lastMapData = null;
|
|
216
|
+
|
|
217
|
+
// dev-сборка (Vite подставляет константу): включает отладочный контур этапа 6
|
|
218
|
+
// плана plan/done/ai-debug — рекордер в комнате и window.__vimpDebug. В прод-бандле
|
|
219
|
+
// ветка вырезается сборкой, поведение не меняется
|
|
220
|
+
const isDevBuild =
|
|
221
|
+
typeof import.meta.env !== 'undefined' && import.meta.env.DEV === true;
|
|
222
|
+
|
|
223
|
+
// рендер снят с тикера на время потери контекста (состояние — по полотнам)
|
|
224
|
+
const contextTracker = createContextTracker();
|
|
225
|
+
|
|
226
|
+
// renderTick на тикере: Ticker.add дубликаты не отсеивает, а добавить его
|
|
227
|
+
// могут и runModules, и восстановление контекста
|
|
228
|
+
let renderTickAttached = false;
|
|
229
|
+
|
|
230
|
+
let gameInformer = null;
|
|
231
|
+
let gameInformList = []; // массив игровых сообщений
|
|
232
|
+
let panelView = null;
|
|
233
|
+
|
|
234
|
+
const techInformer = document.getElementById('tech-informer');
|
|
235
|
+
|
|
236
|
+
// массив системных сообщений: дефолт — из бандла, актуализируется CONFIG_DATA
|
|
237
|
+
// хоста. Дефолт обязателен: отказ полной комнаты (roomFull) приходит ДО
|
|
238
|
+
// CONFIG_DATA — без него клиент показал бы «Unknown error»
|
|
239
|
+
let techInformList = clientDefaults.techInformList;
|
|
240
|
+
|
|
241
|
+
// код 'loading' — единственный не-терминальный tech-код (см. TECH_CODES)
|
|
242
|
+
const TECH_LOADING_CODE = 2;
|
|
243
|
+
// код начала раунда (общий контракт с хостом — см. GAME_CODES)
|
|
244
|
+
const GAME_ROUND_START_CODE = GAME_CODES.roundStart[0];
|
|
245
|
+
// показан ли терминальный tech-код (кик, полная комната): причина закрытия
|
|
246
|
+
// соединения важнее общего сообщения handleDisconnect
|
|
247
|
+
let terminalInformShown = false;
|
|
248
|
+
// снятие предыдущего animationend-листенера логотипа при повторном
|
|
249
|
+
// playLogoRoundStart — иначе листенеры копятся при частых стартах раунда
|
|
250
|
+
let logoAnimationEndHandler = null;
|
|
251
|
+
|
|
252
|
+
const CTRL = {}; // контроллеры
|
|
253
|
+
let gameSets = {}; // наборы конструкторов (id: [наборы])
|
|
254
|
+
let entitiesOnCanvas = {}; // сущности, отображаемые на полотнах
|
|
255
|
+
let currentMapSetId; // текущий id набора конструкторов для карт
|
|
256
|
+
const socketMethods = []; // методы для обработки сокет-данных
|
|
257
|
+
|
|
258
|
+
// клиентское ядро (WASM, срез 2.6): интерполяция снапшотов, предикт своего
|
|
259
|
+
// танка, визуальный спавн выстрелов и распаковка кадров v3 — создаётся при
|
|
260
|
+
// получении конфига; wasm — результат init() для zero-copy чтения памяти
|
|
261
|
+
let clientCore = null;
|
|
262
|
+
let wasm = null;
|
|
263
|
+
let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
|
|
264
|
+
|
|
265
|
+
// обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
|
|
266
|
+
// keyId → { key, kind, width } — раскладку hot-буфера диктует схема,
|
|
267
|
+
// движковый бандл её не знает
|
|
268
|
+
let snapshotKeysById = null;
|
|
269
|
+
|
|
270
|
+
// SOCKET МЕТОДЫ
|
|
271
|
+
|
|
272
|
+
// config data
|
|
273
|
+
socketMethods[PS_CONFIG_DATA] = async data => {
|
|
274
|
+
gameSets = data.parts.gameSets;
|
|
275
|
+
entitiesOnCanvas = data.parts.entitiesOnCanvas;
|
|
276
|
+
|
|
277
|
+
// ширина hot-записи: keyId + id + поля класса по схеме игры
|
|
278
|
+
snapshotKeysById = buildSnapshotKeysById(data.snapshot);
|
|
279
|
+
|
|
280
|
+
// клиентское ядро: интерполяция + предикт + спавн выстрелов; конфиг
|
|
281
|
+
// собирается из interpolation/prediction CONFIG_DATA (хост шлёт их
|
|
282
|
+
// через buildClientConfig в Worker'е). wasmUrl — общий с host-плагином
|
|
283
|
+
// ассет из манифеста активной игры (entries.wasm)
|
|
284
|
+
const { core, memory } = await clientPlugin.createClientCore(
|
|
285
|
+
JSON.stringify(buildClientCoreConfig(data)),
|
|
286
|
+
{ wasmUrl: activeGameManifest.entries.wasm },
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
clientCore = core;
|
|
290
|
+
wasm = { memory };
|
|
291
|
+
|
|
292
|
+
// инициализация сущностей игры
|
|
293
|
+
for (const entity of Object.keys(entitiesOnCanvas)) {
|
|
294
|
+
Factory.add({ [entity]: parts[entity] });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
gameInformer = document.getElementById(data.gameInform.id);
|
|
298
|
+
gameInformList = data.gameInform.list;
|
|
299
|
+
|
|
300
|
+
techInformList = data.techInformList;
|
|
301
|
+
|
|
302
|
+
modulesConfig = data.modules;
|
|
303
|
+
initIdList = data.initIdList;
|
|
304
|
+
|
|
305
|
+
const bakedAssets = data.parts.bakedAssets || {};
|
|
306
|
+
const componentDependencies = data.parts.componentDependencies || {};
|
|
307
|
+
|
|
308
|
+
// путь к звукам — из assetsBase манифеста активной игры (Этап 6.3), не из
|
|
309
|
+
// бандла движка: сборка игры кладёт свою копию звуков рядом с
|
|
310
|
+
// client/host-бандлами (dist/sounds в пакете игры, например @vimp-games/tanks)
|
|
311
|
+
soundData = {
|
|
312
|
+
...(data.parts.sounds || {}),
|
|
313
|
+
path: `${activeGameManifest.assetsBase}sounds/`,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// создание полотен игры: canvas-элементы генерируются из конфига
|
|
317
|
+
// канвасов игры (в HTML их нет)
|
|
318
|
+
const canvasesConfig = modulesConfig.canvasManager.canvases;
|
|
319
|
+
|
|
320
|
+
const initPromises = Object.keys(canvasesConfig).map(async canvasId => {
|
|
321
|
+
const canvas = ensureCanvas(
|
|
322
|
+
canvasId,
|
|
323
|
+
canvasesConfig[canvasId],
|
|
324
|
+
gameContainer,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const app = new Application();
|
|
328
|
+
const assetProvider = new BakingProvider(clientPlugin.bakers);
|
|
329
|
+
const dependencyProvider = new DependencyProvider();
|
|
330
|
+
const bakingArr = bakedAssets[canvasId];
|
|
331
|
+
|
|
332
|
+
await app.init({
|
|
333
|
+
canvas,
|
|
334
|
+
width: canvas.width,
|
|
335
|
+
height: canvas.height,
|
|
336
|
+
antialias: true,
|
|
337
|
+
backgroundAlpha: 0,
|
|
338
|
+
sharedTicker: true,
|
|
339
|
+
accessibilityOptions: {
|
|
340
|
+
activateOnTab: false,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// пул всех доступных сервисов в этом контексте
|
|
345
|
+
const availableServices = {
|
|
346
|
+
renderer: app.renderer,
|
|
347
|
+
soundManager,
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// если есть данные для запекания компонентов
|
|
351
|
+
if (bakingArr) {
|
|
352
|
+
assetProvider.bakeAll(bakingArr, app);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
dependencyProvider.collectAll(availableServices, componentDependencies);
|
|
356
|
+
|
|
357
|
+
CTRL[canvasId] = makeGameController(
|
|
358
|
+
assetProvider.getAssetsCollection(),
|
|
359
|
+
dependencyProvider.getDependenciesCollection(),
|
|
360
|
+
app,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
apps[canvasId] = app;
|
|
364
|
+
renderContexts[canvasId] = { app, assetProvider, bakingArr };
|
|
365
|
+
|
|
366
|
+
canvas.addEventListener('webglcontextlost', handleContextLost);
|
|
367
|
+
canvas.addEventListener('webglcontextrestored', handleContextRestored);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
Promise.all(initPromises)
|
|
371
|
+
.then(() => {
|
|
372
|
+
sending(PC_CONFIG_READY); // config ready
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
.catch(err => {
|
|
376
|
+
console.error('Initialization error:', err);
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// auth data
|
|
381
|
+
socketMethods[PS_AUTH_DATA] = data => {
|
|
382
|
+
if (typeof data !== 'object' || data === null) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const { elems, params, texts } = data;
|
|
387
|
+
|
|
388
|
+
document.getElementById('logo').textContent = texts?.title || 'VIMP';
|
|
389
|
+
|
|
390
|
+
params.forEach(param => {
|
|
391
|
+
const { storage } = param.options;
|
|
392
|
+
|
|
393
|
+
if (storage) {
|
|
394
|
+
param.value = localStorage[storage] || param.value || '';
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// solo: формы нет — отвечаем дефолтами схемы, перекрытыми boot.autoAuth
|
|
399
|
+
if (boot.autoAuth) {
|
|
400
|
+
sending(PC_AUTH_RESPONSE, { ...defaultsFrom(params), ...boot.autoAuth });
|
|
401
|
+
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// клиент проверяет только движковые правила (isValidName): игровые
|
|
406
|
+
// валидаторы (isValidModel) не идут по проводу и не грузятся с
|
|
407
|
+
// ClientPlugin (HostPlugin.authSchema — только у хоста). Хост валидирует
|
|
408
|
+
// их авторитетно (host.worker.js) — рассинхрон вернётся в AUTH_RESULT
|
|
409
|
+
const clientValidator = authData => validateAuth(authData, params);
|
|
410
|
+
|
|
411
|
+
const authModel = new AuthModel(clientValidator);
|
|
412
|
+
const authView = new AuthView(authModel, elems, texts, params);
|
|
413
|
+
modules.auth = new AuthCtrl(authModel, authView);
|
|
414
|
+
|
|
415
|
+
authModel.publisher.on('socket', data => {
|
|
416
|
+
// игровой хук авторизации (модель танка для реплик движения и выстрелов)
|
|
417
|
+
if (clientCore) {
|
|
418
|
+
clientPlugin.hooks.onAuth(clientCore, data);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ник больше не вводится в игровой форме — токен лобби несёт claim
|
|
422
|
+
// 'nick', хост проверяет его подпись по /jwks и берёт ник оттуда (Этап B3)
|
|
423
|
+
sending(PC_AUTH_RESPONSE, { ...data, token: lobbyAuthModel?.getToken() });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
modules.auth.init(params);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// значения формы по умолчанию (schema → { name: value }): база авто-ответа
|
|
430
|
+
function defaultsFrom(params) {
|
|
431
|
+
return Object.fromEntries(params.map(param => [param.name, param.value]));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// auth errors
|
|
435
|
+
socketMethods[PS_AUTH_RESULT] = async err => {
|
|
436
|
+
if (modules.auth) {
|
|
437
|
+
modules.auth.parseRes(err);
|
|
438
|
+
} else if (err) {
|
|
439
|
+
// авто-аутентификация отбита хостом: формы, куда вернуть ошибку, нет
|
|
440
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
441
|
+
`Authorization rejected: ${JSON.stringify(err)}`,
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!err) {
|
|
448
|
+
await soundManager.init(soundData);
|
|
449
|
+
runModules(modulesConfig);
|
|
450
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
451
|
+
|
|
452
|
+
for (const id of initIdList) {
|
|
453
|
+
const elem = document.getElementById(id);
|
|
454
|
+
|
|
455
|
+
if (elem) {
|
|
456
|
+
elem.style.display = id === 'panel' ? 'flex' : 'block';
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
sending(PC_MODULES_READY);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// map data
|
|
465
|
+
socketMethods[PS_MAP_DATA] = data => {
|
|
466
|
+
lastMapData = data;
|
|
467
|
+
applyMapData(data);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
// собирает карту по payload MAP_DATA. notifyHost=false — пересборка после
|
|
471
|
+
// восстановления WebGL-контекста: повторный MAP_READY сломал бы машину
|
|
472
|
+
// состояний портов (хост его больше не ждёт)
|
|
473
|
+
function applyMapData(data, { notifyHost = true } = {}) {
|
|
474
|
+
const { scale, layers, map, step, setId, spriteSheet, physicsStatic } = data;
|
|
475
|
+
|
|
476
|
+
// ядру — мир для raycast выстрелов (+сброс буфера кадров и предикта)
|
|
477
|
+
try {
|
|
478
|
+
clientCore?.set_map(
|
|
479
|
+
JSON.stringify({
|
|
480
|
+
map,
|
|
481
|
+
step,
|
|
482
|
+
scale,
|
|
483
|
+
physicsStatic,
|
|
484
|
+
physicsDynamic: data.physicsDynamic,
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
} catch (e) {
|
|
488
|
+
console.warn('[clientCore] set_map failed:', e);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// удаление данных карт
|
|
492
|
+
const removeMap = setId => {
|
|
493
|
+
const nameArr = gameSets[setId] || [];
|
|
494
|
+
|
|
495
|
+
nameArr.forEach(name => {
|
|
496
|
+
CTRL[entitiesOnCanvas[name]].remove(name);
|
|
497
|
+
});
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// создание карт
|
|
501
|
+
const createMap = (setId, staticData) => {
|
|
502
|
+
const nameArr = gameSets[setId];
|
|
503
|
+
const dynamicArr = data.physicsDynamic || [];
|
|
504
|
+
const dynamicData = {};
|
|
505
|
+
|
|
506
|
+
dynamicArr.forEach((item, index) => {
|
|
507
|
+
const key = `d${index}`;
|
|
508
|
+
dynamicData[key] = { ...item, type: 'dynamic', scale };
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
nameArr.forEach(name => {
|
|
512
|
+
const canvasId = entitiesOnCanvas[name];
|
|
513
|
+
|
|
514
|
+
// статические данные карты
|
|
515
|
+
CTRL[canvasId].parse(name, staticData);
|
|
516
|
+
|
|
517
|
+
// динамические данные карты
|
|
518
|
+
CTRL[canvasId].parse(name, dynamicData);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
currentMapSetId = setId;
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const staticData = Object.entries(layers).reduce(
|
|
525
|
+
(acc, [layer, tiles], index) => {
|
|
526
|
+
acc[`s${index}`] = {
|
|
527
|
+
type: 'static',
|
|
528
|
+
spriteSheet,
|
|
529
|
+
map,
|
|
530
|
+
step,
|
|
531
|
+
layer,
|
|
532
|
+
tiles,
|
|
533
|
+
physicsStatic,
|
|
534
|
+
scale,
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
return acc;
|
|
538
|
+
},
|
|
539
|
+
{},
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
removeMap(currentMapSetId);
|
|
543
|
+
createMap(setId, staticData);
|
|
544
|
+
|
|
545
|
+
if (notifyHost) {
|
|
546
|
+
sending(PC_MAP_READY);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// первый shot сразу после загрузки карты (JSON; порт 5 идёт бинарным путём);
|
|
551
|
+
// применяется немедленно (создание сущностей), в буфер интерполяции не пушится
|
|
552
|
+
socketMethods[PS_FIRST_SHOT_DATA] = data => {
|
|
553
|
+
const [game, camera] = data;
|
|
554
|
+
|
|
555
|
+
applyShot(game, camera);
|
|
556
|
+
|
|
557
|
+
// подтверждение получения первого шота
|
|
558
|
+
sending(PC_FIRST_SHOT_READY);
|
|
559
|
+
|
|
560
|
+
// solo: выход из наблюдателей и чат-команды игры (боты) — см. autostart.js
|
|
561
|
+
runAutostart();
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
// отложенная задача автостарта: исполняется на первом renderTick
|
|
565
|
+
let pendingAutostart = null;
|
|
566
|
+
|
|
567
|
+
const runAutostart = createAutostart({
|
|
568
|
+
votes: boot.startupVotes ?? [],
|
|
569
|
+
commands: boot.startupCommands ?? [],
|
|
570
|
+
sendVote: data => sending(PC_VOTE_DATA, data),
|
|
571
|
+
sendCommand: message => sending(PC_CHAT_DATA, message),
|
|
572
|
+
schedule: fn => {
|
|
573
|
+
pendingAutostart = fn;
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// panel data
|
|
578
|
+
socketMethods[PS_PANEL_DATA] = data => {
|
|
579
|
+
modules.panel.update(data);
|
|
580
|
+
|
|
581
|
+
// игровой хук: зеркало панели в клиентском ядре (гейты try_fire)
|
|
582
|
+
if (clientCore) {
|
|
583
|
+
clientPlugin.hooks.onPanel(clientCore, data);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// stat data
|
|
588
|
+
socketMethods[PS_STAT_DATA] = data => {
|
|
589
|
+
modules.stat.update(data);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
// chat data
|
|
593
|
+
socketMethods[PS_CHAT_DATA] = data => {
|
|
594
|
+
modules.chat.add(data);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// vote data
|
|
598
|
+
socketMethods[PS_VOTE_DATA] = data => {
|
|
599
|
+
modules.vote.open(data);
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// keyset data (смена режима спектатор/игрок)
|
|
603
|
+
socketMethods[PS_KEYSET_DATA] = keySet => {
|
|
604
|
+
modules.controls.changeKeySet(keySet);
|
|
605
|
+
clientCore?.set_active(keySet === 1);
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// sound data
|
|
609
|
+
socketMethods[PS_SOUND_DATA] = sample => {
|
|
610
|
+
soundManager.playSystemSound(sample);
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// game inform data
|
|
614
|
+
socketMethods[PS_GAME_INFORM_DATA] = data => {
|
|
615
|
+
if (data) {
|
|
616
|
+
const [key, arr] = data;
|
|
617
|
+
|
|
618
|
+
gameInformer.textContent = formatMessage(gameInformList[key], arr);
|
|
619
|
+
gameInformer.style.display = 'block';
|
|
620
|
+
|
|
621
|
+
setTimeout(() => {
|
|
622
|
+
gameInformer.textContent = '';
|
|
623
|
+
gameInformer.style.display = 'none';
|
|
624
|
+
}, 3000);
|
|
625
|
+
|
|
626
|
+
if (key === GAME_ROUND_START_CODE) {
|
|
627
|
+
playLogoRoundStart();
|
|
628
|
+
panelView?.playRoundStart();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// проигрывает shimmer-волну по логотипу в начале раунда; логотип живёт вне
|
|
634
|
+
// PanelView.containerId, поэтому панель им не управляет (см. docs/en/client.md)
|
|
635
|
+
const playLogoRoundStart = () => {
|
|
636
|
+
const logo = document.getElementById('logo');
|
|
637
|
+
|
|
638
|
+
if (!logo) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (logoAnimationEndHandler) {
|
|
643
|
+
logo.removeEventListener('animationend', logoAnimationEndHandler);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
logo.classList.remove('logo-round-start');
|
|
647
|
+
void logo.offsetWidth; // reflow: перезапуск анимации при повторном добавлении класса
|
|
648
|
+
logo.classList.add('logo-round-start');
|
|
649
|
+
|
|
650
|
+
logoAnimationEndHandler = () => {
|
|
651
|
+
logo.classList.remove('logo-round-start');
|
|
652
|
+
logoAnimationEndHandler = null;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
logo.addEventListener('animationend', logoAnimationEndHandler, {
|
|
656
|
+
once: true,
|
|
657
|
+
});
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// technical inform data
|
|
661
|
+
socketMethods[PS_TECH_INFORM_DATA] = data => {
|
|
662
|
+
if (data) {
|
|
663
|
+
let message;
|
|
664
|
+
|
|
665
|
+
if (Array.isArray(data)) {
|
|
666
|
+
const [key, arr] = data;
|
|
667
|
+
|
|
668
|
+
message = formatMessage(techInformList[key], arr) || 'Unknown error';
|
|
669
|
+
// терминальные коды (кик, полная комната) — причина закрытия соединения,
|
|
670
|
+
// последующий handleDisconnect не должен затирать её общим сообщением
|
|
671
|
+
terminalInformShown = key !== TECH_LOADING_CODE;
|
|
672
|
+
} else {
|
|
673
|
+
message = data;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
modules.controls?.disableKeys();
|
|
677
|
+
techInformer.textContent = message;
|
|
678
|
+
techInformer.style.display = 'block';
|
|
679
|
+
} else {
|
|
680
|
+
modules.controls?.enableKeys();
|
|
681
|
+
terminalInformShown = false;
|
|
682
|
+
techInformer.textContent = '';
|
|
683
|
+
techInformer.style.display = 'none';
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
// misc
|
|
688
|
+
socketMethods[PS_MISC] = data => {
|
|
689
|
+
const { key, value } = data;
|
|
690
|
+
|
|
691
|
+
if (key === 'localstorageNameReplace') {
|
|
692
|
+
localStorage['userName'] = value;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
// ping
|
|
697
|
+
socketMethods[PS_PING] = pingId => {
|
|
698
|
+
sending(PC_PONG, pingId, false);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
// clear
|
|
702
|
+
socketMethods[PS_CLEAR] = function (setIdList) {
|
|
703
|
+
// если есть список setId (учитывается в том числе пустой список)
|
|
704
|
+
if (Array.isArray(setIdList)) {
|
|
705
|
+
for (let i = 0, len = setIdList.length; i < len; i += 1) {
|
|
706
|
+
const nameArr = gameSets[setIdList[i]] || [];
|
|
707
|
+
|
|
708
|
+
nameArr.forEach(name => {
|
|
709
|
+
CTRL[entitiesOnCanvas[name]].remove(name);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
for (const p in CTRL) {
|
|
714
|
+
if (Object.hasOwn(CTRL, p)) {
|
|
715
|
+
CTRL[p].remove();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
clientCore?.reset();
|
|
721
|
+
soundManager.reset();
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// console: логи авторитетной половины (Worker изолирован от DevTools вкладки —
|
|
725
|
+
// иначе его события в браузере не видны вовсе, этап 6 плана plan/done/ai-debug)
|
|
726
|
+
socketMethods[PS_CONSOLE] = data => {
|
|
727
|
+
console.log(`${DEBUG_PREFIX}[host]`, data);
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// ФУНКЦИИ
|
|
731
|
+
|
|
732
|
+
// применяет игровые данные к сущностям
|
|
733
|
+
function applyGameData(game) {
|
|
734
|
+
Object.entries(game).forEach(([p, instances]) => {
|
|
735
|
+
const nameArr = gameSets[p];
|
|
736
|
+
|
|
737
|
+
nameArr.forEach(name => {
|
|
738
|
+
CTRL[entitiesOnCanvas[name]].parse(name, instances);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// применяет данные камеры (позиция слушателя звука + полотно)
|
|
744
|
+
function applyCamera(camera) {
|
|
745
|
+
if (camera && camera !== 0) {
|
|
746
|
+
soundManager.setListenerPosition(camera[0], camera[1]);
|
|
747
|
+
modules.canvasManager.updateCoords(camera);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// применяет кадр целиком (первый кадр и дискретные кадры интерполяции)
|
|
752
|
+
function applyShot(game, camera) {
|
|
753
|
+
applyGameData(game);
|
|
754
|
+
applyCamera(camera);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// рендер-тик: ядро выдаёт пересечённые кадры (события, создания/удаления)
|
|
758
|
+
// JSON-очередью, а горячие позиции (танки/динамика/камера + предсказанный
|
|
759
|
+
// свой танк) — плоским Float32-буфером zero-copy из памяти WASM
|
|
760
|
+
function renderTick() {
|
|
761
|
+
if (!clientCore) {
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// автостарт solo — на первом кадре после FIRST_SHOT_READY, а не в том же
|
|
766
|
+
// синхронном вызове
|
|
767
|
+
if (pendingAutostart) {
|
|
768
|
+
const task = pendingAutostart;
|
|
769
|
+
|
|
770
|
+
pendingAutostart = null;
|
|
771
|
+
task();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const len = clientCore.sample(performance.now());
|
|
775
|
+
|
|
776
|
+
// view пересоздаётся каждый тик: рост памяти WASM детачит buffer
|
|
777
|
+
const hot = new Float32Array(wasm.memory.buffer, clientCore.hot_ptr(), len);
|
|
778
|
+
const flags = hot[0];
|
|
779
|
+
|
|
780
|
+
if (flags & HOT_FLAGS.FRAMES) {
|
|
781
|
+
JSON.parse(clientCore.take_frames()).forEach(frame => {
|
|
782
|
+
applyShot(frame.game, frame.camera);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (flags & (HOT_FLAGS.GAME | HOT_FLAGS.PREDICTED)) {
|
|
787
|
+
applyGameData(reconstructHot(hot, snapshotKeysById));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (flags & HOT_FLAGS.CAMERA) {
|
|
791
|
+
// камера уже разрешена ядром: предсказанная позиция либо интерполированная
|
|
792
|
+
applyCamera([hot[1], hot[2]]);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
soundManager.processAudibility();
|
|
796
|
+
soundManager.updateActiveSounds();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// создает пользователя
|
|
800
|
+
function runModules(data) {
|
|
801
|
+
const {
|
|
802
|
+
canvasManager: canvasManagerData,
|
|
803
|
+
controls: controlsData,
|
|
804
|
+
chat: chatData,
|
|
805
|
+
panel: panelData,
|
|
806
|
+
stat: statData,
|
|
807
|
+
vote: voteData,
|
|
808
|
+
} = data;
|
|
809
|
+
|
|
810
|
+
//==========================================//
|
|
811
|
+
// CanvasManager Module
|
|
812
|
+
//==========================================//
|
|
813
|
+
|
|
814
|
+
const canvasManagerModel = new CanvasManagerModel(canvasManagerData);
|
|
815
|
+
|
|
816
|
+
const canvasManagerView = new CanvasManagerView(canvasManagerModel, apps);
|
|
817
|
+
|
|
818
|
+
modules.canvasManager = new CanvasManagerCtrl(
|
|
819
|
+
canvasManagerModel,
|
|
820
|
+
canvasManagerView,
|
|
821
|
+
);
|
|
822
|
+
modules.canvasManager.resize({
|
|
823
|
+
width: innerWidth,
|
|
824
|
+
height: innerHeight,
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
//==========================================//
|
|
828
|
+
// Controls Module
|
|
829
|
+
//==========================================//
|
|
830
|
+
|
|
831
|
+
const controlsModel = new ControlsModel(controlsData);
|
|
832
|
+
const controlsView = new ControlsView(controlsModel);
|
|
833
|
+
|
|
834
|
+
modules.controls = new ControlsCtrl(controlsModel, controlsView);
|
|
835
|
+
modules.controls.resetCursorHideTimer();
|
|
836
|
+
|
|
837
|
+
//==========================================//
|
|
838
|
+
// Chat Module
|
|
839
|
+
//==========================================//
|
|
840
|
+
|
|
841
|
+
const chatModel = new ChatModel({
|
|
842
|
+
listLimit: chatData.params.listLimit,
|
|
843
|
+
lineTime: chatData.params.lineTime,
|
|
844
|
+
cacheMin: chatData.params.cacheMin,
|
|
845
|
+
cacheMax: chatData.params.cacheMax,
|
|
846
|
+
messages: chatData.params.messages,
|
|
847
|
+
sanitizeMessage,
|
|
848
|
+
formatMessage,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const chatView = new ChatView(chatModel, chatData.elems);
|
|
852
|
+
|
|
853
|
+
modules.chat = new ChatCtrl(chatModel, chatView);
|
|
854
|
+
|
|
855
|
+
//==========================================//
|
|
856
|
+
// Panel Module
|
|
857
|
+
//==========================================//
|
|
858
|
+
|
|
859
|
+
const panelModel = new PanelModel(panelData.keys, panelData.fields);
|
|
860
|
+
|
|
861
|
+
// PanelView генерирует DOM по типам схемы игры ({ containerId, fields })
|
|
862
|
+
panelView = new PanelView(panelModel, panelData);
|
|
863
|
+
|
|
864
|
+
modules.panel = new PanelCtrl(panelModel, panelView);
|
|
865
|
+
|
|
866
|
+
//==========================================//
|
|
867
|
+
// Stat Module
|
|
868
|
+
//==========================================//
|
|
869
|
+
|
|
870
|
+
const statModel = new StatModel(statData.params);
|
|
871
|
+
|
|
872
|
+
// StatView генерирует шапку и таблицы по схеме игры ({ elems, params })
|
|
873
|
+
const statView = new StatView(statModel, statData);
|
|
874
|
+
|
|
875
|
+
modules.stat = new StatCtrl(statModel, statView);
|
|
876
|
+
|
|
877
|
+
//==========================================//
|
|
878
|
+
// Vote Module
|
|
879
|
+
//==========================================//
|
|
880
|
+
|
|
881
|
+
const voteModel = new VoteModel({ ...voteData.params, formatMessage });
|
|
882
|
+
const voteView = new VoteView(voteModel, voteData.elems, gameContainer);
|
|
883
|
+
|
|
884
|
+
modules.vote = new VoteCtrl(voteModel, voteView);
|
|
885
|
+
|
|
886
|
+
//==========================================//
|
|
887
|
+
// Подписка на события
|
|
888
|
+
//==========================================//
|
|
889
|
+
|
|
890
|
+
// событие активации режима
|
|
891
|
+
controlsModel.publisher.on('mode', openMode);
|
|
892
|
+
|
|
893
|
+
// подписка на данные от пользователя для режимов
|
|
894
|
+
controlsModel.publisher.on('chat', modules.chat.updateCmd.bind(modules.chat));
|
|
895
|
+
controlsModel.publisher.on('stat', modules.stat.close.bind(modules.stat));
|
|
896
|
+
controlsModel.publisher.on('vote', modules.vote.assignKey.bind(modules.vote));
|
|
897
|
+
|
|
898
|
+
inputListener.publisher.on(
|
|
899
|
+
'keyDown',
|
|
900
|
+
modules.controls.add.bind(modules.controls),
|
|
901
|
+
);
|
|
902
|
+
inputListener.publisher.on(
|
|
903
|
+
'keyUp',
|
|
904
|
+
modules.controls.remove.bind(modules.controls),
|
|
905
|
+
);
|
|
906
|
+
inputListener.publisher.on(
|
|
907
|
+
'mouseAction',
|
|
908
|
+
modules.controls.resetCursorHideTimer.bind(modules.controls),
|
|
909
|
+
);
|
|
910
|
+
inputListener.publisher.on(
|
|
911
|
+
'resize',
|
|
912
|
+
modules.canvasManager.resize.bind(modules.canvasManager),
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
chatModel.publisher.on(
|
|
916
|
+
'mode',
|
|
917
|
+
modules.controls.switchMode.bind(modules.controls),
|
|
918
|
+
);
|
|
919
|
+
statModel.publisher.on(
|
|
920
|
+
'mode',
|
|
921
|
+
modules.controls.switchMode.bind(modules.controls),
|
|
922
|
+
);
|
|
923
|
+
voteModel.publisher.on(
|
|
924
|
+
'mode',
|
|
925
|
+
modules.controls.switchMode.bind(modules.controls),
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
controlsModel.publisher.on('socket', data => {
|
|
929
|
+
// формат wire: 'seq:action:name' (seq — подтверждение ввода сервером)
|
|
930
|
+
const [action, name] = data.split(':');
|
|
931
|
+
const now = performance.now();
|
|
932
|
+
|
|
933
|
+
inputSeq = (inputSeq + 1) >>> 0;
|
|
934
|
+
clientCore?.apply_input(action, name, now);
|
|
935
|
+
|
|
936
|
+
// игровой хук: визуальный спавн своего выстрела и локальная смена
|
|
937
|
+
// оружия (try_fire/cycle_weapon; гейты в ядре)
|
|
938
|
+
if (clientCore) {
|
|
939
|
+
const spawn = clientPlugin.hooks.onLocalAction(
|
|
940
|
+
clientCore,
|
|
941
|
+
action,
|
|
942
|
+
name,
|
|
943
|
+
now,
|
|
944
|
+
);
|
|
945
|
+
|
|
946
|
+
if (spawn) {
|
|
947
|
+
applyGameData(JSON.parse(spawn));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
sending(PC_KEYS_DATA, `${inputSeq}:${data}`);
|
|
952
|
+
});
|
|
953
|
+
chatModel.publisher.on('socket', handleChatSend);
|
|
954
|
+
voteModel.publisher.on('socket', data => sending(PC_VOTE_DATA, data));
|
|
955
|
+
|
|
956
|
+
//==========================================//
|
|
957
|
+
// Рендер-цикл интерполяции
|
|
958
|
+
//==========================================//
|
|
959
|
+
|
|
960
|
+
startRenderLoop();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// создает экземпляр игры
|
|
964
|
+
function makeGameController(assetsCollection, dependenciesCollection, app) {
|
|
965
|
+
const model = new GameModel(assetsCollection, dependenciesCollection);
|
|
966
|
+
const view = new GameView(model, app);
|
|
967
|
+
const controller = new GameCtrl(model, view);
|
|
968
|
+
|
|
969
|
+
return controller;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// открывает режим
|
|
973
|
+
function openMode(mode) {
|
|
974
|
+
if (modules[mode]) {
|
|
975
|
+
modules[mode].open();
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// отправляет данные хосту (весь клиентский протокол — по надёжному каналу meta)
|
|
980
|
+
// reliable=false — по ненадёжному state-каналу (только pong: замер RTT
|
|
981
|
+
// должен отражать сетевой путь, а не reliable-поток с ретрансмиссиями)
|
|
982
|
+
function sending(name, data, reliable = true) {
|
|
983
|
+
transport?.send(JSON.stringify([name, data]), reliable);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// перехватывает /like·/unlike <причина> и шлёт голос напрямую мастеру по
|
|
987
|
+
// сигнальному WS, минуя хоста: его CommandProcessor мог бы отфильтровать
|
|
988
|
+
// голос против самого себя (server-rating этап 2, замена /ban). Причина
|
|
989
|
+
// обязательна (публично не отображается). Остальной чат уходит хосту
|
|
990
|
+
function handleChatSend(message) {
|
|
991
|
+
const likeMatch = message === '/like' || message.startsWith('/like ');
|
|
992
|
+
const unlikeMatch = message === '/unlike' || message.startsWith('/unlike ');
|
|
993
|
+
|
|
994
|
+
// вне лобби мастера нет — голосовать некому, команда уходит хосту как
|
|
995
|
+
// обычное сообщение чата
|
|
996
|
+
if (isLobbyMode && (likeMatch || unlikeMatch)) {
|
|
997
|
+
const command = likeMatch ? '/like' : '/unlike';
|
|
998
|
+
const reason = message.slice(command.length).trim();
|
|
999
|
+
const token = lobbyAuthModel.getToken();
|
|
1000
|
+
|
|
1001
|
+
if (!currentHostId) {
|
|
1002
|
+
modules.chat.add([`${command} is available to room guests only`]);
|
|
1003
|
+
} else if (!token) {
|
|
1004
|
+
modules.chat.add([`${command} requires signing in`]);
|
|
1005
|
+
} else if (!reason) {
|
|
1006
|
+
modules.chat.add([`${command} requires a reason: ${command} <reason>`]);
|
|
1007
|
+
} else if (!signaling.connected) {
|
|
1008
|
+
modules.chat.add(['No connection to the master server — vote not sent']);
|
|
1009
|
+
} else if (likeMatch) {
|
|
1010
|
+
signaling.likeHost(currentHostId, reason, token);
|
|
1011
|
+
modules.chat.add(['Vote sent to the master server']);
|
|
1012
|
+
} else {
|
|
1013
|
+
signaling.unlikeHost(currentHostId, reason, token);
|
|
1014
|
+
modules.chat.add(['Vote sent to the master server']);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
sending(PC_CHAT_DATA, message);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// распаковывает данные
|
|
1024
|
+
function unpacking(pack) {
|
|
1025
|
+
return JSON.parse(pack);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// дольше, чем интерполятор способен удержать буфер: после такой паузы
|
|
1029
|
+
// кадры всё равно подрезаны, а часы устарели. Короткий alt-tab ресинка не
|
|
1030
|
+
// стоит — он выбрасывает валидный буфер вместе с событийными кадрами
|
|
1031
|
+
// (создание/удаление сущностей), и сцена замирает на delay + пару кадров
|
|
1032
|
+
const RESYNC_AFTER_HIDDEN_MS = 3000;
|
|
1033
|
+
|
|
1034
|
+
// вкладка могла быть скрыта уже в момент навешивания слушателя — события
|
|
1035
|
+
// 'hidden' тогда не будет, а пауза всё равно идёт
|
|
1036
|
+
let hiddenAt =
|
|
1037
|
+
document.visibilityState === 'hidden' ? performance.now() : null;
|
|
1038
|
+
|
|
1039
|
+
// обработчик видимости вкладки
|
|
1040
|
+
function handleVisibilityChange() {
|
|
1041
|
+
// если вкладка неактивна, выключение звука
|
|
1042
|
+
if (document.visibilityState === 'hidden') {
|
|
1043
|
+
hiddenAt = performance.now();
|
|
1044
|
+
soundManager.mute();
|
|
1045
|
+
// иначе включение звука при возвращении
|
|
1046
|
+
} else {
|
|
1047
|
+
soundManager.unmute();
|
|
1048
|
+
|
|
1049
|
+
const hiddenMs = hiddenAt === null ? 0 : performance.now() - hiddenAt;
|
|
1050
|
+
|
|
1051
|
+
hiddenAt = null;
|
|
1052
|
+
|
|
1053
|
+
// после длинной паузы часы интерполятора устарели: пересеять оффсет
|
|
1054
|
+
// точно, а не догонять EMA десятки кадров. Опциональный вызов —
|
|
1055
|
+
// старая сборка плагина метода не имеет
|
|
1056
|
+
if (hiddenMs >= RESYNC_AFTER_HIDDEN_MS) {
|
|
1057
|
+
clientCore?.resync?.();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (isDevBuild) {
|
|
1061
|
+
for (const id in renderContexts) {
|
|
1062
|
+
if (Object.hasOwn(renderContexts, id)) {
|
|
1063
|
+
const { app } = renderContexts[id];
|
|
1064
|
+
|
|
1065
|
+
debugLog('visible', {
|
|
1066
|
+
canvas: id,
|
|
1067
|
+
contextLost: app.renderer.gl?.isContextLost?.(),
|
|
1068
|
+
size: [app.canvas.width, app.canvas.height],
|
|
1069
|
+
stageScale: app.stage.scale.x,
|
|
1070
|
+
stagePos: [app.stage.position.x, app.stage.position.y],
|
|
1071
|
+
tickerStarted: Ticker.shared.started,
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
debugLog('clientCore', clientCore?.debug_json?.());
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// единая точка управления рендер-циклом: Ticker.add дубликаты не отсеивает,
|
|
1082
|
+
// а добавить renderTick могут и runModules, и восстановление контекста
|
|
1083
|
+
function startRenderLoop() {
|
|
1084
|
+
if (renderTickAttached) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
Ticker.shared.add(renderTick);
|
|
1089
|
+
renderTickAttached = true;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function stopRenderLoop() {
|
|
1093
|
+
if (!renderTickAttached) {
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
Ticker.shared.remove(renderTick);
|
|
1098
|
+
renderTickAttached = false;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// id полотна по его canvas — событие контекста приходит от конкретного
|
|
1102
|
+
function canvasIdByTarget(target) {
|
|
1103
|
+
return Object.keys(renderContexts).find(
|
|
1104
|
+
id => renderContexts[id].app.canvas === target,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// потеря WebGL-контекста (сворачивание вкладки, сброс GPU-драйвера): сцена и
|
|
1109
|
+
// тикер целы, но все текстуры мертвы — полотно рисовалось бы пустым. Рендер
|
|
1110
|
+
// снимаем до восстановления
|
|
1111
|
+
function handleContextLost(event) {
|
|
1112
|
+
// без preventDefault браузер не пришлёт webglcontextrestored
|
|
1113
|
+
event.preventDefault();
|
|
1114
|
+
|
|
1115
|
+
const id = canvasIdByTarget(event.target);
|
|
1116
|
+
|
|
1117
|
+
if (id === undefined || !contextTracker.markLost(id)) {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
stopRenderLoop();
|
|
1122
|
+
console.warn(`[render] WebGL context lost (${id}), rendering paused`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// восстановление контекста: перепекаем ассеты и пересобираем карту (весь
|
|
1126
|
+
// видимый контент — RenderTexture без CPU-источника). Танки и динамика
|
|
1127
|
+
// восстановятся сами из ближайших кадров
|
|
1128
|
+
function handleContextRestored(event) {
|
|
1129
|
+
const id = canvasIdByTarget(event.target);
|
|
1130
|
+
|
|
1131
|
+
// пересобираем сцену только когда живы ВСЕ контексты: перепечка в ещё
|
|
1132
|
+
// мёртвый контекст даёт пустые текстуры, а второго события не будет
|
|
1133
|
+
if (id === undefined || !contextTracker.markRestored(id)) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// сущности держат мёртвые текстуры
|
|
1138
|
+
for (const p in CTRL) {
|
|
1139
|
+
if (Object.hasOwn(CTRL, p)) {
|
|
1140
|
+
CTRL[p].remove();
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
for (const id in renderContexts) {
|
|
1145
|
+
if (Object.hasOwn(renderContexts, id)) {
|
|
1146
|
+
const { app, assetProvider, bakingArr } = renderContexts[id];
|
|
1147
|
+
|
|
1148
|
+
// BakingProvider пишет в тот же экземпляр Map, который держит
|
|
1149
|
+
// GameModel._assets — контроллеры пересоздавать не нужно
|
|
1150
|
+
if (bakingArr) {
|
|
1151
|
+
assetProvider.bakeAll(bakingArr, app);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
if (lastMapData) {
|
|
1157
|
+
applyMapData(lastMapData, { notifyHost: false });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
startRenderLoop();
|
|
1161
|
+
console.warn('[render] WebGL context restored, rendering resumed');
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ДАННЫЕ ОТ ХОСТА (WebRTC-транспорт)
|
|
1165
|
+
|
|
1166
|
+
// обрабатывает входящий пакет: ArrayBuffer → кадр снапшота, строка → JSON-порт
|
|
1167
|
+
function handleMessage(data) {
|
|
1168
|
+
// бинарный кадр (snapshot, порт SHOT_DATA) — в ядро: распаковка, вставка
|
|
1169
|
+
// в буфер по seq, reconciliation предикта по player-блоку
|
|
1170
|
+
if (data instanceof ArrayBuffer) {
|
|
1171
|
+
clientCore?.push_frame(new Uint8Array(data), performance.now());
|
|
1172
|
+
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// JSON-сообщение [portId, payload]
|
|
1177
|
+
const msg = unpacking(data);
|
|
1178
|
+
|
|
1179
|
+
socketMethods[msg[0]](msg[1]);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// разрыв P2P: выход хоста = смерть комнаты (host-migration нет). Останавливаем
|
|
1183
|
+
// рендер, показываем заглушку и возвращаемся в лобби перезагрузкой. closeCode
|
|
1184
|
+
// приходит только от WebSocket-транспорта (dedicated), остальные транспорты
|
|
1185
|
+
// эмитят close без него
|
|
1186
|
+
function handleDisconnect(closeCode) {
|
|
1187
|
+
// app.stop() здесь не зовём: при sharedTicker это Ticker.shared.stop()
|
|
1188
|
+
// глобально, а autoStart вернёт тикер к жизни при первом add() из любого
|
|
1189
|
+
// part'а — уже без renderTick. Рендер снят строкой выше, страницу и так
|
|
1190
|
+
// убивает location.reload() через 3 с
|
|
1191
|
+
stopRenderLoop();
|
|
1192
|
+
|
|
1193
|
+
// иначе восстановление контекста вернуло бы рендер уже мёртвой игре
|
|
1194
|
+
for (const id in renderContexts) {
|
|
1195
|
+
if (Object.hasOwn(renderContexts, id)) {
|
|
1196
|
+
const { canvas } = renderContexts[id].app;
|
|
1197
|
+
|
|
1198
|
+
canvas.removeEventListener('webglcontextlost', handleContextLost);
|
|
1199
|
+
canvas.removeEventListener('webglcontextrestored', handleContextRestored);
|
|
1200
|
+
delete renderContexts[id];
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
contextTracker.reset();
|
|
1205
|
+
lastMapData = null;
|
|
1206
|
+
|
|
1207
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
1208
|
+
soundManager.destroy();
|
|
1209
|
+
modules.controls?.disableKeys();
|
|
1210
|
+
|
|
1211
|
+
// если мы были хостом — гасим комнату: heartbeat, WebRTC-пиры, Worker
|
|
1212
|
+
if (hostHeartbeat) {
|
|
1213
|
+
clearInterval(hostHeartbeat);
|
|
1214
|
+
hostHeartbeat = null;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
hostConnections?.destroy();
|
|
1218
|
+
hostController?.destroy();
|
|
1219
|
+
hostConnections = null;
|
|
1220
|
+
hostController = null; // останавливает и reconnect-петлю сигналинга
|
|
1221
|
+
hostRegistration = null;
|
|
1222
|
+
|
|
1223
|
+
// solo: хост крутится в этом же потоке — его таймеры переживут матч
|
|
1224
|
+
inlineHost?.destroy();
|
|
1225
|
+
inlineHost = null;
|
|
1226
|
+
|
|
1227
|
+
// запасной текст политического отказа: если сервер прислал причину сам
|
|
1228
|
+
// (TECH_INFORM перед закрытием — так приезжает 4006, полная комната), ниже
|
|
1229
|
+
// не пишется вообще ничего, и побеждает серверный текст
|
|
1230
|
+
const policyInform = POLICY_CLOSE_INFORMS[closeCode];
|
|
1231
|
+
|
|
1232
|
+
// терминальную причину закрытия (кик, полная комната) не затираем
|
|
1233
|
+
if (!terminalInformShown) {
|
|
1234
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
1235
|
+
policyInform ??
|
|
1236
|
+
(isLobbyMode
|
|
1237
|
+
? 'Host left — the room is closed. Returning to lobby…'
|
|
1238
|
+
: 'The match is over — connection to the host is closed.'),
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// в solo перезагружаться некуда: лобби нет, а матч поднимается с нуля.
|
|
1243
|
+
// В dedicated сервер жив — переподключение перезагрузкой уместно, но не
|
|
1244
|
+
// тогда, когда он сам нас и отбил по политике (network/policyClose.js)
|
|
1245
|
+
if (bootMode !== 'solo' && shouldReloadAfterClose(closeCode)) {
|
|
1246
|
+
setTimeout(() => location.reload(), 3000);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Внешний останов матча (standalone SDK, Этап 3 плана standalone-sdk).
|
|
1252
|
+
* Закрытие транспорта эмитит 'close' → handleDisconnect: тот снимает
|
|
1253
|
+
* рендер-луп, гасит inline-хост и освобождает звук/клавиатуру. Отдельного
|
|
1254
|
+
* teardown у SDK нет специально — путь останова один на все режимы.
|
|
1255
|
+
*/
|
|
1256
|
+
export function stopGame() {
|
|
1257
|
+
transport?.close();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// БУТСТРАП: лобби и установка P2P через мастер-сервер
|
|
1261
|
+
|
|
1262
|
+
let lobby = null;
|
|
1263
|
+
|
|
1264
|
+
// ресурсы роли хоста (комната в этой же вкладке)
|
|
1265
|
+
let hostController = null;
|
|
1266
|
+
let hostConnections = null;
|
|
1267
|
+
let hostHeartbeat = null;
|
|
1268
|
+
|
|
1269
|
+
// solo: авторитетный хост в главном потоке (без Worker'а)
|
|
1270
|
+
let inlineHost = null;
|
|
1271
|
+
|
|
1272
|
+
if (isDevBuild) {
|
|
1273
|
+
window.__vimpDebug = createDebugApi({
|
|
1274
|
+
getHostController: () => hostController,
|
|
1275
|
+
getClientCore: () => clientCore,
|
|
1276
|
+
reportUrl: lobbyConfig.debugReportUrl,
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
debugLog('window.__vimpDebug is available: dump, startRecording, stopRecording, divergence');
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
|
|
1283
|
+
// отсутствовать (media.peerconnection.enabled = false, resistFingerprinting,
|
|
1284
|
+
// приватные сборки) — честное сообщение вместо падения с чёрным экраном
|
|
1285
|
+
function ensureWebRtcAvailable() {
|
|
1286
|
+
if (typeof RTCPeerConnection !== 'undefined') {
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
1291
|
+
'WebRTC is unavailable in this browser: P2P play is impossible. ' +
|
|
1292
|
+
'In Firefox check that media.peerconnection.enabled is on.',
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// устанавливает P2P-соединение с выбранным хостом и уходит из лобби
|
|
1299
|
+
function connectToHost(hostId) {
|
|
1300
|
+
if (!ensureWebRtcAvailable()) {
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
currentHostId = hostId;
|
|
1305
|
+
|
|
1306
|
+
transport = new WebRtcManager(signaling, {
|
|
1307
|
+
iceServers: signaling.iceServers,
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
transport.publisher.on('message', handleMessage);
|
|
1311
|
+
transport.publisher.on('close', handleDisconnect);
|
|
1312
|
+
transport.connect(hostId);
|
|
1313
|
+
|
|
1314
|
+
lobby?.close();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// поднимает комнату в этой же вкладке (Worker хоста): хост-игрок играет через
|
|
1318
|
+
// loopback, удалённые клиенты — по WebRTC (answerer). Клиентский код одинаков,
|
|
1319
|
+
// отличается лишь транспорт. Выход хоста = смерть комнаты — как у клиента
|
|
1320
|
+
async function connectAsHost(room) {
|
|
1321
|
+
// фича-детект вместо classic-фолбэка (запретил бы ESM/инлайн WASM,
|
|
1322
|
+
// см. PLAN.md риск №5): честная ошибка, join остаётся доступен
|
|
1323
|
+
if (!supportsModuleWorker()) {
|
|
1324
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
1325
|
+
'This browser cannot be a host: ES module Web Workers are ' +
|
|
1326
|
+
'unsupported. You can still join existing rooms.',
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (!ensureWebRtcAvailable()) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// фактическая карта комнаты (из 'ready'; далее актуализируется map_changed)
|
|
1337
|
+
let currentMapName = null;
|
|
1338
|
+
|
|
1339
|
+
// отладочный контур (этап 6): рекордер живого матча и хостовый CONSOLE-лог
|
|
1340
|
+
// поднимаются только в dev-сборке
|
|
1341
|
+
room.isDevMode = isDevBuild;
|
|
1342
|
+
|
|
1343
|
+
// Этап 6.4: Worker грузит HostPlugin динамически по entries.host/entries.wasm
|
|
1344
|
+
// активной игры — движок больше не знает игру статически
|
|
1345
|
+
room.game = {
|
|
1346
|
+
id: activeGameManifest.id,
|
|
1347
|
+
version: activeGameManifest.version,
|
|
1348
|
+
hostEntryUrl: activeGameManifest.entries.host,
|
|
1349
|
+
wasmUrl: activeGameManifest.entries.wasm,
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
// Этап 5.1: комната стартует на актуальных картах мастера;
|
|
1353
|
+
// недоступность каталога некритична — Worker возьмёт карты из бандла
|
|
1354
|
+
try {
|
|
1355
|
+
const catalog = await fetchMasterMaps();
|
|
1356
|
+
|
|
1357
|
+
room.maps = catalog.maps;
|
|
1358
|
+
hostMapsVersion = catalog.version;
|
|
1359
|
+
} catch (e) {
|
|
1360
|
+
console.warn('[maps] master catalog unavailable, using bundled maps:', e);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Этап 5.2: Worker создаётся по манифесту мастера — бандл страницы после
|
|
1364
|
+
// деплоя исчезает из раздачи; без манифеста (dev) — бандловый URL,
|
|
1365
|
+
// обновления кода отключены
|
|
1366
|
+
let workerUrl = null;
|
|
1367
|
+
|
|
1368
|
+
try {
|
|
1369
|
+
const manifest = await fetchWorkerManifest();
|
|
1370
|
+
|
|
1371
|
+
// составной codeVersion (Этап 6.5): движок (worker-бандл) + игра
|
|
1372
|
+
// (id/version активного манифеста, с которым комната стартует)
|
|
1373
|
+
hostCodeVersion = {
|
|
1374
|
+
engine: manifest.version,
|
|
1375
|
+
game: { id: activeGameManifest.id, version: activeGameManifest.version },
|
|
1376
|
+
};
|
|
1377
|
+
workerUrl = manifest.url;
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
console.warn('[worker] master manifest unavailable, using bundled:', e);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
hostController = new HostController(room, {
|
|
1383
|
+
workerUrl,
|
|
1384
|
+
onReady: readyMsg => {
|
|
1385
|
+
currentMapName = readyMsg?.mapName;
|
|
1386
|
+
|
|
1387
|
+
// seed мира приезжает в 'ready' (этап 1): без него запись матча
|
|
1388
|
+
// невоспроизводима, поэтому он виден в консоли сразу
|
|
1389
|
+
if (isDevBuild) {
|
|
1390
|
+
debugLog('room ready', {
|
|
1391
|
+
map: currentMapName,
|
|
1392
|
+
seed: readyMsg?.seed,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// периодический heartbeat/актуализация комнаты у мастера
|
|
1397
|
+
const update = () =>
|
|
1398
|
+
signaling.updateHost({
|
|
1399
|
+
currentPlayers: 1 + (hostConnections?.peerCount || 0),
|
|
1400
|
+
mapName: currentMapName,
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
// регистрация комнаты; повторно вызывается при reconnect сигналинга
|
|
1404
|
+
hostRegistration = () => {
|
|
1405
|
+
signaling.registerHost({
|
|
1406
|
+
name: room.name,
|
|
1407
|
+
maxPlayers: room.maxPlayers,
|
|
1408
|
+
mapName: currentMapName,
|
|
1409
|
+
gameId: room.game.id,
|
|
1410
|
+
gameVersion: room.game.version,
|
|
1411
|
+
token: lobbyAuthModel.getToken(),
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
clearInterval(hostHeartbeat);
|
|
1415
|
+
hostHeartbeat = setInterval(
|
|
1416
|
+
update,
|
|
1417
|
+
lobbyConfig.create.heartbeatInterval,
|
|
1418
|
+
);
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
hostRegistration();
|
|
1422
|
+
},
|
|
1423
|
+
|
|
1424
|
+
// смена карты голосованием/таймером — сразу отразить в лобби мастера
|
|
1425
|
+
onMapChange: mapName => {
|
|
1426
|
+
currentMapName = mapName;
|
|
1427
|
+
signaling.updateHost({ mapName });
|
|
1428
|
+
},
|
|
1429
|
+
|
|
1430
|
+
// Worker не поднялся (WASM/конфиг): гасим комнату и возвращаемся в лобби
|
|
1431
|
+
onError: msg => {
|
|
1432
|
+
handleDisconnect();
|
|
1433
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
1434
|
+
`Failed to start the room: ${msg.message || 'unknown error'}. Returning to lobby…`,
|
|
1435
|
+
);
|
|
1436
|
+
},
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// удалённые клиенты по WebRTC; актуализация currentPlayers при их вход/выходе
|
|
1440
|
+
hostConnections = new HostConnectionManager(signaling, hostController, {
|
|
1441
|
+
iceServers: signaling.iceServers,
|
|
1442
|
+
onPeersChange: count => signaling.updateHost({ currentPlayers: 1 + count }),
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
// хост-игрок в этой же вкладке (socketId согласован с kick-исключением)
|
|
1446
|
+
transport = new LoopbackTransport(
|
|
1447
|
+
hostController,
|
|
1448
|
+
lobbyConfig.create.hostSocketId,
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
transport.publisher.on('message', handleMessage);
|
|
1452
|
+
transport.publisher.on('close', handleDisconnect);
|
|
1453
|
+
transport.connect();
|
|
1454
|
+
|
|
1455
|
+
// сигнальный WS хоста должен жить постоянно (офферы, heartbeat, выдача) —
|
|
1456
|
+
// при разрыве переподключаемся с бэкоффом; welcome вызовет re-register
|
|
1457
|
+
let reconnectAttempt = 0;
|
|
1458
|
+
|
|
1459
|
+
signaling.publisher.on('close', () => {
|
|
1460
|
+
if (!hostController) {
|
|
1461
|
+
return; // комната уже погашена
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const { baseDelay, maxDelay } = lobbyConfig.reconnect;
|
|
1465
|
+
const delay = Math.min(maxDelay, baseDelay * 2 ** reconnectAttempt);
|
|
1466
|
+
|
|
1467
|
+
reconnectAttempt += 1;
|
|
1468
|
+
setTimeout(() => signaling.connect(), delay);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
signaling.publisher.on('welcome', () => {
|
|
1472
|
+
reconnectAttempt = 0;
|
|
1473
|
+
hostRegistration?.();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
// мастер отвечает актуальными версиями каталога карт и worker-бандла:
|
|
1477
|
+
// расхождение (деплой, пока комната жила) — подтянуть каталог к следующей
|
|
1478
|
+
// смене карты / заменить Worker эстафетой на границе раунда (Этап 5.2)
|
|
1479
|
+
signaling.publisher.on('host_registered', msg => {
|
|
1480
|
+
// кодревью №1 (plan/server-rating/review.md): hostId + per-room секрет не
|
|
1481
|
+
// известны Worker'у до этого момента — прокидываем их, чтобы PlayerDataSync
|
|
1482
|
+
// атрибутировал последующие rank/state-flush к этой комнате (секрет
|
|
1483
|
+
// доказывает мастеру владение hostId)
|
|
1484
|
+
hostController?.setHostId(msg.hostId, msg.hostSecret);
|
|
1485
|
+
|
|
1486
|
+
if (msg.mapsVersion && msg.mapsVersion !== hostMapsVersion) {
|
|
1487
|
+
refreshHostMaps();
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
if (
|
|
1491
|
+
msg.codeVersion &&
|
|
1492
|
+
hostCodeVersion &&
|
|
1493
|
+
codeVersionChanged(msg.codeVersion, hostCodeVersion)
|
|
1494
|
+
) {
|
|
1495
|
+
refreshHostWorker();
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
// сигнал мастера об обновлении каталога карт/кода (hot-reload в будущем)
|
|
1500
|
+
signaling.publisher.on('update_available', msg => {
|
|
1501
|
+
if (!msg.mapsVersion || msg.mapsVersion !== hostMapsVersion) {
|
|
1502
|
+
refreshHostMaps();
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
if (
|
|
1506
|
+
msg.codeVersion &&
|
|
1507
|
+
hostCodeVersion &&
|
|
1508
|
+
codeVersionChanged(msg.codeVersion, hostCodeVersion)
|
|
1509
|
+
) {
|
|
1510
|
+
refreshHostWorker();
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
lobby?.close();
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// solo-режим: авторитетный матч в этом же потоке (standalone SDK). Ни
|
|
1518
|
+
// мастера, ни Worker'а, ни WebRTC — только inline-хост и loopback
|
|
1519
|
+
async function connectSolo() {
|
|
1520
|
+
const socketId = lobbyConfig.create.hostSocketId;
|
|
1521
|
+
|
|
1522
|
+
inlineHost = new InlineHostBridge(
|
|
1523
|
+
{
|
|
1524
|
+
name: 'solo',
|
|
1525
|
+
...boot.room,
|
|
1526
|
+
hostSocketId: socketId,
|
|
1527
|
+
// hostEntryUrl не нужен: HostPlugin приходит живым объектом
|
|
1528
|
+
game: {
|
|
1529
|
+
id: activeGameManifest.id,
|
|
1530
|
+
version: activeGameManifest.version,
|
|
1531
|
+
wasmUrl: activeGameManifest.entries.wasm,
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
{ hostPlugin: boot.hostPlugin },
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
// хендшейк начинается в connect() — хост к этому моменту обязан быть готов
|
|
1538
|
+
await inlineHost.ready;
|
|
1539
|
+
|
|
1540
|
+
transport = new LoopbackTransport(inlineHost, socketId);
|
|
1541
|
+
|
|
1542
|
+
transport.publisher.on('message', handleMessage);
|
|
1543
|
+
transport.publisher.on('close', handleDisconnect);
|
|
1544
|
+
transport.connect();
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// dedicated-режим: прямой WebSocket к Node-серверу игры
|
|
1548
|
+
function connectDedicated() {
|
|
1549
|
+
transport = new WebSocketTransport(boot.wsUrl);
|
|
1550
|
+
|
|
1551
|
+
transport.publisher.on('message', handleMessage);
|
|
1552
|
+
transport.publisher.on('close', handleDisconnect);
|
|
1553
|
+
transport.connect();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// версия каталога карт мастера, с которой поднята комната (Этап 5.1)
|
|
1557
|
+
let hostMapsVersion = null;
|
|
1558
|
+
|
|
1559
|
+
// составной codeVersion комнаты (Этап 5.2/6.5): { engine, game: { id, version } };
|
|
1560
|
+
// null — обновления кода отключены (манифест недоступен при старте)
|
|
1561
|
+
let hostCodeVersion = null;
|
|
1562
|
+
|
|
1563
|
+
// версия, своп на которую не удался — не ретраить её на каждом re-register
|
|
1564
|
+
let failedCodeVersion = null;
|
|
1565
|
+
|
|
1566
|
+
// сравнимый ключ составного codeVersion (Этап 6.5): движок + игра —
|
|
1567
|
+
// расхождение любой половины (деплой движка ИЛИ деплой игры) запускает эстафету
|
|
1568
|
+
function codeVersionKey(cv) {
|
|
1569
|
+
return cv
|
|
1570
|
+
? `${cv.engine ?? ''}:${cv.game?.id ?? ''}:${cv.game?.version ?? ''}`
|
|
1571
|
+
: null;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function codeVersionChanged(remote, local) {
|
|
1575
|
+
return codeVersionKey(remote) !== codeVersionKey(local);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// защита от параллельных эстафет Worker'ов
|
|
1579
|
+
let workerSwapInProgress = false;
|
|
1580
|
+
|
|
1581
|
+
// повторная регистрация комнаты у мастера (reconnect сигналинга)
|
|
1582
|
+
let hostRegistration = null;
|
|
1583
|
+
|
|
1584
|
+
// Этап 5.1/6.4: скачивает каталог карт мастера активной игры (манифест +
|
|
1585
|
+
// все карты)
|
|
1586
|
+
async function fetchMasterMaps() {
|
|
1587
|
+
const gameId = activeGameManifest.id;
|
|
1588
|
+
const manifestRes = await fetch(lobbyConfig.maps.manifestUrl(gameId));
|
|
1589
|
+
|
|
1590
|
+
if (!manifestRes.ok) {
|
|
1591
|
+
throw new Error(`maps manifest: HTTP ${manifestRes.status}`);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const manifest = await manifestRes.json();
|
|
1595
|
+
|
|
1596
|
+
const entries = await Promise.all(
|
|
1597
|
+
manifest.maps.map(async name => {
|
|
1598
|
+
const url = `${lobbyConfig.maps.baseUrl(gameId)}/${encodeURIComponent(name)}`;
|
|
1599
|
+
const res = await fetch(url);
|
|
1600
|
+
|
|
1601
|
+
if (!res.ok) {
|
|
1602
|
+
throw new Error(`map ${name}: HTTP ${res.status}`);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
return [name, await res.json()];
|
|
1606
|
+
}),
|
|
1607
|
+
);
|
|
1608
|
+
|
|
1609
|
+
return { version: manifest.version, maps: Object.fromEntries(entries) };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// перечитывает каталог карт мастера и передаёт в Worker:
|
|
1613
|
+
// применится со следующей смены карты (текущий раунд не трогается)
|
|
1614
|
+
async function refreshHostMaps() {
|
|
1615
|
+
try {
|
|
1616
|
+
const catalog = await fetchMasterMaps();
|
|
1617
|
+
|
|
1618
|
+
hostMapsVersion = catalog.version;
|
|
1619
|
+
hostController?.updateMaps(catalog.maps);
|
|
1620
|
+
} catch (e) {
|
|
1621
|
+
console.warn('[maps] refresh from master failed:', e);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// Этап 5.2: скачивает манифест worker-бандла мастера ({ version, url })
|
|
1626
|
+
async function fetchWorkerManifest() {
|
|
1627
|
+
const res = await fetch(lobbyConfig.worker.manifestUrl);
|
|
1628
|
+
|
|
1629
|
+
if (!res.ok) {
|
|
1630
|
+
throw new Error(`worker manifest: HTTP ${res.status}`);
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
return res.json();
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Этап 6.5: перечитывает манифест активной игры мастера — своп не должен
|
|
1637
|
+
// нести новому Worker'у закэшированный с момента создания комнаты
|
|
1638
|
+
// hostEntryUrl/wasmUrl (деплой игры мог обновиться независимо от движка)
|
|
1639
|
+
async function fetchGameManifest(gameId) {
|
|
1640
|
+
return fetchGamePluginManifest(lobbyConfig.game.manifestUrl(gameId));
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Этап 5.2/6.5: эстафета Worker'ов — новая версия кода (движка ИЛИ игры) у
|
|
1644
|
+
// мастера. Worker заменяется на границе раунда без разрыва P2P; сбой свопа
|
|
1645
|
+
// не смертелен — комната продолжает жить на прежней версии
|
|
1646
|
+
async function refreshHostWorker() {
|
|
1647
|
+
if (workerSwapInProgress || !hostController) {
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
workerSwapInProgress = true;
|
|
1652
|
+
|
|
1653
|
+
let manifest = null;
|
|
1654
|
+
let game = null;
|
|
1655
|
+
|
|
1656
|
+
try {
|
|
1657
|
+
manifest = await fetchWorkerManifest();
|
|
1658
|
+
const gameManifest = await fetchGameManifest(activeGameManifest.id);
|
|
1659
|
+
|
|
1660
|
+
game = {
|
|
1661
|
+
id: gameManifest.id,
|
|
1662
|
+
version: gameManifest.version,
|
|
1663
|
+
hostEntryUrl: gameManifest.entries.host,
|
|
1664
|
+
wasmUrl: gameManifest.entries.wasm,
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
const nextCodeVersion = {
|
|
1668
|
+
engine: manifest.version,
|
|
1669
|
+
game: { id: game.id, version: game.version },
|
|
1670
|
+
};
|
|
1671
|
+
const nextKey = codeVersionKey(nextCodeVersion);
|
|
1672
|
+
|
|
1673
|
+
if (
|
|
1674
|
+
!manifest.version ||
|
|
1675
|
+
!manifest.url ||
|
|
1676
|
+
nextKey === codeVersionKey(hostCodeVersion) ||
|
|
1677
|
+
nextKey === codeVersionKey(failedCodeVersion)
|
|
1678
|
+
) {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
await hostController.swapWorker(manifest.url, game);
|
|
1683
|
+
|
|
1684
|
+
hostCodeVersion = nextCodeVersion;
|
|
1685
|
+
failedCodeVersion = null;
|
|
1686
|
+
console.info(`[worker] room migrated to code version ${nextKey}`);
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
if (manifest?.version) {
|
|
1689
|
+
failedCodeVersion = { engine: manifest.version, game };
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
console.warn('[worker] swap to new version failed:', e);
|
|
1693
|
+
} finally {
|
|
1694
|
+
workerSwapInProgress = false;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// REST-запрос списка серверов у мастера (поиск игнорирует пагинацию)
|
|
1699
|
+
async function fetchServers({ offset, limit, search }) {
|
|
1700
|
+
const params = new URLSearchParams();
|
|
1701
|
+
|
|
1702
|
+
if (search) {
|
|
1703
|
+
params.set('search', search);
|
|
1704
|
+
} else {
|
|
1705
|
+
params.set('offset', offset);
|
|
1706
|
+
params.set('limit', limit);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
try {
|
|
1710
|
+
const res = await fetch(`${lobbyConfig.serversUrl}?${params}`);
|
|
1711
|
+
|
|
1712
|
+
return res.ok ? await res.json() : null;
|
|
1713
|
+
} catch (e) {
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// топ-N рейтинга игры (lobby-page-plan) — публичный эндпоинт, доступен и до
|
|
1719
|
+
// логина, поэтому без Authorization
|
|
1720
|
+
async function fetchLeaderboard(gameId) {
|
|
1721
|
+
const params = new URLSearchParams({
|
|
1722
|
+
game: gameId,
|
|
1723
|
+
limit: lobbyConfig.leaderboardLimit,
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
try {
|
|
1727
|
+
const res = await fetch(`${lobbyConfig.leaderboardUrl}?${params}`);
|
|
1728
|
+
|
|
1729
|
+
return res.ok ? await res.json() : null;
|
|
1730
|
+
} catch {
|
|
1731
|
+
return null;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// позиция вызывающего в рейтинге игры (lobby-page-plan) — требует identity-
|
|
1736
|
+
// токена, как и остальные /auth/* запросы игрока (rank/state)
|
|
1737
|
+
async function fetchPlacement(gameId) {
|
|
1738
|
+
const token = lobbyAuthModel.getToken();
|
|
1739
|
+
|
|
1740
|
+
if (!token) {
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
try {
|
|
1745
|
+
const res = await fetch(
|
|
1746
|
+
`${lobbyConfig.placementUrl}?${new URLSearchParams({ game: gameId })}`,
|
|
1747
|
+
{ headers: { authorization: `Bearer ${token}` } },
|
|
1748
|
+
);
|
|
1749
|
+
|
|
1750
|
+
return res.ok ? await res.json() : null;
|
|
1751
|
+
} catch {
|
|
1752
|
+
return null;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// code review M1: токен актуальности запроса — переключение игры быстрее
|
|
1757
|
+
// сетевого ответа не должно дать устаревшему ответу (например, от игры A)
|
|
1758
|
+
// затереть уже выбранную игру B, если её собственный ответ пришёл раньше
|
|
1759
|
+
let leaderboardReqId = 0;
|
|
1760
|
+
|
|
1761
|
+
// поля формы комнаты, сгенерированные по manifest.roomForm: key -> field
|
|
1762
|
+
let roomFormFields = new Map();
|
|
1763
|
+
|
|
1764
|
+
// генерирует форму создания комнаты по явной схеме манифеста (roomForm,
|
|
1765
|
+
// docs/en/plugin-api.md "Form schema") — движок не выводит контролы из типа
|
|
1766
|
+
// значения, схема плагина полностью описывает форму
|
|
1767
|
+
function populateRoomForm(manifest) {
|
|
1768
|
+
const container = document.getElementById(lobbyConfig.elems.fieldsId);
|
|
1769
|
+
|
|
1770
|
+
if (!container) {
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (Array.isArray(manifest.roomForm)) {
|
|
1775
|
+
const descriptors = mergeRoomDefaults(
|
|
1776
|
+
manifest.roomForm,
|
|
1777
|
+
manifest.roomDefaults,
|
|
1778
|
+
);
|
|
1779
|
+
|
|
1780
|
+
roomFormFields = buildForm(descriptors, container, {
|
|
1781
|
+
sources: { maps: manifest.maps?.list },
|
|
1782
|
+
});
|
|
1783
|
+
} else {
|
|
1784
|
+
// без явной схемы форма комнаты пустая — не выводим контролы из типа
|
|
1785
|
+
// значения (Часть 6 плана), но и не молчим об этом
|
|
1786
|
+
console.warn(
|
|
1787
|
+
`GameManifest "${manifest.id}" has no roomForm — room creation form will be empty`,
|
|
1788
|
+
);
|
|
1789
|
+
container.textContent = '';
|
|
1790
|
+
roomFormFields = new Map();
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// каталог манифестов по id (lobby-page-plan): достаточно для формы и
|
|
1795
|
+
// leaderboard селектора игр — активная игра для host остаётся
|
|
1796
|
+
// gamesManifest[0] (граница задачи, plan/lobby-page-plan.md)
|
|
1797
|
+
const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
|
|
1798
|
+
|
|
1799
|
+
// заполняет #lobby-game всем каталогом манифестов мастера (lobby-page-plan) —
|
|
1800
|
+
// раньше туда попадал только gamesManifest[0], теперь селектор рабочий
|
|
1801
|
+
function populateGameSelect() {
|
|
1802
|
+
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
1803
|
+
|
|
1804
|
+
if (!gameSelect) {
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
gamesManifest.forEach(manifest => {
|
|
1809
|
+
const option = document.createElement('option');
|
|
1810
|
+
|
|
1811
|
+
option.value = manifest.id;
|
|
1812
|
+
option.textContent = manifest.title;
|
|
1813
|
+
gameSelect.appendChild(option);
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
gameSelect.value = activeGameManifest.id;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// поднимает лобби после welcome от мастера (iceServers уже получены);
|
|
1820
|
+
// повторный welcome (reconnect сигналинга хоста) лобби не пересоздаёт
|
|
1821
|
+
function initLobby() {
|
|
1822
|
+
if (lobby) {
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
const lobbyModel = new LobbyModel(lobbyConfig);
|
|
1827
|
+
const lobbyView = new LobbyView(lobbyModel, lobbyConfig.elems);
|
|
1828
|
+
|
|
1829
|
+
lobbyView.setLeaderboardLimit(lobbyConfig.leaderboardLimit);
|
|
1830
|
+
|
|
1831
|
+
lobby = new LobbyCtrl(lobbyModel, lobbyView);
|
|
1832
|
+
|
|
1833
|
+
// список серверов — REST-запросом к мастеру
|
|
1834
|
+
lobbyModel.publisher.on('fetch', async query => {
|
|
1835
|
+
const list = await fetchServers(query);
|
|
1836
|
+
|
|
1837
|
+
if (list) {
|
|
1838
|
+
lobbyModel.setList(list, query.append);
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
// умный пинг видимого сервера — сигнальным путём (замер приблизительный)
|
|
1843
|
+
lobbyModel.publisher.on('ping-request', ({ hostId, pingId }) => {
|
|
1844
|
+
signaling.pingHost(hostId, pingId);
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
signaling.publisher.on('pong_host', msg => {
|
|
1848
|
+
lobbyModel.resolvePong(msg.pingId, performance.now());
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// выбор сервера → установка P2P
|
|
1852
|
+
lobbyModel.publisher.on('join', connectToHost);
|
|
1853
|
+
|
|
1854
|
+
// Leaderboard (lobby-page-plan): контроллер сигнализирует, для какой игры
|
|
1855
|
+
// нужны свежие данные (смена #lobby-game или первое открытие вкладки).
|
|
1856
|
+
// code review M1: сброс до fetch'а — иначе данные предыдущей игры видны
|
|
1857
|
+
// под заголовком новой, пока не пришёл ответ (и остаются навсегда при
|
|
1858
|
+
// сетевом сбое); reqId отбрасывает ответ устаревшего запроса, если игру
|
|
1859
|
+
// переключили быстрее, чем пришёл ответ (latest-wins)
|
|
1860
|
+
lobby.publisher.on('leaderboard-needed', async gameId => {
|
|
1861
|
+
lobbyModel.clearLeaderboard();
|
|
1862
|
+
|
|
1863
|
+
const reqId = ++leaderboardReqId;
|
|
1864
|
+
const [leaderboard, placement] = await Promise.all([
|
|
1865
|
+
fetchLeaderboard(gameId),
|
|
1866
|
+
fetchPlacement(gameId),
|
|
1867
|
+
]);
|
|
1868
|
+
|
|
1869
|
+
if (reqId !== leaderboardReqId) {
|
|
1870
|
+
return; // игру уже переключили ещё раз — этот ответ устарел
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (leaderboard) {
|
|
1874
|
+
lobbyModel.setLeaderboard(leaderboard);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
if (placement) {
|
|
1878
|
+
lobbyModel.setPlacement(placement);
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
// ник вызывающего (code review M4-остаток): нужен view, чтобы решить,
|
|
1883
|
+
// виден ли вызывающий уже в отрисованном топе Leaderboard, по членству в
|
|
1884
|
+
// списке, а не по числу placement (расходится с leaderboard.length при
|
|
1885
|
+
// ничьих на границе LIMIT). Ник неизменен на сессию — один вызов до
|
|
1886
|
+
// первого gameChanged/рендера Leaderboard
|
|
1887
|
+
lobbyView.setSelfNick(lobbyAuthModel.getNick());
|
|
1888
|
+
|
|
1889
|
+
// создание комнаты в этой же вкладке (хост-игрок через loopback)
|
|
1890
|
+
populateGameSelect();
|
|
1891
|
+
populateRoomForm(activeGameManifest);
|
|
1892
|
+
lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
|
|
1893
|
+
|
|
1894
|
+
// селектор игр (lobby-page-plan): работает с каталогом манифестов — меняет
|
|
1895
|
+
// форму создания комнаты и Leaderboard, но НЕ активную игру для host
|
|
1896
|
+
// (граница задачи, см. plan/lobby-page-plan.md)
|
|
1897
|
+
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
1898
|
+
|
|
1899
|
+
const hostBtn = document.getElementById(lobbyConfig.elems.hostBtnId);
|
|
1900
|
+
|
|
1901
|
+
gameSelect?.addEventListener('change', () => {
|
|
1902
|
+
const manifest = gamesById.get(gameSelect.value);
|
|
1903
|
+
|
|
1904
|
+
if (!manifest) {
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
populateRoomForm(manifest);
|
|
1909
|
+
lobby.gameChanged(manifest.id, manifest.title);
|
|
1910
|
+
|
|
1911
|
+
if (hostBtn) {
|
|
1912
|
+
const { disabled, title } = getHostGateState(manifest.id, activeGameManifest);
|
|
1913
|
+
|
|
1914
|
+
hostBtn.disabled = disabled;
|
|
1915
|
+
hostBtn.title = title;
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
const nameInput = document.getElementById(lobbyConfig.elems.nameId);
|
|
1920
|
+
|
|
1921
|
+
hostBtn?.addEventListener('click', () => {
|
|
1922
|
+
// защита в глубину поверх hostBtn.disabled (code review lobby-page): клик
|
|
1923
|
+
// не должен создать комнату с полями формы чужой игры, даже если
|
|
1924
|
+
// disabled почему-то не выставился/был обойдён
|
|
1925
|
+
if (gameSelect && getHostGateState(gameSelect.value, activeGameManifest).disabled) {
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// нативная валидация (pattern/required) — единственная граница
|
|
1930
|
+
// room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
|
|
1931
|
+
// (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
|
|
1932
|
+
// "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
|
|
1933
|
+
// (вызывается из host.worker.js при создании комнаты)
|
|
1934
|
+
if (
|
|
1935
|
+
!reportFormValidity(document.getElementById(lobbyConfig.elems.fieldsId))
|
|
1936
|
+
) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
const name =
|
|
1941
|
+
(nameInput?.value || '').trim() || lobbyConfig.create.defaultName;
|
|
1942
|
+
const { roomDefaults } = activeGameManifest;
|
|
1943
|
+
|
|
1944
|
+
// дефолты манифеста перекрываются значениями сгенерированных полей
|
|
1945
|
+
const overrides = { ...roomDefaults };
|
|
1946
|
+
|
|
1947
|
+
for (const [key, field] of roomFormFields) {
|
|
1948
|
+
overrides[key] = field.getValue();
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
connectAsHost({
|
|
1952
|
+
name,
|
|
1953
|
+
hostSocketId: lobbyConfig.create.hostSocketId,
|
|
1954
|
+
...overrides,
|
|
1955
|
+
});
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
lobby.open();
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// логин лобби (Этап B2): central auth-сервис выдаёт JWT, лобби открывается
|
|
1962
|
+
// только после успешной авторизации (глобальный ник вместо свободного ввода
|
|
1963
|
+
// в игре). Не зависит от сигнального сокета мастера — читает query string
|
|
1964
|
+
// (OAuth-редирект) и localStorage независимо от 'welcome'
|
|
1965
|
+
let lobbyAuthModel = null;
|
|
1966
|
+
let lobbyAuthView = null;
|
|
1967
|
+
|
|
1968
|
+
let welcomeReceived = false;
|
|
1969
|
+
let authenticated = false;
|
|
1970
|
+
|
|
1971
|
+
function maybeInitLobby() {
|
|
1972
|
+
if (welcomeReceived && authenticated) {
|
|
1973
|
+
initLobby();
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// точка 2 ветвления: лобби, OAuth-гейт и сигналинг живут только в lobby;
|
|
1978
|
+
// solo и dedicated идут сразу к транспорту
|
|
1979
|
+
if (isLobbyMode) {
|
|
1980
|
+
lobbyAuthModel = new LobbyAuthModel(authClientConfig);
|
|
1981
|
+
lobbyAuthView = new LobbyAuthView(lobbyAuthModel, authClientConfig);
|
|
1982
|
+
|
|
1983
|
+
const lobbyAuthCtrl = new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView);
|
|
1984
|
+
|
|
1985
|
+
signaling.publisher.on('welcome', () => {
|
|
1986
|
+
welcomeReceived = true;
|
|
1987
|
+
maybeInitLobby();
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
lobbyAuthModel.publisher.on('authenticated', () => {
|
|
1991
|
+
authenticated = true;
|
|
1992
|
+
maybeInitLobby();
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
if (lobbyAuthCtrl.init(window.location.search)) {
|
|
1996
|
+
window.history.replaceState(null, '', window.location.pathname);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
signaling.connect();
|
|
2000
|
+
} else if (bootMode === 'solo') {
|
|
2001
|
+
try {
|
|
2002
|
+
await connectSolo();
|
|
2003
|
+
} catch (e) {
|
|
2004
|
+
socketMethods[PS_TECH_INFORM_DATA](
|
|
2005
|
+
`Failed to start the match: ${e.message || 'unknown error'}`,
|
|
2006
|
+
);
|
|
2007
|
+
throw e;
|
|
2008
|
+
}
|
|
2009
|
+
} else {
|
|
2010
|
+
connectDedicated();
|
|
2011
|
+
}
|