vimp-engine 0.11.0 → 0.12.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/components/model/Lobby.js +7 -3
- package/src/client/lib/gameActivator.js +38 -0
- package/src/client/main.js +117 -45
- package/src/client/style.css +8 -0
- package/src/client/views/includes/lobby.pug +5 -0
- package/src/config/lobby.js +5 -3
- package/src/config/opcodes.js +5 -1
- 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.6.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -31,7 +31,7 @@ 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';
|
|
@@ -80,18 +80,36 @@ import lobbyConfig from '../config/lobby.js';
|
|
|
80
80
|
import authClientConfig from '../config/authClient.js';
|
|
81
81
|
import clientDefaults from '../config/clientDefaults.js';
|
|
82
82
|
|
|
83
|
-
// Динамическая загрузка игры по каталогу мастера (Этап 6.3):
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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-режим перезагружает страницу
|
|
90
91
|
let activeGameManifest;
|
|
91
92
|
let clientPlugin;
|
|
92
|
-
let parts;
|
|
93
93
|
let gamesManifest;
|
|
94
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
|
+
|
|
95
113
|
// режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
|
|
96
114
|
// solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
|
|
97
115
|
// Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
|
|
@@ -138,12 +156,10 @@ try {
|
|
|
138
156
|
throw new Error('master has no games in its catalog');
|
|
139
157
|
}
|
|
140
158
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
gameStyle.textContent = clientPlugin.styles;
|
|
146
|
-
document.head.append(gameStyle);
|
|
159
|
+
bindActiveGame(
|
|
160
|
+
activeGameManifest,
|
|
161
|
+
boot.clientPlugin ?? (await loadClientPlugin(activeGameManifest)),
|
|
162
|
+
);
|
|
147
163
|
} catch (e) {
|
|
148
164
|
document.body.textContent = `Failed to load the game: ${e.message}`;
|
|
149
165
|
throw e;
|
|
@@ -296,7 +312,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
296
312
|
|
|
297
313
|
// инициализация сущностей игры
|
|
298
314
|
for (const entity of Object.keys(entitiesOnCanvas)) {
|
|
299
|
-
Factory.add({ [entity]: parts[entity] });
|
|
315
|
+
Factory.add({ [entity]: clientPlugin.parts[entity] });
|
|
300
316
|
}
|
|
301
317
|
|
|
302
318
|
gameInformer = document.getElementById(data.gameInform.id);
|
|
@@ -1828,13 +1844,62 @@ function populateRoomForm(manifest) {
|
|
|
1828
1844
|
}
|
|
1829
1845
|
}
|
|
1830
1846
|
|
|
1831
|
-
// каталог манифестов по id
|
|
1832
|
-
//
|
|
1833
|
-
// gamesManifest[0] (граница задачи, plan/lobby-page-plan.md)
|
|
1847
|
+
// каталог манифестов по id: форма и leaderboard селектора игр, а также
|
|
1848
|
+
// активация игры перед созданием комнаты и входом в чужую
|
|
1834
1849
|
const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
|
|
1835
1850
|
|
|
1836
|
-
//
|
|
1837
|
-
//
|
|
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], теперь селектор рабочий
|
|
1838
1903
|
function populateGameSelect() {
|
|
1839
1904
|
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
1840
1905
|
|
|
@@ -1885,8 +1950,16 @@ function initLobby() {
|
|
|
1885
1950
|
lobbyModel.resolvePong(msg.pingId, performance.now());
|
|
1886
1951
|
});
|
|
1887
1952
|
|
|
1888
|
-
// выбор сервера → установка P2P
|
|
1889
|
-
|
|
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
|
+
});
|
|
1890
1963
|
|
|
1891
1964
|
// Leaderboard (lobby-page-plan): контроллер сигнализирует, для какой игры
|
|
1892
1965
|
// нужны свежие данные (смена #lobby-game или первое открытие вкладки).
|
|
@@ -1928,9 +2001,8 @@ function initLobby() {
|
|
|
1928
2001
|
populateRoomForm(activeGameManifest);
|
|
1929
2002
|
lobby.gameChanged(activeGameManifest.id, activeGameManifest.title);
|
|
1930
2003
|
|
|
1931
|
-
// селектор
|
|
1932
|
-
//
|
|
1933
|
-
// (граница задачи, см. plan/lobby-page-plan.md)
|
|
2004
|
+
// селектор игр: меняет форму создания комнаты и Leaderboard сразу
|
|
2005
|
+
// (синхронно, без сети); сама игра активируется уже по клику
|
|
1934
2006
|
const gameSelect = document.getElementById(lobbyConfig.elems.gameId);
|
|
1935
2007
|
|
|
1936
2008
|
const hostBtn = document.getElementById(lobbyConfig.elems.hostBtnId);
|
|
@@ -1944,47 +2016,47 @@ function initLobby() {
|
|
|
1944
2016
|
|
|
1945
2017
|
populateRoomForm(manifest);
|
|
1946
2018
|
lobby.gameChanged(manifest.id, manifest.title);
|
|
1947
|
-
|
|
1948
|
-
if (hostBtn) {
|
|
1949
|
-
const { disabled, title } = getHostGateState(manifest.id, activeGameManifest);
|
|
1950
|
-
|
|
1951
|
-
hostBtn.disabled = disabled;
|
|
1952
|
-
hostBtn.title = title;
|
|
1953
|
-
}
|
|
2019
|
+
clearLobbyError();
|
|
1954
2020
|
});
|
|
1955
2021
|
|
|
1956
2022
|
const nameInput = document.getElementById(lobbyConfig.elems.nameId);
|
|
1957
2023
|
|
|
1958
|
-
hostBtn?.addEventListener('click', () => {
|
|
1959
|
-
// защита в глубину поверх hostBtn.disabled (code review lobby-page): клик
|
|
1960
|
-
// не должен создать комнату с полями формы чужой игры, даже если
|
|
1961
|
-
// disabled почему-то не выставился/был обойдён
|
|
1962
|
-
if (gameSelect && getHostGateState(gameSelect.value, activeGameManifest).disabled) {
|
|
1963
|
-
return;
|
|
1964
|
-
}
|
|
1965
|
-
|
|
2024
|
+
hostBtn?.addEventListener('click', async () => {
|
|
1966
2025
|
// нативная валидация (pattern/required) — единственная граница
|
|
1967
2026
|
// room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
|
|
1968
2027
|
// (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
|
|
1969
2028
|
// "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
|
|
1970
|
-
// (вызывается из host.worker.js при создании комнаты)
|
|
2029
|
+
// (вызывается из host.worker.js при создании комнаты). Проверяем до
|
|
2030
|
+
// активации игры: неверная форма не должна стоить загрузки плагина
|
|
1971
2031
|
if (
|
|
1972
2032
|
!reportFormValidity(document.getElementById(lobbyConfig.elems.fieldsId))
|
|
1973
2033
|
) {
|
|
1974
2034
|
return;
|
|
1975
2035
|
}
|
|
1976
2036
|
|
|
2037
|
+
const manifest = gamesById.get(gameSelect?.value) ?? activeGameManifest;
|
|
1977
2038
|
const name =
|
|
1978
2039
|
(nameInput?.value || '').trim() || lobbyConfig.create.defaultName;
|
|
1979
|
-
const { roomDefaults } = activeGameManifest;
|
|
1980
2040
|
|
|
1981
|
-
//
|
|
1982
|
-
|
|
2041
|
+
// значения формы снимаем до await: активация асинхронна, а roomFormFields
|
|
2042
|
+
// пересобираются при смене игры — в комнату должно уехать то, что игрок
|
|
2043
|
+
// видел в момент клика
|
|
2044
|
+
const overrides = { ...manifest.roomDefaults };
|
|
1983
2045
|
|
|
1984
2046
|
for (const [key, field] of roomFormFields) {
|
|
1985
2047
|
overrides[key] = field.getValue();
|
|
1986
2048
|
}
|
|
1987
2049
|
|
|
2050
|
+
// повторный клик, пока грузится плагин, не должен поднять вторую комнату;
|
|
2051
|
+
// при успехе лобби закрывается, разблокировка нужна только на отказе
|
|
2052
|
+
hostBtn.disabled = true;
|
|
2053
|
+
|
|
2054
|
+
if (!(await selectActiveGame(manifest.id))) {
|
|
2055
|
+
hostBtn.disabled = false;
|
|
2056
|
+
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
1988
2060
|
connectAsHost({
|
|
1989
2061
|
name,
|
|
1990
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%;
|
|
@@ -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/opcodes.js
CHANGED
|
@@ -15,7 +15,11 @@ export const ENGINE_API_VERSION = 3;
|
|
|
15
15
|
// увеличивать при любом изменении байтовой раскладки в ядре
|
|
16
16
|
// v2: per-user player-блок (gameId, inputSeq, состояние своего танка) — Фаза 5b
|
|
17
17
|
// v3: id автора в событиях оружия (tracers +shooterId, bombs +ownerId) — Фаза 5c
|
|
18
|
-
|
|
18
|
+
// v4: опциональный хвост строки блока (schema.optionalFrom) — скорости
|
|
19
|
+
// динамики карты (vx/vy/angvel) у движущихся тел, покоящиеся не дорожают
|
|
20
|
+
// v5: угловая скорость танка (m1 +angvel) — предсказание доворота чужого
|
|
21
|
+
// корпуса при контакте
|
|
22
|
+
export const SNAPSHOT_FORMAT_VERSION = 5;
|
|
19
23
|
|
|
20
24
|
// Реестр ключей снапшота (SNAPSHOT_KEYS) — игровая схема: живёт в
|
|
21
25
|
// gameConfig.snapshot игры (например src/config/snapshot.js в vimp-tanks),
|
|
@@ -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
|
-
}
|