vimp-engine 0.10.2 → 0.11.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/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/view/CanvasManager.js +33 -0
- package/src/client/lib/localPlayer.js +37 -0
- package/src/client/main.js +32 -0
- package/src/client/views/gameShell.js +3 -0
- 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/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] =
|
|
@@ -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,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
|
@@ -36,6 +36,7 @@ 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 {
|
|
@@ -260,6 +261,10 @@ const socketMethods = []; // методы для обработки сокет-
|
|
|
260
261
|
// получении конфига; wasm — результат init() для zero-copy чтения памяти
|
|
261
262
|
let clientCore = null;
|
|
262
263
|
let wasm = null;
|
|
264
|
+
|
|
265
|
+
// сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
|
|
266
|
+
// геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
|
|
267
|
+
const localPlayer = createLocalPlayer(() => clientCore);
|
|
263
268
|
let inputSeq = 0; // номер отправленного ввода (KEYS_DATA)
|
|
264
269
|
|
|
265
270
|
// обратный индекс снапшот-схемы игры (CONFIG_DATA.snapshot):
|
|
@@ -345,6 +350,10 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
345
350
|
const availableServices = {
|
|
346
351
|
renderer: app.renderer,
|
|
347
352
|
soundManager,
|
|
353
|
+
// «свой ли это персонаж»: part сравнивает id своего экземпляра (четвёртый
|
|
354
|
+
// аргумент конструктора) со своим gameId. Игра без этого сервиса звучит
|
|
355
|
+
// одинаково за всех — чужие подборы и чужие смерти вперемешку со своими
|
|
356
|
+
localPlayer,
|
|
348
357
|
// база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
|
|
349
358
|
// картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
|
|
350
359
|
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
@@ -912,6 +921,10 @@ function runModules(data) {
|
|
|
912
921
|
'mouseAction',
|
|
913
922
|
modules.controls.resetCursorHideTimer.bind(modules.controls),
|
|
914
923
|
);
|
|
924
|
+
inputListener.publisher.on(
|
|
925
|
+
'pointerAction',
|
|
926
|
+
modules.controls.addPointer.bind(modules.controls),
|
|
927
|
+
);
|
|
915
928
|
inputListener.publisher.on(
|
|
916
929
|
'resize',
|
|
917
930
|
modules.canvasManager.resize.bind(modules.canvasManager),
|
|
@@ -955,6 +968,25 @@ function runModules(data) {
|
|
|
955
968
|
|
|
956
969
|
sending(PC_KEYS_DATA, `${inputSeq}:${data}`);
|
|
957
970
|
});
|
|
971
|
+
|
|
972
|
+
// указатель: экранная точка -> мировая (камера и масштаб полотна знает
|
|
973
|
+
// только движок), дальше тем же портом, что и клавиши
|
|
974
|
+
controlsModel.publisher.on('aim', ({ x, y, flags }) => {
|
|
975
|
+
const world = modules.canvasManager.toWorld(x, y);
|
|
976
|
+
|
|
977
|
+
if (!world) {
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const wx = Math.round(world.x * 100) / 100;
|
|
982
|
+
const wy = Math.round(world.y * 100) / 100;
|
|
983
|
+
|
|
984
|
+
inputSeq = (inputSeq + 1) >>> 0;
|
|
985
|
+
clientCore?.apply_aim?.(wx, wy, flags, performance.now());
|
|
986
|
+
|
|
987
|
+
// формат wire: 'seq:aim:x:y:flags' рядом с 'seq:action:name'
|
|
988
|
+
sending(PC_KEYS_DATA, `${inputSeq}:aim:${wx}:${wy}:${flags}`);
|
|
989
|
+
});
|
|
958
990
|
chatModel.publisher.on('socket', handleChatSend);
|
|
959
991
|
voteModel.publisher.on('socket', data => sending(PC_VOTE_DATA, data));
|
|
960
992
|
|
|
@@ -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
|
|
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
|
}
|