vimp-engine 0.10.2 → 0.11.1
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/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/InputListener.js +18 -0
- package/src/client/components/controller/CanvasManager.js +5 -0
- package/src/client/components/controller/Controls.js +5 -0
- package/src/client/components/model/CanvasManager.js +8 -0
- package/src/client/components/model/Controls.js +118 -0
- package/src/client/components/model/Game.js +6 -0
- package/src/client/components/model/Lobby.js +7 -3
- package/src/client/components/view/CanvasManager.js +33 -0
- package/src/client/lib/gameActivator.js +38 -0
- package/src/client/lib/localPlayer.js +37 -0
- package/src/client/main.js +149 -45
- package/src/client/style.css +8 -0
- package/src/client/views/gameShell.js +3 -0
- package/src/client/views/includes/lobby.pug +5 -0
- package/src/config/lobby.js +5 -3
- package/src/config/master.js +10 -3
- package/src/devtools/ScenarioRunner.js +17 -0
- package/src/devtools/contract/rules/b7-chat-commands.js +6 -10
- package/src/devtools/contract/rules/c4-component-dependencies.js +2 -2
- package/src/host/DebugRecorder.js +4 -0
- package/src/host/GameCoreAdapter.js +8 -0
- package/src/host/HostGame.js +32 -3
- package/src/host/PortMachine.js +1 -1
- package/src/host/meta/core/CommandProcessor.js +16 -68
- package/tests/fixtures/miniGame/host/fakeCore.js +17 -0
- package/src/client/lib/hostGate.js +0 -16
package/core/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vimp-engine-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
|
@@ -26,6 +26,24 @@ export default class InputListener {
|
|
|
26
26
|
window.addEventListener('mousedown', emitMouse);
|
|
27
27
|
window.addEventListener('mousemove', emitMouse);
|
|
28
28
|
|
|
29
|
+
// указатель: один набор Pointer Events закрывает мышь, палец и стилус —
|
|
30
|
+
// это и есть поддержка смартфона. Экранные координаты сырые: в мировые
|
|
31
|
+
// их переводит CanvasManager, у которого камера и масштаб полотна.
|
|
32
|
+
// Безаргументный mouseAction выше остаётся: на нём висит скрытие курсора
|
|
33
|
+
const emitPointer = type => event =>
|
|
34
|
+
this.publisher.emit('pointerAction', {
|
|
35
|
+
type,
|
|
36
|
+
x: event.clientX,
|
|
37
|
+
y: event.clientY,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
window.addEventListener('pointerdown', emitPointer('down'));
|
|
41
|
+
window.addEventListener('pointermove', emitPointer('move'));
|
|
42
|
+
window.addEventListener('pointerup', emitPointer('up'));
|
|
43
|
+
// палец, уведённый системным жестом, up не даёт — без этого змейка
|
|
44
|
+
// осталась бы разогнанной с «прижатым» указателем
|
|
45
|
+
window.addEventListener('pointercancel', emitPointer('up'));
|
|
46
|
+
|
|
29
47
|
window.addEventListener('resize', () =>
|
|
30
48
|
this.publisher.emit('resize', {
|
|
31
49
|
width: innerWidth,
|
|
@@ -19,6 +19,11 @@ export default class CanvasManagerCtrl {
|
|
|
19
19
|
this._model.updateCoords(x, y, cameraReset, shakeData);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// экранная точка указателя -> мировая (в координатах игрового полотна)
|
|
23
|
+
toWorld(clientX, clientY) {
|
|
24
|
+
return this._view.toWorld(this._model.pointerCanvasId, clientX, clientY);
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
// обновляет размеры
|
|
23
28
|
resize(data) {
|
|
24
29
|
this._model.resize(data);
|
|
@@ -24,6 +24,11 @@ export default class ControlsCtrl {
|
|
|
24
24
|
this._model.removeKey(event);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// добавляет ввод указателем (мышь/палец)
|
|
28
|
+
addPointer(data) {
|
|
29
|
+
this._model.addPointer(data);
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
// задает текущий режим
|
|
28
33
|
switchMode(data) {
|
|
29
34
|
this._model.setMode(data.name, data.status);
|
|
@@ -60,6 +60,10 @@ export default class CanvasManagerModel {
|
|
|
60
60
|
|
|
61
61
|
const canvases = data.canvases || {};
|
|
62
62
|
|
|
63
|
+
// полотно, в системе координат которого считается указатель: имя из
|
|
64
|
+
// конфига либо первое объявленное (у игры оно, как правило, одно)
|
|
65
|
+
this._pointerCanvasId = data.pointerCanvas || Object.keys(canvases)[0];
|
|
66
|
+
|
|
63
67
|
for (const canvasName in canvases) {
|
|
64
68
|
if (Object.hasOwn(canvases, canvasName)) {
|
|
65
69
|
const canvasData = canvases[canvasName];
|
|
@@ -81,6 +85,10 @@ export default class CanvasManagerModel {
|
|
|
81
85
|
this.publisher = new Publisher();
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
get pointerCanvasId() {
|
|
89
|
+
return this._pointerCanvasId;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
// рассчитывает размеры элементов с учетом пропорций
|
|
85
93
|
resize(data) {
|
|
86
94
|
const screenWidth = data.width;
|
|
@@ -16,11 +16,32 @@ export default class ControlsModel {
|
|
|
16
16
|
this._modes = data.modes;
|
|
17
17
|
this._cmds = data.cmds;
|
|
18
18
|
|
|
19
|
+
this._currentKeySetIndex = 0; // индекс текущего набора клавиш
|
|
19
20
|
this._currentKeySet = this._keySetList[0]; // текущий набор клавиш
|
|
20
21
|
this._currentModes = {}; // статусы режимов
|
|
21
22
|
this._pressedKeys = {}; // объект для хранения состояния зажатых клавиш
|
|
22
23
|
this._areKeysEnabled = false; // статус возможности нажатия клавиш
|
|
23
24
|
|
|
25
|
+
// канал указателя: игра, не объявившая modules.controls.pointer, его не
|
|
26
|
+
// получает вовсе — ни одного лишнего сообщения по проводу
|
|
27
|
+
this._pointer = data.pointer
|
|
28
|
+
? {
|
|
29
|
+
doubleTapMs: data.pointer.doubleTapMs ?? 300,
|
|
30
|
+
doubleTapPx: data.pointer.doubleTapPx ?? 40,
|
|
31
|
+
sendIntervalMs: data.pointer.sendIntervalMs ?? 50,
|
|
32
|
+
// индексы наборов клавиш, в которых указатель живой; по умолчанию
|
|
33
|
+
// все (у наблюдателя рулить нечем, но это дело игры)
|
|
34
|
+
keySets: data.pointer.keySets ?? null,
|
|
35
|
+
}
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
this._isPointerDown = false; // указатель прижат
|
|
39
|
+
this._isPointerDoubled = false; // нажатие было вторым в двойном тапе
|
|
40
|
+
this._lastTapTime = 0;
|
|
41
|
+
this._lastTapX = 0;
|
|
42
|
+
this._lastTapY = 0;
|
|
43
|
+
this._lastAimTime = 0;
|
|
44
|
+
|
|
24
45
|
this.publisher = new Publisher();
|
|
25
46
|
}
|
|
26
47
|
|
|
@@ -101,20 +122,117 @@ export default class ControlsModel {
|
|
|
101
122
|
setMode(mode, status) {
|
|
102
123
|
if (status === 'opened') {
|
|
103
124
|
this._currentModes[mode] = true;
|
|
125
|
+
|
|
126
|
+
// иначе змейка так и ехала бы к последней точке, пока игрок набирает
|
|
127
|
+
// сообщение в чат
|
|
128
|
+
this._releasePointer(this._lastTapX, this._lastTapY);
|
|
104
129
|
} else if (status === 'closed') {
|
|
105
130
|
this._currentModes[mode] = false;
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
|
|
134
|
+
// живой ли сейчас канал указателя: те же правила, что и у клавиш —
|
|
135
|
+
// ввод разрешён, ни один режим не открыт, набор клавиш игровой
|
|
136
|
+
_isPointerLive() {
|
|
137
|
+
if (!this._pointer || this._areKeysEnabled === false) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
this._currentModes.chat ||
|
|
143
|
+
this._currentModes.stat ||
|
|
144
|
+
this._currentModes.vote
|
|
145
|
+
) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { keySets } = this._pointer;
|
|
150
|
+
|
|
151
|
+
return keySets === null || keySets.includes(this._currentKeySetIndex);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// отпускает указатель (чат открылся, набор сменился, палец ушёл)
|
|
155
|
+
_releasePointer(x, y) {
|
|
156
|
+
if (!this._isPointerDown) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this._isPointerDown = false;
|
|
161
|
+
this._isPointerDoubled = false;
|
|
162
|
+
this._lastAimTime = 0;
|
|
163
|
+
|
|
164
|
+
this.publisher.emit('aim', { x, y, flags: 0 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// добавляет ввод указателем (мышь/палец/стилус)
|
|
168
|
+
//
|
|
169
|
+
// Канал живёт от нажатия до отпускания: `move` без прижатого указателя не
|
|
170
|
+
// шлётся — иначе любое движение мыши перехватывало бы управление у клавиш
|
|
171
|
+
// и грузило провод. `flags`: бит 0 — прижат, бит 1 — двойной тап.
|
|
172
|
+
addPointer({ type, x, y }) {
|
|
173
|
+
if (!this._pointer) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!this._isPointerLive()) {
|
|
178
|
+
this._releasePointer(x, y);
|
|
179
|
+
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { doubleTapMs, doubleTapPx, sendIntervalMs } = this._pointer;
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
|
|
186
|
+
if (type === 'down') {
|
|
187
|
+
// dblclick тач-устройства не гарантируют — распознаём сами: два
|
|
188
|
+
// нажатия в пределах doubleTapMs и doubleTapPx
|
|
189
|
+
const dx = x - this._lastTapX;
|
|
190
|
+
const dy = y - this._lastTapY;
|
|
191
|
+
|
|
192
|
+
this._isPointerDoubled =
|
|
193
|
+
now - this._lastTapTime <= doubleTapMs &&
|
|
194
|
+
Math.sqrt(dx * dx + dy * dy) <= doubleTapPx;
|
|
195
|
+
|
|
196
|
+
this._isPointerDown = true;
|
|
197
|
+
this._lastTapTime = now;
|
|
198
|
+
this._lastTapX = x;
|
|
199
|
+
this._lastTapY = y;
|
|
200
|
+
} else if (type === 'up') {
|
|
201
|
+
this._releasePointer(x, y);
|
|
202
|
+
|
|
203
|
+
return;
|
|
204
|
+
} else if (!this._isPointerDown) {
|
|
205
|
+
return;
|
|
206
|
+
} else if (now - this._lastAimTime < sendIntervalMs) {
|
|
207
|
+
// move приходит с частотой опроса устройства; по проводу он идёт не
|
|
208
|
+
// чаще sendIntervalMs
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this._lastAimTime = now;
|
|
213
|
+
|
|
214
|
+
this.publisher.emit('aim', {
|
|
215
|
+
x,
|
|
216
|
+
y,
|
|
217
|
+
flags: 1 | (this._isPointerDoubled ? 2 : 0),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
109
221
|
// меняет набор клавиш
|
|
110
222
|
changeKeySet(key) {
|
|
223
|
+
this._currentKeySetIndex = key;
|
|
111
224
|
this._currentKeySet = this._keySetList[key];
|
|
112
225
|
this._pressedKeys = {};
|
|
226
|
+
this._releasePointer(this._lastTapX, this._lastTapY);
|
|
113
227
|
}
|
|
114
228
|
|
|
115
229
|
// задаёт возможность нажатия клавиш
|
|
116
230
|
// (stat и chat доступны при любом значении)
|
|
117
231
|
setKeysEnabled(isEnabled) {
|
|
118
232
|
this._areKeysEnabled = isEnabled;
|
|
233
|
+
|
|
234
|
+
if (isEnabled === false) {
|
|
235
|
+
this._releasePointer(this._lastTapX, this._lastTapY);
|
|
236
|
+
}
|
|
119
237
|
}
|
|
120
238
|
}
|
|
@@ -30,6 +30,10 @@ export default class GameModel {
|
|
|
30
30
|
data,
|
|
31
31
|
this._assets.get(constructor),
|
|
32
32
|
this._dependencies.get(constructor),
|
|
33
|
+
// контекст экземпляра: id той сущности, которую рисует part. Игра
|
|
34
|
+
// сравнивает его со своим gameId (сервис localPlayer) - без этого part
|
|
35
|
+
// не может отличить своего персонажа от чужого
|
|
36
|
+
{ id },
|
|
33
37
|
);
|
|
34
38
|
|
|
35
39
|
this._data[constructor] = this._data[constructor] || {};
|
|
@@ -47,6 +51,8 @@ export default class GameModel {
|
|
|
47
51
|
data,
|
|
48
52
|
this._assets.get(constructor),
|
|
49
53
|
this._dependencies.get(constructor),
|
|
54
|
+
// у эффекта нет id: запись кадра анонимна, живёт до конца анимации
|
|
55
|
+
{ id: null },
|
|
50
56
|
);
|
|
51
57
|
|
|
52
58
|
this._managedEffects[constructor] =
|
|
@@ -86,10 +86,14 @@ export default class LobbyModel {
|
|
|
86
86
|
this._emitList();
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
// пользователь выбрал сервер — во внешнего подписчика (P2P-подключение)
|
|
89
|
+
// пользователь выбрал сервер — во внешнего подписчика (P2P-подключение).
|
|
90
|
+
// gameId едет вместе с hostId: комната может быть по другой игре, и клиент
|
|
91
|
+
// обязан активировать её ClientPlugin до подключения
|
|
90
92
|
join(hostId) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const server = this._servers.get(hostId);
|
|
94
|
+
|
|
95
|
+
if (server) {
|
|
96
|
+
this.publisher.emit('join', { hostId, gameId: server.gameId });
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
|
|
@@ -30,6 +30,39 @@ export default class CanvasManagerView {
|
|
|
30
30
|
app.renderer.resize(sizes.width, sizes.height);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// переводит экранную точку (clientX/clientY указателя) в мировую
|
|
34
|
+
//
|
|
35
|
+
// Считается по фактическому состоянию сцены, а не по копии камеры: stage
|
|
36
|
+
// уже несёт и смещение, и масштаб последнего updateCoords, а
|
|
37
|
+
// getBoundingClientRect закрывает случай, когда CSS растянул полотно
|
|
38
|
+
// не один к одному с его буфером.
|
|
39
|
+
toWorld(id, clientX, clientY) {
|
|
40
|
+
const app = this._apps[id];
|
|
41
|
+
|
|
42
|
+
if (!app) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rect = app.canvas.getBoundingClientRect();
|
|
47
|
+
|
|
48
|
+
if (!rect.width || !rect.height) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const px = ((clientX - rect.left) * app.canvas.width) / rect.width;
|
|
53
|
+
const py = ((clientY - rect.top) * app.canvas.height) / rect.height;
|
|
54
|
+
const { position, scale } = app.stage;
|
|
55
|
+
|
|
56
|
+
if (!scale.x || !scale.y) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
x: (px - position.x) / scale.x,
|
|
62
|
+
y: (py - position.y) / scale.y,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
// вычисляет координаты для отображения и обновляет полотно
|
|
34
67
|
updateCoords({ id, coords, scale }) {
|
|
35
68
|
const app = this._apps[id];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Активная игра переключается в лобби на лету: ClientPlugin выбранной игры
|
|
2
|
+
// (или игры комнаты, в которую заходим) грузится в момент клика, а не один раз
|
|
3
|
+
// при бутстрапе. Это возможно потому, что всё пер-игровое состояние (Factory,
|
|
4
|
+
// Pixi-приложения, clientCore) появляется только на старте матча, а после
|
|
5
|
+
// матча lobby-режим перезагружает страницу — лобби всегда в чистом
|
|
6
|
+
// до-матчевом состоянии.
|
|
7
|
+
//
|
|
8
|
+
// Вынесено из main.js (бутстрап, тестами не покрывается) отдельным модулем,
|
|
9
|
+
// чтобы кеш и обработка отказа проверялись юнит-тестом.
|
|
10
|
+
export function createGameActivator({ gamesById, loadClientPlugin }) {
|
|
11
|
+
// gameId -> промис загрузки: кешируется именно промис, иначе два быстрых
|
|
12
|
+
// клика подряд запустили бы импорт дважды
|
|
13
|
+
const plugins = new Map();
|
|
14
|
+
|
|
15
|
+
return async function activateGame(gameId) {
|
|
16
|
+
const manifest = gamesById.get(gameId);
|
|
17
|
+
|
|
18
|
+
if (!manifest) {
|
|
19
|
+
throw new Error(`unknown game "${gameId}"`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let pending = plugins.get(gameId);
|
|
23
|
+
|
|
24
|
+
if (!pending) {
|
|
25
|
+
pending = loadClientPlugin(manifest).catch(e => {
|
|
26
|
+
// отказ не кешируем: сеть могла моргнуть, повторный клик обязан
|
|
27
|
+
// попробовать снова, а не переигрывать ту же ошибку вечно
|
|
28
|
+
plugins.delete(gameId);
|
|
29
|
+
|
|
30
|
+
throw e;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
plugins.set(gameId, pending);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { manifest, plugin: await pending };
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Сервис пула зависимостей `localPlayer`: отвечает part'у на один вопрос —
|
|
2
|
+
// «эта сущность моя или чужая?».
|
|
3
|
+
//
|
|
4
|
+
// Свой gameId знает только клиентское ядро (ClientCore.my_game_id()), а part
|
|
5
|
+
// получает id своего экземпляра четвёртым аргументом конструктора
|
|
6
|
+
// (GameModel.create → { id }). Свести одно с другим внутри игры было нечем:
|
|
7
|
+
// поэтому сервис общий, а не переизобретается каждой игрой заново.
|
|
8
|
+
//
|
|
9
|
+
// Читает id ЛЕНИВО, на каждый вызов: player-блок приходит только с первым
|
|
10
|
+
// бинарным кадром, а сущности создаются раньше — из FIRST_SHOT_DATA. Флаг,
|
|
11
|
+
// вычисленный в конструкторе part'а, был бы для своего же персонажа ложным.
|
|
12
|
+
//
|
|
13
|
+
// getCore — геттер ядра, а не само ядро: ядро создаётся позже пула сервисов и
|
|
14
|
+
// пересоздаётся на смене игры.
|
|
15
|
+
export function createLocalPlayer(getCore) {
|
|
16
|
+
return {
|
|
17
|
+
// свой gameId или null, пока player-блок не пришёл (ядро отдаёт -1)
|
|
18
|
+
get id() {
|
|
19
|
+
const id = getCore()?.my_game_id?.();
|
|
20
|
+
|
|
21
|
+
return typeof id === 'number' && id >= 0 ? id : null;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// id сущности приходит в part ключом объекта кадра, то есть СТРОКОЙ —
|
|
25
|
+
// сравнение приводит обе стороны к строке, иначе '3' !== 3 и звук своего
|
|
26
|
+
// персонажа не отличить от чужого
|
|
27
|
+
is(id) {
|
|
28
|
+
const my = this.id;
|
|
29
|
+
|
|
30
|
+
if (my === null || id === null || id === undefined) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return String(id) === String(my);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -31,11 +31,12 @@ import {
|
|
|
31
31
|
mergeRoomDefaults,
|
|
32
32
|
reportFormValidity,
|
|
33
33
|
} from './lib/formBuilder.js';
|
|
34
|
-
import {
|
|
34
|
+
import { createGameActivator } from './lib/gameActivator.js';
|
|
35
35
|
import createAutostart from './lib/autostart.js';
|
|
36
36
|
import { getBootConfig, resolveBootConfig } from './boot.js';
|
|
37
37
|
import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
|
|
38
38
|
import { createContextTracker } from './lib/contextTracker.js';
|
|
39
|
+
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
39
40
|
import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
|
|
40
41
|
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
41
42
|
import {
|
|
@@ -79,18 +80,36 @@ import lobbyConfig from '../config/lobby.js';
|
|
|
79
80
|
import authClientConfig from '../config/authClient.js';
|
|
80
81
|
import clientDefaults from '../config/clientDefaults.js';
|
|
81
82
|
|
|
82
|
-
// Динамическая загрузка игры по каталогу мастера (Этап 6.3):
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
83
|
+
// Динамическая загрузка игры по каталогу мастера (Этап 6.3): ClientPlugin
|
|
84
|
+
// (parts, bakers, игровой CSS, хуки ядра) грузится по entries.client манифеста
|
|
85
|
+
// — движок не импортирует игру статически. Первой активируется gamesManifest[0]
|
|
86
|
+
// (или boot.gameId), но активная игра не заморожена: выбор в #lobby-game и
|
|
87
|
+
// вход в чужую комнату переключают её через gameActivator (см. bindActiveGame).
|
|
88
|
+
// Переключение безопасно ровно до старта матча: всё пер-игровое состояние
|
|
89
|
+
// (Factory, Pixi-приложения, clientCore, звук) появляется только в CONFIG_DATA,
|
|
90
|
+
// а после матча lobby-режим перезагружает страницу
|
|
89
91
|
let activeGameManifest;
|
|
90
92
|
let clientPlugin;
|
|
91
|
-
let parts;
|
|
92
93
|
let gamesManifest;
|
|
93
94
|
|
|
95
|
+
// узел игрового CSS: при переключении игры его текст заменяется, иначе стили
|
|
96
|
+
// двух игр жили бы в head одновременно и конфликтовали селекторами
|
|
97
|
+
let gameStyleNode = null;
|
|
98
|
+
|
|
99
|
+
// единственная точка присвоения активной игры — и в бутстрапе, и при
|
|
100
|
+
// переключении в лобби
|
|
101
|
+
function bindActiveGame(manifest, plugin) {
|
|
102
|
+
activeGameManifest = manifest;
|
|
103
|
+
clientPlugin = plugin;
|
|
104
|
+
|
|
105
|
+
if (!gameStyleNode) {
|
|
106
|
+
gameStyleNode = document.createElement('style');
|
|
107
|
+
document.head.append(gameStyleNode);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
gameStyleNode.textContent = plugin.styles ?? '';
|
|
111
|
+
}
|
|
112
|
+
|
|
94
113
|
// режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
|
|
95
114
|
// solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
|
|
96
115
|
// Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
|
|
@@ -137,12 +156,10 @@ try {
|
|
|
137
156
|
throw new Error('master has no games in its catalog');
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
gameStyle.textContent = clientPlugin.styles;
|
|
145
|
-
document.head.append(gameStyle);
|
|
159
|
+
bindActiveGame(
|
|
160
|
+
activeGameManifest,
|
|
161
|
+
boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
|
|
162
|
+
);
|
|
146
163
|
} catch (e) {
|
|
147
164
|
document.body.textContent = `Failed to load the game: ${e.message}`;
|
|
148
165
|
throw e;
|
|
@@ -260,6 +277,10 @@ const socketMethods = []; // методы для обработки сокет-
|
|
|
260
277
|
// получении конфига; wasm — результат init() для zero-copy чтения памяти
|
|
261
278
|
let clientCore = null;
|
|
262
279
|
let wasm = null;
|
|
280
|
+
|
|
281
|
+
// сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
|
|
282
|
+
// геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
|
|
283
|
+
const localPlayer = createLocalPlayer(() => clientCore);
|
|
263
284
|
let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
|
|
264
285
|
|
|
265
286
|
// обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
|
|
@@ -291,7 +312,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
291
312
|
|
|
292
313
|
// инициализация сущностей игры
|
|
293
314
|
for (const entity of Object.keys(entitiesOnCanvas)) {
|
|
294
|
-
Factory.add({ [entity]: parts[entity] });
|
|
315
|
+
Factory.add({ [entity]: clientPlugin.parts[entity] });
|
|
295
316
|
}
|
|
296
317
|
|
|
297
318
|
gameInformer = document.getElementById(data.gameInform.id);
|
|
@@ -345,6 +366,10 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
345
366
|
const availableServices = {
|
|
346
367
|
renderer: app.renderer,
|
|
347
368
|
soundManager,
|
|
369
|
+
// «свой ли это персонаж»: part сравнивает id своего экземпляра (четвёртый
|
|
370
|
+
// аргумент конструктора) со своим gameId. Игра без этого сервиса звучит
|
|
371
|
+
// одинаково за всех — чужие подборы и чужие смерти вперемешку со своими
|
|
372
|
+
localPlayer,
|
|
348
373
|
// база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
|
|
349
374
|
// картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
|
|
350
375
|
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
@@ -912,6 +937,10 @@ function runModules(data) {
|
|
|
912
937
|
'mouseAction',
|
|
913
938
|
modules.controls.resetCursorHideTimer.bind(modules.controls),
|
|
914
939
|
);
|
|
940
|
+
inputListener.publisher.on(
|
|
941
|
+
'pointerAction',
|
|
942
|
+
modules.controls.addPointer.bind(modules.controls),
|
|
943
|
+
);
|
|
915
944
|
inputListener.publisher.on(
|
|
916
945
|
'resize',
|
|
917
946
|
modules.canvasManager.resize.bind(modules.canvasManager),
|
|
@@ -955,6 +984,25 @@ function runModules(data) {
|
|
|
955
984
|
|
|
956
985
|
sending(PC_KEYS_DATA, `${inputSeq}:${data}`);
|
|
957
986
|
});
|
|
987
|
+
|
|
988
|
+
// указатель: экранная точка -> мировая (камера и масштаб полотна знает
|
|
989
|
+
// только движок), дальше тем же портом, что и клавиши
|
|
990
|
+
controlsModel.publisher.on('aim', ({ x, y, flags }) => {
|
|
991
|
+
const world = modules.canvasManager.toWorld(x, y);
|
|
992
|
+
|
|
993
|
+
if (!world) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const wx = Math.round(world.x * 100) / 100;
|
|
998
|
+
const wy = Math.round(world.y * 100) / 100;
|
|
999
|
+
|
|
1000
|
+
inputSeq = (inputSeq + 1) >>> 0;
|
|
1001
|
+
clientCore?.apply_aim?.(wx, wy, flags, performance.now());
|
|
1002
|
+
|
|
1003
|
+
// формат wire: 'seq:aim:x:y:flags' рядом с 'seq:action:name'
|
|
1004
|
+
sending(PC_KEYS_DATA, `${inputSeq}:aim:${wx}:${wy}:${flags}`);
|
|
1005
|
+
});
|
|
958
1006
|
chatModel.publisher.on('socket', handleChatSend);
|
|
959
1007
|
voteModel.publisher.on('socket', data => sending(PC_VOTE_DATA, data));
|
|
960
1008
|
|
|
@@ -1796,13 +1844,62 @@ function populateRoomForm(manifest) {
|
|
|
1796
1844
|
}
|
|
1797
1845
|
}
|
|
1798
1846
|
|
|
1799
|
-
// каталог манифестов по id
|
|
1800
|
-
//
|
|
1801
|
-
// gamesManifest[0] (граница задачи, plan/lobby-page-plan.md)
|
|
1847
|
+
// каталог манифестов по id: форма и leaderboard селектора игр, а также
|
|
1848
|
+
// активация игры перед созданием комнаты и входом в чужую
|
|
1802
1849
|
const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
|
|
1803
1850
|
|
|
1804
|
-
//
|
|
1805
|
-
//
|
|
1851
|
+
// ClientPlugin выбранной игры грузится в момент клика (создание комнаты /
|
|
1852
|
+
// вход в комнату), а не при смене селектора: просмотр каталога не должен
|
|
1853
|
+
// качать бандлы игр, в которые не станут играть
|
|
1854
|
+
const activateGame = createGameActivator({ gamesById, loadClientPlugin });
|
|
1855
|
+
|
|
1856
|
+
// строка отказа в форме лобби: загрузка плагина восстановима (другая игра,
|
|
1857
|
+
// повторный клик), поэтому #tech-informer не годится — он кроет вкладку
|
|
1858
|
+
// непрозрачным слоем под терминальные причины
|
|
1859
|
+
function showLobbyError(text) {
|
|
1860
|
+
const elem = document.getElementById(lobbyConfig.elems.errorId);
|
|
1861
|
+
|
|
1862
|
+
if (elem) {
|
|
1863
|
+
elem.textContent = text;
|
|
1864
|
+
elem.style.display = '';
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function clearLobbyError() {
|
|
1869
|
+
const elem = document.getElementById(lobbyConfig.elems.errorId);
|
|
1870
|
+
|
|
1871
|
+
if (elem) {
|
|
1872
|
+
elem.textContent = '';
|
|
1873
|
+
elem.style.display = 'none';
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// активирует игру и перепривязывает манифест/плагин/CSS; false — отказ уже
|
|
1878
|
+
// показан игроку, лобби остаётся рабочим
|
|
1879
|
+
async function selectActiveGame(gameId) {
|
|
1880
|
+
if (gameId === activeGameManifest.id) {
|
|
1881
|
+
return true;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
try {
|
|
1885
|
+
const { manifest, plugin } = await activateGame(gameId);
|
|
1886
|
+
|
|
1887
|
+
bindActiveGame(manifest, plugin);
|
|
1888
|
+
clearLobbyError();
|
|
1889
|
+
|
|
1890
|
+
return true;
|
|
1891
|
+
} catch (e) {
|
|
1892
|
+
console.error(`[game] failed to activate "${gameId}":`, e);
|
|
1893
|
+
showLobbyError(
|
|
1894
|
+
`Failed to load ${gamesById.get(gameId)?.title ?? gameId}: ${e.message}`,
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
return false;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// заполняет #lobby-game всем каталогом манифестов мастера — раньше туда
|
|
1902
|
+
// попадал только gamesManifest[0], теперь селектор рабочий
|
|
1806
1903
|
function populateGameSelect() {
|
|
1807
1904
|
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
1808
1905
|
|
|
@@ -1853,8 +1950,16 @@ function initLobby() {
|
|
|
1853
1950
|
lobbyModel.resolvePong(msg.pingId, performance.now());
|
|
1854
1951
|
});
|
|
1855
1952
|
|
|
1856
|
-
// выбор сервера → установка P2P
|
|
1857
|
-
|
|
1953
|
+
// выбор сервера → активация игры комнаты и установка P2P. gameId нет у
|
|
1954
|
+
// хостов старше 6.4 — тогда заходим на активной игре, как раньше: неизвестная
|
|
1955
|
+
// версия не повод запретить вход
|
|
1956
|
+
lobbyModel.publisher.on('join', async ({ hostId, gameId }) => {
|
|
1957
|
+
if (gameId && gamesById.has(gameId) && !(await selectActiveGame(gameId))) {
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
connectToHost(hostId);
|
|
1962
|
+
});
|
|
1858
1963
|
|
|
1859
1964
|
// Leaderboard (lobby-page-plan): контроллер сигнализирует, для какой игры
|
|
1860
1965
|
// нужны свежие данные (смена #lobby-game или первое открытие вкладки).
|
|
@@ -1896,9 +2001,8 @@ function initLobby() {
|
|
|
1896
2001
|
populateRoomForm(activeGameManifest);
|
|
1897
2002
|
lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
|
|
1898
2003
|
|
|
1899
|
-
// селектор
|
|
1900
|
-
//
|
|
1901
|
-
// (граница задачи, см. plan/lobby-page-plan.md)
|
|
2004
|
+
// селектор игр: меняет форму создания комнаты и Leaderboard сразу
|
|
2005
|
+
// (синхронно, без сети); сама игра активируется уже по клику
|
|
1902
2006
|
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
1903
2007
|
|
|
1904
2008
|
const hostBtn = document.getElementById(lobbyConfig.elems.hostBtnId);
|
|
@@ -1912,47 +2016,47 @@ function initLobby() {
|
|
|
1912
2016
|
|
|
1913
2017
|
populateRoomForm(manifest);
|
|
1914
2018
|
lobby.gameChanged(manifest.id, manifest.title);
|
|
1915
|
-
|
|
1916
|
-
if (hostBtn) {
|
|
1917
|
-
const { disabled, title } = getHostGateState(manifest.id, activeGameManifest);
|
|
1918
|
-
|
|
1919
|
-
hostBtn.disabled = disabled;
|
|
1920
|
-
hostBtn.title = title;
|
|
1921
|
-
}
|
|
2019
|
+
clearLobbyError();
|
|
1922
2020
|
});
|
|
1923
2021
|
|
|
1924
2022
|
const nameInput = document.getElementById(lobbyConfig.elems.nameId);
|
|
1925
2023
|
|
|
1926
|
-
hostBtn?.addEventListener('click', () => {
|
|
1927
|
-
// защита в глубину поверх hostBtn.disabled (code review lobby-page): клик
|
|
1928
|
-
// не должен создать комнату с полями формы чужой игры, даже если
|
|
1929
|
-
// disabled почему-то не выставился/был обойдён
|
|
1930
|
-
if (gameSelect && getHostGateState(gameSelect.value, activeGameManifest).disabled) {
|
|
1931
|
-
return;
|
|
1932
|
-
}
|
|
1933
|
-
|
|
2024
|
+
hostBtn?.addEventListener('click', async () => {
|
|
1934
2025
|
// нативная валидация (pattern/required) — единственная граница
|
|
1935
2026
|
// room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
|
|
1936
2027
|
// (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
|
|
1937
2028
|
// "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
|
|
1938
|
-
// (вызывается из host.worker.js при создании комнаты)
|
|
2029
|
+
// (вызывается из host.worker.js при создании комнаты). Проверяем до
|
|
2030
|
+
// активации игры: неверная форма не должна стоить загрузки плагина
|
|
1939
2031
|
if (
|
|
1940
2032
|
!reportFormValidity(document.getElementById(lobbyConfig.elems.fieldsId))
|
|
1941
2033
|
) {
|
|
1942
2034
|
return;
|
|
1943
2035
|
}
|
|
1944
2036
|
|
|
2037
|
+
const manifest = gamesById.get(gameSelect?.value) ?? activeGameManifest;
|
|
1945
2038
|
const name =
|
|
1946
2039
|
(nameInput?.value || '').trim() || lobbyConfig.create.defaultName;
|
|
1947
|
-
const { roomDefaults } = activeGameManifest;
|
|
1948
2040
|
|
|
1949
|
-
//
|
|
1950
|
-
|
|
2041
|
+
// значения формы снимаем до await: активация асинхронна, а roomFormFields
|
|
2042
|
+
// пересобираются при смене игры — в комнату должно уехать то, что игрок
|
|
2043
|
+
// видел в момент клика
|
|
2044
|
+
const overrides = { ...manifest.roomDefaults };
|
|
1951
2045
|
|
|
1952
2046
|
for (const [key, field] of roomFormFields) {
|
|
1953
2047
|
overrides[key] = field.getValue();
|
|
1954
2048
|
}
|
|
1955
2049
|
|
|
2050
|
+
// повторный клик, пока грузится плагин, не должен поднять вторую комнату;
|
|
2051
|
+
// при успехе лобби закрывается, разблокировка нужна только на отказе
|
|
2052
|
+
hostBtn.disabled = true;
|
|
2053
|
+
|
|
2054
|
+
if (!(await selectActiveGame(manifest.id))) {
|
|
2055
|
+
hostBtn.disabled = false;
|
|
2056
|
+
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1956
2060
|
connectAsHost({
|
|
1957
2061
|
name,
|
|
1958
2062
|
hostSocketId: lobbyConfig.create.hostSocketId,
|
package/src/client/style.css
CHANGED
|
@@ -619,6 +619,14 @@ body.hide-cursor {
|
|
|
619
619
|
margin: 16px auto 0;
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
+
#lobby-error {
|
|
623
|
+
margin: 8px 0 0;
|
|
624
|
+
color: #d98282;
|
|
625
|
+
font-size: 12px;
|
|
626
|
+
line-height: 16px;
|
|
627
|
+
text-align: center;
|
|
628
|
+
}
|
|
629
|
+
|
|
622
630
|
#lobby-search {
|
|
623
631
|
display: block;
|
|
624
632
|
width: 100%;
|
|
@@ -115,6 +115,9 @@ export function ensureCanvas(canvasId, size, container = document.body) {
|
|
|
115
115
|
canvas.setAttribute('id', canvasId);
|
|
116
116
|
canvas.width = size.width;
|
|
117
117
|
canvas.height = size.height;
|
|
118
|
+
// палец на полотне ведёт игру, а не страницу: без этого браузер
|
|
119
|
+
// смартфона забирает жест себе — прокрутка, pull-to-refresh, зум
|
|
120
|
+
canvas.style.touchAction = 'none';
|
|
118
121
|
container.appendChild(canvas);
|
|
119
122
|
}
|
|
120
123
|
|
|
@@ -27,6 +27,11 @@ div#lobby(style='display:none')
|
|
|
27
27
|
//- манифеста активной игры (docs/en/plugin-api.md "Form schema")
|
|
28
28
|
div#lobby-fields
|
|
29
29
|
input#lobby-host(type='button', value='Create server')
|
|
30
|
+
//- отказ загрузки плагина выбранной игры: восстановим (другая игра,
|
|
31
|
+
//- повторный клик), поэтому строкой в форме, а не #tech-informer —
|
|
32
|
+
//- тот кроет вкладку непрозрачным слоем и рассчитан на терминальные
|
|
33
|
+
//- причины
|
|
34
|
+
p#lobby-error(style='display:none')
|
|
30
35
|
//- правая панель: вкладки Active Servers / Leaderboard (lobby-page-plan)
|
|
31
36
|
div#lobby-browser-panel.card
|
|
32
37
|
//- абсолютный слой: высоту .card задаёт форма слева (stretch), а
|
package/src/config/lobby.js
CHANGED
|
@@ -77,9 +77,11 @@ export default {
|
|
|
77
77
|
emptyId: 'lobby-empty',
|
|
78
78
|
nameId: 'lobby-name',
|
|
79
79
|
hostBtnId: 'lobby-host',
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
80
|
+
// строка отказа под кнопкой: загрузка ClientPlugin выбранной игры может
|
|
81
|
+
// не удаться, и лобби обязано остаться рабочим
|
|
82
|
+
errorId: 'lobby-error',
|
|
83
|
+
// селектор игры: заполняется всем каталогом мастера, выбор задаёт и
|
|
84
|
+
// форму/leaderboard, и игру, которая поднимется по «Create server»
|
|
83
85
|
gameId: 'lobby-game',
|
|
84
86
|
// контейнер полей комнаты: генерируются по ключам roomDefaults
|
|
85
87
|
// манифеста активной игры (Д7) — движок не знает игровых полей
|
package/src/config/master.js
CHANGED
|
@@ -24,8 +24,11 @@ export default {
|
|
|
24
24
|
// не в packages/engine — vimp-engine остаётся game-agnostic и не тянет
|
|
25
25
|
// конкретную игру за собой (кодревью Этапов A, находка F1). Версию задаёт
|
|
26
26
|
// именно эта зависимость, каталог берёт её из dist/manifest.json пакета.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
27
|
+
// Список переопределяется переменной окружения GAMES_MATRIX (JSON) — её
|
|
28
|
+
// задаёт деплой; вне прода каталог вдобавок дополняется собранными
|
|
29
|
+
// пакетами @vimp-games/* из node_modules (master/localGames.js), чтобы
|
|
30
|
+
// прилинкованная игра попадала в лобби без правки этого массива: он
|
|
31
|
+
// публикуется вместе с пакетом vimp-engine
|
|
29
32
|
games: [{ id: 'tanks', package: '@vimp-games/tanks' }],
|
|
30
33
|
|
|
31
34
|
// список серверов (GET /servers)
|
|
@@ -40,7 +43,11 @@ export default {
|
|
|
40
43
|
// ограничения регистрируемых комнат
|
|
41
44
|
host: {
|
|
42
45
|
maxNameLength: 30, // длина имени комнаты
|
|
43
|
-
|
|
46
|
+
// санитарная рамка вместимости комнаты для случая, когда игра комнаты
|
|
47
|
+
// неизвестна мастеру (gameId: null у старых хостов или id не из
|
|
48
|
+
// каталога). Потолок известной игры задаёт её манифест
|
|
49
|
+
// (roomDefaults.maxPlayers) — движок его не ограничивает
|
|
50
|
+
maxPlayersLimit: 8,
|
|
44
51
|
heartbeatTimeout: 30000, // нет heartbeat дольше — комната удаляется
|
|
45
52
|
sweepInterval: 10000, // период проверки протухших комнат
|
|
46
53
|
},
|
|
@@ -363,6 +363,23 @@ async function applyOp(op, ctx) {
|
|
|
363
363
|
break;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
case 'aim': {
|
|
367
|
+
const entry = requireEntry(byParticipant, op.who);
|
|
368
|
+
const seq = (inputSeq.get(op.who) ?? 0) + 1;
|
|
369
|
+
const flags = op.flags ?? 1;
|
|
370
|
+
|
|
371
|
+
inputSeq.set(op.who, seq);
|
|
372
|
+
host.updateKeys(entry.gameId, `${seq}:aim:${op.x}:${op.y}:${flags}`);
|
|
373
|
+
|
|
374
|
+
// клиент предсказывает тот же ввод — как и с клавишами, иначе дрейф
|
|
375
|
+
// предикта не с чем сравнивать
|
|
376
|
+
clients
|
|
377
|
+
.get(entry.socketId)
|
|
378
|
+
?.core.apply_aim?.(op.x, op.y, flags, virtualClock.monotonic());
|
|
379
|
+
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
|
|
366
383
|
case 'chat':
|
|
367
384
|
host.pushMessage(requireEntry(byParticipant, op.who).gameId, op.text);
|
|
368
385
|
break;
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { ERROR, skip, verdict } from '../result.js';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
const RESERVED = ['/name', '/nr', '/timeleft', '/mapname', '/rank'];
|
|
7
|
-
|
|
3
|
+
// Своих команд у движка нет: реестр CommandProcessor наполняет только игра,
|
|
4
|
+
// поэтому зарезервированных имён не существует. Проверяем форму: ведущий слэш
|
|
5
|
+
// и отсутствие дублей — вторая регистрация одного имени молча затирает первую.
|
|
8
6
|
export default {
|
|
9
7
|
id: 'B7',
|
|
10
8
|
name: 'chatCommands',
|
|
11
9
|
level: ERROR,
|
|
12
|
-
title: 'chat commands
|
|
10
|
+
title: 'chat commands are well-formed and unique',
|
|
13
11
|
|
|
14
12
|
check(ctx) {
|
|
15
13
|
const commands = ctx.hostPlugin?.chatCommands;
|
|
@@ -31,10 +29,8 @@ export default {
|
|
|
31
29
|
continue;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
if (
|
|
35
|
-
violations.push(
|
|
36
|
-
`chat command "${name}" is an engine command — it will never fire`,
|
|
37
|
-
);
|
|
32
|
+
if (typeof command.handler !== 'function') {
|
|
33
|
+
violations.push(`chat command "${name}" has no handler function`);
|
|
38
34
|
}
|
|
39
35
|
|
|
40
36
|
if (seen.has(name)) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ERROR, skip, verdict } from '../result.js';
|
|
2
2
|
|
|
3
|
-
// Пул сервисов клиента ровно из
|
|
3
|
+
// Пул сервисов клиента ровно из четырёх имён (client/main.js). Незнакомое имя
|
|
4
4
|
// не ошибка для движка: part получает undefined и рисует пустоту — карта
|
|
5
5
|
// без assetsBase выглядит как чистый холст без единой строки в консоли.
|
|
6
|
-
const SERVICES = ['renderer', 'soundManager', 'assetsBase'];
|
|
6
|
+
const SERVICES = ['renderer', 'soundManager', 'assetsBase', 'localPlayer'];
|
|
7
7
|
|
|
8
8
|
export default {
|
|
9
9
|
id: 'C4',
|
|
@@ -112,6 +112,10 @@ export default class DebugRecorder {
|
|
|
112
112
|
this._pushFor(gameId, who => ({ op: 'key', who, action, name }));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
noteAim(gameId, x, y, flags) {
|
|
116
|
+
this._pushFor(gameId, who => ({ op: 'aim', who, x, y, flags }));
|
|
117
|
+
}
|
|
118
|
+
|
|
115
119
|
noteChat(gameId, text) {
|
|
116
120
|
this._pushFor(gameId, who => ({ op: 'chat', who, text }));
|
|
117
121
|
}
|
|
@@ -88,6 +88,14 @@ export default class GameCoreAdapter {
|
|
|
88
88
|
this._core.apply_input(gameId, seq, action, name);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// ввод указателем: мировая точка + биты состояния (бит 0 — прижат,
|
|
92
|
+
// бит 1 — двойной тап). У трейта дефолтная пустая реализация, но ядро
|
|
93
|
+
// игры, собранное против крейта старше 0.4, метода не экспортирует
|
|
94
|
+
// вовсе — такой игре канал просто не доезжает, вместо падения хоста
|
|
95
|
+
applyAim(gameId, seq, x, y, flags) {
|
|
96
|
+
this._core.apply_aim?.(gameId, seq, x, y, flags);
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
// ***** запросы состояния ***** //
|
|
92
100
|
|
|
93
101
|
isAlive(gameId) {
|
package/src/host/HostGame.js
CHANGED
|
@@ -870,7 +870,9 @@ export default class HostGame {
|
|
|
870
870
|
// финальная синхронизация rank/state перед уходом участника (Этап B4)
|
|
871
871
|
this._playerDataSync
|
|
872
872
|
.flush(gameId)
|
|
873
|
-
.catch(err =>
|
|
873
|
+
.catch(err =>
|
|
874
|
+
console.warn('[playerData] final flush failed:', err.message),
|
|
875
|
+
)
|
|
874
876
|
.finally(() => this._playerDataSync.removeUser(gameId));
|
|
875
877
|
|
|
876
878
|
// если не наблюдатель — удалить танк из ядра (null-маркер ставит ядро)
|
|
@@ -883,7 +885,8 @@ export default class HostGame {
|
|
|
883
885
|
this._chat.pushSystem('USER_LEFT', [user.name]);
|
|
884
886
|
}
|
|
885
887
|
|
|
886
|
-
// обновляет команды (формат wire: 'seq:action:name'
|
|
888
|
+
// обновляет команды (формат wire: 'seq:action:name', указатель —
|
|
889
|
+
// 'seq:aim:x:y:flags')
|
|
887
890
|
updateKeys(gameId, keyStr) {
|
|
888
891
|
const user = this._participants.get(gameId);
|
|
889
892
|
|
|
@@ -891,7 +894,33 @@ export default class HostGame {
|
|
|
891
894
|
return;
|
|
892
895
|
}
|
|
893
896
|
|
|
894
|
-
const [seq, action, name] = keyStr.split(':');
|
|
897
|
+
const [seq, action, name, aimY, aimFlags] = keyStr.split(':');
|
|
898
|
+
|
|
899
|
+
// ввод указателем: наблюдателю рулить нечем, живому актору — мировая
|
|
900
|
+
// точка и биты состояния указателя
|
|
901
|
+
if (action === 'aim') {
|
|
902
|
+
this._recorder?.noteAim(
|
|
903
|
+
gameId,
|
|
904
|
+
Number(name),
|
|
905
|
+
Number(aimY),
|
|
906
|
+
Number(aimFlags) >>> 0,
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
user.lastActionTime = clock.now();
|
|
910
|
+
user.lastInputSeq = Number(seq) >>> 0;
|
|
911
|
+
|
|
912
|
+
if (user.isWatching !== true) {
|
|
913
|
+
this._game.applyAim(
|
|
914
|
+
gameId,
|
|
915
|
+
user.lastInputSeq,
|
|
916
|
+
Number(name),
|
|
917
|
+
Number(aimY),
|
|
918
|
+
Number(aimFlags) >>> 0,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
895
924
|
|
|
896
925
|
this._recorder?.noteKey(gameId, action, name);
|
|
897
926
|
|
package/src/host/PortMachine.js
CHANGED
|
@@ -270,7 +270,7 @@ export default class PortMachine {
|
|
|
270
270
|
// 4: first shot ready
|
|
271
271
|
() => host.firstShotReady(state.gameId),
|
|
272
272
|
|
|
273
|
-
// 5: keys data ('seq:action:name')
|
|
273
|
+
// 5: keys data ('seq:action:name'; указатель — 'seq:aim:x:y:flags')
|
|
274
274
|
keyEventString => {
|
|
275
275
|
if (typeof keyEventString === 'string') {
|
|
276
276
|
host.updateKeys(state.gameId, keyEventString);
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
// Обработчик чат-команд.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Обработчик чат-команд. Своих команд у движка НЕТ: реестр целиком наполняет
|
|
2
|
+
// игра через HostPlugin.chatCommands, а обработчик получает контекст меты —
|
|
3
|
+
// handler(ctx, gameId, args). Одно и то же имя (/name, /bot, /timeleft …) в
|
|
4
|
+
// разных играх может делать разное или отсутствовать вовсе; незнакомая
|
|
5
|
+
// команда — COMMANDS_NOT_FOUND.
|
|
6
|
+
//
|
|
7
|
+
// Всё, что раньше разбирал движковый switch, доступно играм через контекст:
|
|
8
|
+
// roundManager (смена ника, новый раунд, текущая карта), timerManager
|
|
9
|
+
// (остаток времени карты), playerDataSync (ранг), chat, isDevMode.
|
|
4
10
|
class CommandProcessor {
|
|
5
11
|
constructor(deps) {
|
|
6
12
|
this._chat = deps.chat;
|
|
7
|
-
this._roundManager = deps.roundManager;
|
|
8
|
-
this._timerManager = deps.timerManager;
|
|
9
|
-
this._playerDataSync = deps.playerDataSync;
|
|
10
|
-
this._isDevMode = deps.isDevMode;
|
|
11
13
|
|
|
12
14
|
// контекст игровых команд (participants, chat, scripted, roundManager,
|
|
13
|
-
// voteCoordinator,
|
|
15
|
+
// voteCoordinator, timerManager, playerDataSync, teams, spectatorTeam,
|
|
16
|
+
// spectatorId, isDevMode)
|
|
14
17
|
this._ctx = deps;
|
|
15
18
|
|
|
16
19
|
this._commands = new Map();
|
|
@@ -31,67 +34,12 @@ class CommandProcessor {
|
|
|
31
34
|
|
|
32
35
|
const arr = message.split(' ');
|
|
33
36
|
const cmd = arr.shift();
|
|
37
|
+
const handler = this._commands.get(cmd);
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
break;
|
|
40
|
-
|
|
41
|
-
// новый раунд
|
|
42
|
-
case '/nr':
|
|
43
|
-
if (this._isDevMode) {
|
|
44
|
-
this._roundManager.initiateNewRound();
|
|
45
|
-
} else {
|
|
46
|
-
this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
|
|
47
|
-
}
|
|
48
|
-
break;
|
|
49
|
-
|
|
50
|
-
// время карты
|
|
51
|
-
case '/timeleft': {
|
|
52
|
-
function getTime(ms) {
|
|
53
|
-
const totalSeconds = Math.floor(ms / 1000);
|
|
54
|
-
let minutes = Math.floor(totalSeconds / 60);
|
|
55
|
-
let seconds = totalSeconds % 60;
|
|
56
|
-
|
|
57
|
-
if (minutes < 10) {
|
|
58
|
-
minutes = '0' + minutes;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (seconds < 10) {
|
|
62
|
-
seconds = '0' + seconds;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return `${minutes}:${seconds}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
this._chat.pushSystemByUser(gameId, [
|
|
69
|
-
getTime(this._timerManager.getMapTimeLeft()),
|
|
70
|
-
]);
|
|
71
|
-
break;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// название текущей карты
|
|
75
|
-
case '/mapname':
|
|
76
|
-
this._chat.pushSystemByUser(gameId, [this._roundManager.currentMap]);
|
|
77
|
-
break;
|
|
78
|
-
|
|
79
|
-
// ранг игрока (Этап B4/B5: PlayerDataSync, подгружен с auth-сервиса)
|
|
80
|
-
case '/rank':
|
|
81
|
-
this._chat.pushSystemByUser(gameId, 'RANK', [
|
|
82
|
-
this._playerDataSync.getRank(gameId),
|
|
83
|
-
]);
|
|
84
|
-
break;
|
|
85
|
-
|
|
86
|
-
default: {
|
|
87
|
-
const handler = this._commands.get(cmd);
|
|
88
|
-
|
|
89
|
-
if (handler) {
|
|
90
|
-
handler(this._ctx, gameId, arr);
|
|
91
|
-
} else {
|
|
92
|
-
this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
|
|
93
|
-
}
|
|
94
|
-
}
|
|
39
|
+
if (handler) {
|
|
40
|
+
handler(this._ctx, gameId, arr);
|
|
41
|
+
} else {
|
|
42
|
+
this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
|
|
95
43
|
}
|
|
96
44
|
}
|
|
97
45
|
}
|
|
@@ -112,6 +112,23 @@ export default class FakeGameCore {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// ввод указателем: миниигра просто запоминает последнюю точку и биты —
|
|
116
|
+
// движковым тестам важно, что канал доходит до ядра
|
|
117
|
+
apply_aim(gameId, seq, x, y, flags) {
|
|
118
|
+
const actor = this._actors.get(gameId);
|
|
119
|
+
|
|
120
|
+
if (!actor) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
actor.lastInputSeq = seq;
|
|
125
|
+
actor.aim = { x, y, flags };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
aim_of(gameId) {
|
|
129
|
+
return this._actors.get(gameId)?.aim ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
last_input_seq(gameId) {
|
|
116
133
|
return this._actors.get(gameId)?.lastInputSeq ?? 0;
|
|
117
134
|
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Пока хост всегда поднимает activeGameManifest (динамическая загрузка
|
|
2
|
-
// ClientPlugin по выбору игры отложена — граница задачи, plan/lobby-page-plan.md),
|
|
3
|
-
// нельзя дать создать комнату с полями формы игры, отличной от активной
|
|
4
|
-
// (code review lobby-page 2.1). Вынесено в чистую функцию, чтобы поведение
|
|
5
|
-
// было проверяемо юнит-тестом отдельно от main.js (main.js — бутстрап, тестами
|
|
6
|
-
// не покрывается).
|
|
7
|
-
export function getHostGateState(selectedGameId, activeGameManifest) {
|
|
8
|
-
if (selectedGameId === activeGameManifest.id) {
|
|
9
|
-
return { disabled: false, title: '' };
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
return {
|
|
13
|
-
disabled: true,
|
|
14
|
-
title: `Hosting ${activeGameManifest.title} — switch back to create a server`,
|
|
15
|
-
};
|
|
16
|
-
}
|