vimp-engine 0.7.1 → 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/math.js +0 -2
- package/src/lib/offlinePlayerData.js +18 -0
- package/src/standalone/index.js +125 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vimp-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -16,19 +16,26 @@
|
|
|
16
16
|
"./lib/*": "./src/lib/*",
|
|
17
17
|
"./config/*": "./src/config/*",
|
|
18
18
|
"./host/*": "./src/host/*",
|
|
19
|
-
"./
|
|
19
|
+
"./client/*": "./src/client/*",
|
|
20
|
+
"./devtools/*": "./src/devtools/*",
|
|
21
|
+
"./standalone": "./src/standalone/index.js",
|
|
22
|
+
"./style.css": "./src/client/style.css"
|
|
20
23
|
},
|
|
21
24
|
"files": [
|
|
22
25
|
"src/lib",
|
|
23
26
|
"src/config",
|
|
24
27
|
"src/host",
|
|
28
|
+
"src/client",
|
|
29
|
+
"!src/client/_*",
|
|
30
|
+
"src/standalone",
|
|
25
31
|
"src/devtools",
|
|
26
32
|
"tests/fixtures",
|
|
27
33
|
"bin"
|
|
28
34
|
],
|
|
29
35
|
"scripts": {
|
|
30
36
|
"predev": "node scripts/sync-pixi-vendor.mjs",
|
|
31
|
-
"dev": "NODE_ENV=development nodemon src/master/main.js -w src/master -w src/lib -w src/config",
|
|
37
|
+
"dev": "NODE_ENV=development nodemon src/master/main.js -w src/master -w src/dedicated -w src/lib -w src/config",
|
|
38
|
+
"dedicated": "NODE_ENV=development nodemon src/master/main.js -w src/master -w src/dedicated -w src/host -w src/lib -w src/config",
|
|
32
39
|
"start": "NODE_ENV=production node --env-file ../../.env src/master/main.js",
|
|
33
40
|
"prebuild": "node scripts/sync-pixi-vendor.mjs",
|
|
34
41
|
"build": "vite build",
|
|
@@ -36,6 +43,7 @@
|
|
|
36
43
|
},
|
|
37
44
|
"dependencies": {
|
|
38
45
|
"express": "^5.1.0",
|
|
46
|
+
"howler": "^2.2.4",
|
|
39
47
|
"pixi.js": "8.19.0",
|
|
40
48
|
"uuid": "^13.0.0",
|
|
41
49
|
"vite-express": "^0.21.1",
|
|
@@ -43,7 +51,6 @@
|
|
|
43
51
|
},
|
|
44
52
|
"devDependencies": {
|
|
45
53
|
"esbuild": "0.27.7",
|
|
46
|
-
"howler": "^2.2.4",
|
|
47
54
|
"nodemon": "^3.1.10",
|
|
48
55
|
"vite": "^7.1.11",
|
|
49
56
|
"vite-plugin-pug": "^0.4.1"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Publisher from '../lib/Publisher.js';
|
|
2
|
+
|
|
3
|
+
let inputListener;
|
|
4
|
+
|
|
5
|
+
export default class InputListener {
|
|
6
|
+
constructor() {
|
|
7
|
+
if (inputListener) {
|
|
8
|
+
return inputListener;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
inputListener = this;
|
|
12
|
+
|
|
13
|
+
this.publisher = new Publisher();
|
|
14
|
+
|
|
15
|
+
window.addEventListener('keydown', event =>
|
|
16
|
+
this.publisher.emit('keyDown', event),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
window.addEventListener('keyup', event =>
|
|
20
|
+
this.publisher.emit('keyUp', event),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const emitMouse = () => this.publisher.emit('mouseAction');
|
|
24
|
+
|
|
25
|
+
window.addEventListener('mouseup', emitMouse);
|
|
26
|
+
window.addEventListener('mousedown', emitMouse);
|
|
27
|
+
window.addEventListener('mousemove', emitMouse);
|
|
28
|
+
|
|
29
|
+
window.addEventListener('resize', () =>
|
|
30
|
+
this.publisher.emit('resize', {
|
|
31
|
+
width: innerWidth,
|
|
32
|
+
height: innerHeight,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { Howl, Howler } from 'howler';
|
|
2
|
+
|
|
3
|
+
// глобальный лимит звуков
|
|
4
|
+
const WORLD_VOICE_LIMIT = 30;
|
|
5
|
+
|
|
6
|
+
// минимальная дистанция для панорамирования
|
|
7
|
+
const MIN_SPATIAL_DISTANCE = 1;
|
|
8
|
+
|
|
9
|
+
// настройки пространственного звука
|
|
10
|
+
const PANNER_SETTINGS = {
|
|
11
|
+
panningModel: 'HRTF', // модель панорамирования
|
|
12
|
+
distanceModel: 'inverse', // модель затухания звука с расстоянием
|
|
13
|
+
refDistance: 150, // расстояние (в px), на котором громкость равна 100%
|
|
14
|
+
maxDistance: 1000, // расстояние, дальше которого звук не слышен
|
|
15
|
+
rolloffFactor: 1, // коэффициент затухания (больше - быстрее затухает)
|
|
16
|
+
coneInnerAngle: 360, // звук распространяется во все стороны одинаково
|
|
17
|
+
coneOuterAngle: 0,
|
|
18
|
+
coneOuterGain: 0,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @class SoundManager
|
|
23
|
+
* @description Централизованный "режиссёр звука". Управляет загрузкой,
|
|
24
|
+
* виртуализацией и воспроизведением всех звуков в игре. Использует систему
|
|
25
|
+
* приоритетов и глобальный лимит голосов для предотвращения перегрузки
|
|
26
|
+
* аудио-движка и обеспечения того, чтобы самые важные звуки всегда были слышны.
|
|
27
|
+
*/
|
|
28
|
+
export default class SoundManager {
|
|
29
|
+
constructor() {
|
|
30
|
+
// хранение загруженных звуков { sound, config }
|
|
31
|
+
this._sounds = new Map();
|
|
32
|
+
|
|
33
|
+
// хранит соответствие soundId -> { sound, ownerId, loop }
|
|
34
|
+
this._activeInstances = new Map();
|
|
35
|
+
|
|
36
|
+
// реестр всех зарегистрированных звуковых источников
|
|
37
|
+
this._registeredSounds = new Map();
|
|
38
|
+
|
|
39
|
+
// позиция слушателя
|
|
40
|
+
this._listenerX = 0;
|
|
41
|
+
this._listenerY = 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Производит базовую настройку Howler, асинхронно загружает все звуки,
|
|
46
|
+
* определенные в конфигурации.
|
|
47
|
+
* Сохраняет как сам экземпляр Howl, так и его конфигурацию (приоритет).
|
|
48
|
+
* @param {object} soundsConfig - Объект конфигурации звуков.
|
|
49
|
+
* @param {string[]} soundsConfig.codecList - Список поддерживаемых кодеков
|
|
50
|
+
* (['webm', 'mp3']).
|
|
51
|
+
* @param {string} soundsConfig.path - Путь к директории со звуками.
|
|
52
|
+
* @param {object} soundsConfig.sounds - Словарь, где ключ - имя звука,
|
|
53
|
+
* а значение - объект конфигурации { file, priority, loop, volume }.
|
|
54
|
+
* @returns {Promise<void>} Promise, который разрешается после загрузки.
|
|
55
|
+
*/
|
|
56
|
+
async init(soundsConfig) {
|
|
57
|
+
const { codecList, path, sounds } = soundsConfig;
|
|
58
|
+
const supportedCodec = codecList.find(codec => Howler.codecs(codec));
|
|
59
|
+
|
|
60
|
+
Howler.usingWebAudio = true;
|
|
61
|
+
Howler.autoSuspend = false;
|
|
62
|
+
Howler.pos(0, 0, 0);
|
|
63
|
+
Howler.volume(0.7);
|
|
64
|
+
|
|
65
|
+
// устанавливает ориентацию (направление взгляда) слушателя.
|
|
66
|
+
// вектор "вперед" (0, 0, -1) - соответствует верху экрана
|
|
67
|
+
// вектор "вверх" (0, 1, 0) - вектор "вверх" для аудиосистемы
|
|
68
|
+
Howler.orientation(0, 0, -1, 0, 1, 0);
|
|
69
|
+
|
|
70
|
+
if (!supportedCodec) {
|
|
71
|
+
console.error(`No supported audio codec found from: ${codecList}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const loadingPromises = Object.entries(sounds).map(
|
|
76
|
+
([soundName, soundData]) => {
|
|
77
|
+
const fileName = soundData.file;
|
|
78
|
+
const loop = !!soundData.loop;
|
|
79
|
+
const volume = soundData.volume ?? 0.5;
|
|
80
|
+
const url = `${path}${fileName}.${supportedCodec}`;
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const soundInstance = new Howl({
|
|
84
|
+
src: [url],
|
|
85
|
+
preload: true,
|
|
86
|
+
html5: false,
|
|
87
|
+
loop,
|
|
88
|
+
volume,
|
|
89
|
+
onload: () => {
|
|
90
|
+
this._sounds.set(soundName, {
|
|
91
|
+
sound: soundInstance.pannerAttr(PANNER_SETTINGS),
|
|
92
|
+
config: { ...soundData, priority: soundData.priority ?? 50 },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
resolve(soundName);
|
|
96
|
+
},
|
|
97
|
+
onloaderror: (_id, error) => {
|
|
98
|
+
reject({
|
|
99
|
+
message: `Error loading "${soundName}" from ${url}`,
|
|
100
|
+
error,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Promise.allSettled, чтобы сбой загрузки одного файла
|
|
109
|
+
// не прерывал загрузку остальных
|
|
110
|
+
const results = await Promise.allSettled(loadingPromises);
|
|
111
|
+
|
|
112
|
+
results.forEach(result => {
|
|
113
|
+
if (result.status === 'rejected') {
|
|
114
|
+
console.error(result.reason.message, result.reason.error);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Возвращает конфигурацию для указанного звука.
|
|
121
|
+
* @param {string} soundName - Имя звука.
|
|
122
|
+
* @returns {object | undefined} Конфигурация звука или undefined.
|
|
123
|
+
*/
|
|
124
|
+
getSoundConfig(soundName) {
|
|
125
|
+
return this._sounds.get(soundName)?.config;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Устанавливает позицию слушателя (игрока) в 2D-пространстве для
|
|
130
|
+
* корректного расчета 3D-звука.
|
|
131
|
+
* @param {number} x - Координата X слушателя.
|
|
132
|
+
* @param {number} y - Координата Y слушателя.
|
|
133
|
+
*/
|
|
134
|
+
setListenerPosition(x, y) {
|
|
135
|
+
this._listenerX = x;
|
|
136
|
+
this._listenerY = y;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Воспроизводит системный/UI звук немедленно, в обход системы приоритетов.
|
|
141
|
+
* Следует использовать для критически важных звуков, не относящихся к
|
|
142
|
+
* игровому миру (например звук начала раунда).
|
|
143
|
+
* @param {string} soundName - Имя звука для воспроизведения.
|
|
144
|
+
*/
|
|
145
|
+
playSystemSound(soundName) {
|
|
146
|
+
this._sounds.get(soundName)?.sound.play();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Регистрирует звук.
|
|
151
|
+
* Возвращает уникальный ID для управления этим источником.
|
|
152
|
+
* @param {string} soundName - Имя звука (ключ из файла sounds.js).
|
|
153
|
+
* @param {object} data - Начальные параметры звука.
|
|
154
|
+
* @param {object} data.position - { x: number, y: number }.
|
|
155
|
+
* @param {number} [data?.rate] - Скорость воспроизведения.
|
|
156
|
+
* @param {number} [data?.volume] - Громкость.
|
|
157
|
+
* @param {function} [callback] - Функция, вызываемая по завершении.
|
|
158
|
+
* @returns {symbol | null} Уникальный ID звука или null, если звук не найден.
|
|
159
|
+
*/
|
|
160
|
+
registerSound(soundName, data, callback) {
|
|
161
|
+
const soundData = this._sounds.get(soundName);
|
|
162
|
+
|
|
163
|
+
if (!soundData) {
|
|
164
|
+
console.warn(`SoundManager: Sound "${soundName}" does not exist.`);
|
|
165
|
+
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const id = Symbol(soundName);
|
|
170
|
+
const registration = {
|
|
171
|
+
...soundData.config,
|
|
172
|
+
...data,
|
|
173
|
+
sound: soundData.sound,
|
|
174
|
+
id,
|
|
175
|
+
activeSoundId: null, // ID от Howler, когда звук будет играть
|
|
176
|
+
callback,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
this._registeredSounds.set(id, registration);
|
|
180
|
+
|
|
181
|
+
return id;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Снимает звук с регистрации. Если он играет, он будет остановлен.
|
|
186
|
+
* @param {symbol} id - ID, полученный от `registerSound`.
|
|
187
|
+
*/
|
|
188
|
+
unregisterSound(id) {
|
|
189
|
+
const sound = this._registeredSounds.get(id);
|
|
190
|
+
|
|
191
|
+
if (sound && sound.activeSoundId !== null) {
|
|
192
|
+
this._internalStop(sound.activeSoundId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this._registeredSounds.delete(id);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Снимает звук с регистрации, но даёт уже звучащему одноразовому сэмплу
|
|
200
|
+
* доиграть. Для сущностей, которые исчезают раньше своего звука
|
|
201
|
+
* (например, взорвавшаяся бомба).
|
|
202
|
+
* @param {symbol} id - ID, полученный от `registerSound`.
|
|
203
|
+
*/
|
|
204
|
+
releaseSound(id) {
|
|
205
|
+
const sound = this._registeredSounds.get(id);
|
|
206
|
+
|
|
207
|
+
if (!sound) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// луп обязан замолчать вместе с владельцем, one-shot — доиграть:
|
|
212
|
+
// updateActiveSounds() не-лупы не трогает, а обработчик 'end' сам
|
|
213
|
+
// подчистит _activeInstances
|
|
214
|
+
if (sound.loop && sound.activeSoundId !== null) {
|
|
215
|
+
this._internalStop(sound.activeSoundId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this._registeredSounds.delete(id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Обновляет параметры зарегистрированного звука.
|
|
223
|
+
* @param {symbol} id - ID, полученный от `registerSound`.
|
|
224
|
+
* @param {object} data - Новые параметры.
|
|
225
|
+
*/
|
|
226
|
+
updateSoundData(id, data) {
|
|
227
|
+
const sound = this._registeredSounds.get(id);
|
|
228
|
+
|
|
229
|
+
if (sound) {
|
|
230
|
+
Object.assign(sound, data);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Главный метод - "режиссёр", мозг всей звуковой системы.
|
|
236
|
+
* Анализирует все существующие и заявленные звуки, пересчитывает их важность
|
|
237
|
+
* на основе приоритета и расстояния, и решает, какие из них должны
|
|
238
|
+
* звучать в данный момент, соблюдая глобальный лимит голосов.
|
|
239
|
+
* Вызывается один раз за кадр.
|
|
240
|
+
*/
|
|
241
|
+
processAudibility() {
|
|
242
|
+
const candidates = [];
|
|
243
|
+
const maxDistSquared =
|
|
244
|
+
PANNER_SETTINGS.maxDistance * PANNER_SETTINGS.maxDistance;
|
|
245
|
+
const { _listenerX: lx, _listenerY: ly } = this;
|
|
246
|
+
const deleteList = [];
|
|
247
|
+
|
|
248
|
+
// сбор и предварительный отсев кандидатов
|
|
249
|
+
for (const regSound of this._registeredSounds.values()) {
|
|
250
|
+
const dx = regSound.position.x - lx;
|
|
251
|
+
const dy = regSound.position.y - ly;
|
|
252
|
+
const distanceSquared = dx * dx + dy * dy;
|
|
253
|
+
|
|
254
|
+
// если звук слишком далеко и он одноразовый, то удаление
|
|
255
|
+
if (distanceSquared >= maxDistSquared && !regSound.loop) {
|
|
256
|
+
deleteList.push(regSound.id);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// расчет приоритета
|
|
261
|
+
const basePriority = regSound.priority;
|
|
262
|
+
|
|
263
|
+
// дистанция 1.0, если звук в той же точке,
|
|
264
|
+
// чтобы избежать деления на ноль
|
|
265
|
+
regSound.priorityScore =
|
|
266
|
+
(basePriority * basePriority) / Math.max(distanceSquared, 1.0);
|
|
267
|
+
regSound.isPlaying = regSound.activeSoundId !== null;
|
|
268
|
+
|
|
269
|
+
candidates.push(regSound);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
deleteList.forEach(id => this._registeredSounds.delete(id));
|
|
273
|
+
|
|
274
|
+
// если кандидатов нет, то очистка одноразовых звуков и выход
|
|
275
|
+
if (candidates.length === 0) {
|
|
276
|
+
this._cleanupUnplayedOneShots();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// сортировка кандидатов по убыванию очков приоритета
|
|
281
|
+
candidates.sort((a, b) => b.priorityScore - a.priorityScore);
|
|
282
|
+
|
|
283
|
+
const audibleCandidates =
|
|
284
|
+
candidates.length > WORLD_VOICE_LIMIT
|
|
285
|
+
? candidates.slice(0, WORLD_VOICE_LIMIT)
|
|
286
|
+
: candidates;
|
|
287
|
+
|
|
288
|
+
const audibleSet = new Set(audibleCandidates);
|
|
289
|
+
|
|
290
|
+
// синхронизация и очистка
|
|
291
|
+
for (const candidate of candidates) {
|
|
292
|
+
const shouldBePlaying = audibleSet.has(candidate);
|
|
293
|
+
|
|
294
|
+
if (candidate.isPlaying) {
|
|
295
|
+
if (!shouldBePlaying) {
|
|
296
|
+
this._internalStop(candidate.activeSoundId);
|
|
297
|
+
candidate.activeSoundId = null;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
if (shouldBePlaying) {
|
|
301
|
+
const newSoundId = this._internalPlay(candidate);
|
|
302
|
+
|
|
303
|
+
if (newSoundId !== null) {
|
|
304
|
+
candidate.activeSoundId = newSoundId;
|
|
305
|
+
this._updateSpatialSound(
|
|
306
|
+
this._activeInstances.get(newSoundId)?.sound,
|
|
307
|
+
newSoundId,
|
|
308
|
+
candidate.position.x,
|
|
309
|
+
candidate.position.y,
|
|
310
|
+
candidate.volume,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// очистка несыгравших одноразовых звуков
|
|
318
|
+
this._cleanupUnplayedOneShots();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Обновляет параметры всех активных зацикленных звуков.
|
|
323
|
+
* Вызывается каждый кадр после `processAudibility`.
|
|
324
|
+
*/
|
|
325
|
+
updateActiveSounds() {
|
|
326
|
+
for (const [soundId, activeInstance] of this._activeInstances.entries()) {
|
|
327
|
+
if (!activeInstance.loop) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const regSound = this._registeredSounds.get(activeInstance.ownerId);
|
|
332
|
+
const { sound } = activeInstance;
|
|
333
|
+
|
|
334
|
+
if (!regSound) {
|
|
335
|
+
sound.stop(soundId);
|
|
336
|
+
this._activeInstances.delete(soundId);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const { position, volume, rate } = regSound;
|
|
341
|
+
|
|
342
|
+
this._updateSpatialSound(sound, soundId, position.x, position.y, volume);
|
|
343
|
+
|
|
344
|
+
if (typeof rate === 'number') {
|
|
345
|
+
sound.rate(rate, soundId);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @private Внутренний метод для воспроизведения звука через Howler.
|
|
352
|
+
*/
|
|
353
|
+
_internalPlay(candidate) {
|
|
354
|
+
const { sound, id, loop, callback } = candidate;
|
|
355
|
+
const soundId = sound.play();
|
|
356
|
+
|
|
357
|
+
if (typeof soundId !== 'number') {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this._activeInstances.set(soundId, {
|
|
362
|
+
sound,
|
|
363
|
+
ownerId: id,
|
|
364
|
+
loop,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
if (!loop) {
|
|
368
|
+
sound.once(
|
|
369
|
+
'end',
|
|
370
|
+
() => {
|
|
371
|
+
if (typeof callback === 'function') {
|
|
372
|
+
callback();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// по завершению удаляем из активных инстансов и из реестра
|
|
376
|
+
const regSound = this._registeredSounds.get(id);
|
|
377
|
+
|
|
378
|
+
if (regSound && regSound.activeSoundId === soundId) {
|
|
379
|
+
this._registeredSounds.delete(id);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this._activeInstances.delete(soundId);
|
|
383
|
+
},
|
|
384
|
+
soundId,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return soundId;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @private Внутренний метод для остановки экземпляра звука.
|
|
393
|
+
*/
|
|
394
|
+
_internalStop(soundId) {
|
|
395
|
+
const instanceData = this._activeInstances.get(soundId);
|
|
396
|
+
|
|
397
|
+
if (instanceData) {
|
|
398
|
+
instanceData.sound.stop(soundId);
|
|
399
|
+
this._activeInstances.delete(soundId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Обновляет громкость и 3D-позицию/панораму.
|
|
405
|
+
* @private
|
|
406
|
+
* @param {Howl} sound - Экземпляр Howl.
|
|
407
|
+
* @param {number} soundId - ID конкретного проигрываемого звука.
|
|
408
|
+
* @param {number} x - Координата X источника звука.
|
|
409
|
+
* @param {number} y - Координата Y источника звука.
|
|
410
|
+
* @param {number} volume - Громкость звука.
|
|
411
|
+
*/
|
|
412
|
+
_updateSpatialSound(sound, soundId, x, y, volume) {
|
|
413
|
+
if (!sound || typeof soundId !== 'number') {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const distance = Math.hypot(x - this._listenerX, y - this._listenerY);
|
|
418
|
+
|
|
419
|
+
if (distance >= PANNER_SETTINGS.maxDistance) {
|
|
420
|
+
sound.volume(0, soundId);
|
|
421
|
+
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
sound.volume(volume, soundId);
|
|
426
|
+
|
|
427
|
+
// если дистанция позволяет панорамировать звук
|
|
428
|
+
if (distance > MIN_SPATIAL_DISTANCE) {
|
|
429
|
+
sound.pos(x - this._listenerX, 0, y - this._listenerY, soundId);
|
|
430
|
+
} else {
|
|
431
|
+
sound.pos(0, 0, 0, soundId); // отключение панорамирования
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* @private
|
|
437
|
+
* Удаляет из реестра одноразовые звуки, которые были заявлены,
|
|
438
|
+
* но не попали в лимит воспроизведения в текущем кадре.
|
|
439
|
+
*/
|
|
440
|
+
_cleanupUnplayedOneShots() {
|
|
441
|
+
for (const [id, regSound] of this._registeredSounds.entries()) {
|
|
442
|
+
if (!regSound.loop && regSound.activeSoundId === null) {
|
|
443
|
+
this._registeredSounds.delete(id);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Выключает все звуки.
|
|
450
|
+
*/
|
|
451
|
+
mute() {
|
|
452
|
+
Howler.mute(true);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Включает все звуки после выключения.
|
|
457
|
+
*/
|
|
458
|
+
unmute() {
|
|
459
|
+
Howler.mute(false);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Останавливает все играющие звуки и
|
|
464
|
+
* сбрасывает внутреннее состояние "режиссёра".
|
|
465
|
+
* Используется при смене карты или полной перезагрузке.
|
|
466
|
+
*/
|
|
467
|
+
reset() {
|
|
468
|
+
Howler.stop();
|
|
469
|
+
this._activeInstances.clear();
|
|
470
|
+
|
|
471
|
+
// луп переживает reset: его владелец жив, и ближайший
|
|
472
|
+
// processAudibility() запустит звук заново. Одноразовый — нет:
|
|
473
|
+
// Howler.stop() не шлёт 'end', регистрация сыгравшего сэмпла осталась
|
|
474
|
+
// бы в реестре и прозвучала бы второй раз с начала
|
|
475
|
+
for (const [id, regSound] of this._registeredSounds.entries()) {
|
|
476
|
+
if (regSound.loop) {
|
|
477
|
+
regSound.activeSoundId = null;
|
|
478
|
+
} else {
|
|
479
|
+
this._registeredSounds.delete(id);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
this._listenerX = 0;
|
|
484
|
+
this._listenerY = 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Полностью выгружает все загруженные звуки из памяти.
|
|
489
|
+
* Следует вызывать при закрытии вкладки или
|
|
490
|
+
* полном завершении работы приложения.
|
|
491
|
+
*/
|
|
492
|
+
destroy() {
|
|
493
|
+
this.reset();
|
|
494
|
+
this._registeredSounds.clear();
|
|
495
|
+
Howler.unload();
|
|
496
|
+
this._sounds.clear();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Режим загрузки клиента движка (Этап 2 плана standalone-sdk). Клиент один
|
|
2
|
+
// на все контуры, а различает их этот модуль:
|
|
3
|
+
//
|
|
4
|
+
// lobby — прод: каталог игр и сигналинг мастера, лобби за OAuth-гейтом;
|
|
5
|
+
// solo — standalone SDK: хост в этой же вкладке (inline, без Worker'а),
|
|
6
|
+
// мастера нет вовсе;
|
|
7
|
+
// dedicated — Node-сервер: прямой WebSocket к игровому процессу.
|
|
8
|
+
//
|
|
9
|
+
// Канал передачи конфига от SDK к main.js — модульное состояние: и SDK, и
|
|
10
|
+
// main.js резолвят этот модуль в один экземпляр графа бандлера. Глобалов на
|
|
11
|
+
// window не вводим: они переживают перезагрузку бандла и ломают вторую
|
|
12
|
+
// вкладку SDK в том же документе.
|
|
13
|
+
|
|
14
|
+
// эндпоинт режима: его отдаёт dedicated-сервер, у мастера его нет —
|
|
15
|
+
// 404/сеть/мусор означают прод-лобби (обратная совместимость)
|
|
16
|
+
const CONFIG_URL = '/config';
|
|
17
|
+
|
|
18
|
+
// умолчание игрового WS dedicated-сервера: тот же хост, путь /game
|
|
19
|
+
const DEDICATED_WS_PATH = '/game';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Конфиг загрузки:
|
|
23
|
+
* {
|
|
24
|
+
* mode: 'lobby' | 'solo' | 'dedicated',
|
|
25
|
+
* container, // solo: точка монтирования каркаса и канвасов
|
|
26
|
+
* manifest, // solo: манифест-подобный объект в памяти
|
|
27
|
+
* clientPlugin, // solo: живой ClientPlugin (минуя dynamic import)
|
|
28
|
+
* hostPlugin, // solo: живой HostPlugin (для inline-хоста)
|
|
29
|
+
* room, // solo: переопределения комнаты (map/maxPlayers/seed/…)
|
|
30
|
+
* autoAuth, // solo: { name, model, … } → AUTH_RESPONSE без формы
|
|
31
|
+
* startupVotes, // solo: ответы на initialVote (выход из наблюдателей)
|
|
32
|
+
* startupCommands, // solo: чат-команды игры (спавн ботов)
|
|
33
|
+
* wsUrl, // dedicated: адрес игрового WS
|
|
34
|
+
* gameId, // dedicated: какую игру каталога считать активной
|
|
35
|
+
* }
|
|
36
|
+
*/
|
|
37
|
+
let bootConfig = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Задаёт конфиг загрузки. SDK вызывает его до import('./main.js') — main.js
|
|
41
|
+
* читает уже готовое состояние и сети не касается.
|
|
42
|
+
* @param {Object} cfg - Конфиг загрузки (см. выше).
|
|
43
|
+
*/
|
|
44
|
+
export function setBootConfig(cfg) {
|
|
45
|
+
bootConfig = cfg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Текущий конфиг загрузки (null, пока не задан и не разрешён).
|
|
50
|
+
* @returns {Object|null}
|
|
51
|
+
*/
|
|
52
|
+
export function getBootConfig() {
|
|
53
|
+
return bootConfig;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Сбрасывает состояние модуля. Нужен тестам: модульный синглтон переживает
|
|
58
|
+
* отдельный it().
|
|
59
|
+
*/
|
|
60
|
+
export function resetBootConfig() {
|
|
61
|
+
bootConfig = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Разрешает режим загрузки: инъекция SDK → GET /config → лобби.
|
|
66
|
+
* @param {Function} [fetchImpl] - Реализация fetch (тесты подставляют свою).
|
|
67
|
+
* @returns {Promise<Object>} Конфиг загрузки.
|
|
68
|
+
*/
|
|
69
|
+
export async function resolveBootConfig(fetchImpl) {
|
|
70
|
+
if (bootConfig) {
|
|
71
|
+
return bootConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const doFetch = fetchImpl ?? ((...args) => fetch(...args));
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const res = await doFetch(CONFIG_URL);
|
|
78
|
+
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
bootConfig = normalize(await res.json());
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// сети/эндпоинта нет — это и есть прод-лобби, шуметь не о чем
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
bootConfig = bootConfig ?? { mode: 'lobby' };
|
|
87
|
+
|
|
88
|
+
return bootConfig;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// приводит ответ /config к валидному конфигу; всё, что не dedicated, —
|
|
92
|
+
// лобби: неизвестный режим не должен уронить клиент в чёрный экран
|
|
93
|
+
function normalize(raw) {
|
|
94
|
+
if (!raw || typeof raw !== 'object' || raw.mode !== 'dedicated') {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...raw,
|
|
100
|
+
mode: 'dedicated',
|
|
101
|
+
// сервер объявляет свой путь в /config: константа — только умолчание
|
|
102
|
+
wsUrl: raw.wsUrl || defaultDedicatedWsUrl(raw.wsPath),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function defaultDedicatedWsUrl(wsPath) {
|
|
107
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
108
|
+
// пустой или отсутствующий путь — это «умолчание», а не корень: `''` дал бы
|
|
109
|
+
// ws://host, null — ws://hostnull
|
|
110
|
+
const path = wsPath || DEDICATED_WS_PATH;
|
|
111
|
+
|
|
112
|
+
return `${protocol}//${location.host}${path}`;
|
|
113
|
+
}
|