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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.3.2"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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
 
@@ -24,8 +24,11 @@ export default {
24
24
  // не в packages/engine — vimp-engine остаётся game-agnostic и не тянет
25
25
  // конкретную игру за собой (кодревью Этапов A, находка F1). Версию задаёт
26
26
  // именно эта зависимость, каталог берёт её из dist/manifest.json пакета.
27
- // В проде список переопределяется переменной окружения GAMES_MATRIX (JSON),
28
- // см. master/main.js
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
- maxPlayersLimit: 8, // целевой размер комнаты (рамка P2P-плана)
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
- // Движковые команды разбираются switch'ем раньше реестра игровых
4
- // (CommandProcessor.parseCommand): одноимённая команда плагина
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 do not shadow the engine 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 (RESERVED.includes(name)) {
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
- // Пул сервисов клиента ровно из трёх имён (client/main.js). Незнакомое имя
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) {
@@ -870,7 +870,9 @@ export default class HostGame {
870
870
  // финальная синхронизация rank/state перед уходом участника (Этап B4)
871
871
  this._playerDataSync
872
872
  .flush(gameId)
873
- .catch(err => console.warn('[playerData] final flush failed:', err.message))
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
 
@@ -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
- // Обработчик чат-команд. Движковое ядро: /name, /nr, /timeleft, /mapname,
2
- // /rank; игровые команды (HostPlugin.chatCommands) регистрируются через
3
- // registerCommand и получают контекст меты: handler(ctx, gameId, args).
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, teams, spectatorTeam, spectatorId, …)
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
- switch (cmd) {
36
- // смена ника
37
- case '/name':
38
- this._roundManager.changeName(gameId, arr.join(' '));
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
  }