vimp-engine 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +11 -4
  2. package/src/client/InputListener.js +36 -0
  3. package/src/client/SoundManager.js +498 -0
  4. package/src/client/boot.js +113 -0
  5. package/src/client/components/controller/Auth.js +44 -0
  6. package/src/client/components/controller/CanvasManager.js +26 -0
  7. package/src/client/components/controller/Chat.js +56 -0
  8. package/src/client/components/controller/Controls.js +51 -0
  9. package/src/client/components/controller/Game.js +37 -0
  10. package/src/client/components/controller/Lobby.js +81 -0
  11. package/src/client/components/controller/LobbyAuth.js +41 -0
  12. package/src/client/components/controller/Panel.js +23 -0
  13. package/src/client/components/controller/Stat.js +33 -0
  14. package/src/client/components/controller/Vote.js +55 -0
  15. package/src/client/components/model/Auth.js +92 -0
  16. package/src/client/components/model/CanvasManager.js +319 -0
  17. package/src/client/components/model/Chat.js +119 -0
  18. package/src/client/components/model/Controls.js +120 -0
  19. package/src/client/components/model/Game.js +172 -0
  20. package/src/client/components/model/Lobby.js +226 -0
  21. package/src/client/components/model/LobbyAuth.js +161 -0
  22. package/src/client/components/model/Panel.js +70 -0
  23. package/src/client/components/model/Stat.js +77 -0
  24. package/src/client/components/model/Vote.js +216 -0
  25. package/src/client/components/view/Auth.js +167 -0
  26. package/src/client/components/view/CanvasManager.js +44 -0
  27. package/src/client/components/view/Chat.js +90 -0
  28. package/src/client/components/view/Controls.js +30 -0
  29. package/src/client/components/view/Game.js +48 -0
  30. package/src/client/components/view/Lobby.js +303 -0
  31. package/src/client/components/view/LobbyAuth.js +111 -0
  32. package/src/client/components/view/Panel.js +314 -0
  33. package/src/client/components/view/Stat.js +238 -0
  34. package/src/client/components/view/Vote.js +108 -0
  35. package/src/client/debug.js +158 -0
  36. package/src/client/lib/autostart.js +61 -0
  37. package/src/client/lib/contextTracker.js +46 -0
  38. package/src/client/lib/formBuilder.js +280 -0
  39. package/src/client/lib/hostGate.js +16 -0
  40. package/src/client/main.js +2011 -0
  41. package/src/client/network/HostConnectionManager.js +196 -0
  42. package/src/client/network/HostController.js +422 -0
  43. package/src/client/network/InlineHostBridge.js +152 -0
  44. package/src/client/network/LoopbackTransport.js +51 -0
  45. package/src/client/network/SignalingClient.js +146 -0
  46. package/src/client/network/WebRtcManager.js +147 -0
  47. package/src/client/network/WebSocketTransport.js +78 -0
  48. package/src/client/network/policyClose.js +54 -0
  49. package/src/client/network/workerSupport.js +34 -0
  50. package/src/client/providers/BakingProvider.js +88 -0
  51. package/src/client/providers/DependencyProvider.js +41 -0
  52. package/src/client/style.css +898 -0
  53. package/src/client/views/gameShell.js +190 -0
  54. package/src/client/views/includes/auth.pug +20 -0
  55. package/src/client/views/includes/chat.pug +5 -0
  56. package/src/client/views/includes/informer.pug +2 -0
  57. package/src/client/views/includes/lobby.pug +48 -0
  58. package/src/client/views/includes/lobbyAuth.pug +19 -0
  59. package/src/client/views/includes/panel.pug +3 -0
  60. package/src/client/views/includes/stat.pug +2 -0
  61. package/src/client/views/index.pug +8 -0
  62. package/src/config/closeCodes.js +18 -0
  63. package/src/config/env.js +61 -0
  64. package/src/devtools/ScenarioRunner.js +3 -8
  65. package/src/devtools/pluginLoader.js +8 -93
  66. package/src/host/HostGame.js +41 -3
  67. package/src/host/PortMachine.js +294 -0
  68. package/src/host/host.worker.js +29 -246
  69. package/src/host/identity.js +100 -0
  70. package/src/lib/clientIp.js +39 -0
  71. package/src/lib/loadGamePackage.js +122 -0
  72. package/src/lib/offlinePlayerData.js +18 -0
  73. package/src/standalone/index.js +125 -0
@@ -0,0 +1,172 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+ import Factory from '../../../lib/factory.js';
3
+
4
+ // GameModel
5
+
6
+ export default class GameModel {
7
+ constructor(assetsCollection, dependenciesCollection) {
8
+ // коллекция ассетов (Map)
9
+ this._assets = assetsCollection || new Map();
10
+
11
+ // коллекция зависимостей (Map)
12
+ this._dependencies = dependenciesCollection || new Map();
13
+
14
+ this._data = {};
15
+ this._managedEffects = {};
16
+ this.publisher = new Publisher();
17
+ }
18
+
19
+ // создает экземпляры вида:
20
+ // this._data['Tank']['01'] - игрок c id '01'
21
+ // this._data['Map']['1'] - данные карты на 1 слое
22
+ // this._data['Bullet']['f2'] - пуля с id 'f2'
23
+ //
24
+ // constructor - имя конструктора для экземпляра
25
+ // id - id экземпляра
26
+ // data - данные для создания экземпляра
27
+ create(constructor, id, data) {
28
+ const instance = Factory(
29
+ constructor,
30
+ data,
31
+ this._assets.get(constructor),
32
+ this._dependencies.get(constructor),
33
+ );
34
+
35
+ this._data[constructor] = this._data[constructor] || {};
36
+ this._data[constructor][id] = instance;
37
+ this.publisher.emit('create', instance);
38
+ }
39
+
40
+ // создает экземпляр эффекта (например анимация выстрела или дыма)
41
+ // this._managedEffects['ShotEffect'] = [
42
+ // [100, 1000, 11, 1, true], [200, 200, 200, 200, false]
43
+ // ]
44
+ createEffect(constructor, data) {
45
+ const instance = Factory(
46
+ constructor,
47
+ data,
48
+ this._assets.get(constructor),
49
+ this._dependencies.get(constructor),
50
+ );
51
+
52
+ this._managedEffects[constructor] =
53
+ this._managedEffects[constructor] || new Set();
54
+ this._managedEffects[constructor].add(instance);
55
+
56
+ const originalDestroy = instance.destroy;
57
+
58
+ // proxy destroy - для удаления завершенных эффектов из this._managedEffects
59
+ instance.destroy = (...args) => {
60
+ if (this._managedEffects[constructor]?.has(instance)) {
61
+ this._managedEffects[constructor].delete(instance);
62
+
63
+ if (this._managedEffects[constructor].size === 0) {
64
+ delete this._managedEffects[constructor];
65
+ }
66
+ }
67
+
68
+ originalDestroy.apply(instance, args);
69
+ };
70
+
71
+ this.publisher.emit('createEffect', instance);
72
+ }
73
+
74
+ // возвращает данные:
75
+ // - экземпляра конструктора
76
+ // - всех экземляров конструктора
77
+ // - все данные
78
+ read(constructor, id) {
79
+ // если нужны данные по конструктору
80
+ if (constructor) {
81
+ // .. и они существуют
82
+ if (this._data[constructor]) {
83
+ // .. и также нужны данные конкретного id
84
+ if (id) {
85
+ // .. и они существуют
86
+ if (this._data[constructor][id]) {
87
+ return this._data[constructor][id];
88
+ }
89
+ } else {
90
+ return this._data[constructor];
91
+ }
92
+ }
93
+ } else {
94
+ return this._data;
95
+ }
96
+ }
97
+
98
+ // обновляет данные экземпляра
99
+ update(constructor, id, data) {
100
+ if (data === null) {
101
+ this.remove(constructor, id);
102
+ } else {
103
+ this._data[constructor][id].update(data);
104
+ }
105
+ }
106
+
107
+ // удаляет данные:
108
+ // - экземпляра конструктора
109
+ // - всех экземляров конструктора (пустой конструктор)
110
+ // - все данные (чистое полотно)
111
+ //
112
+ // constructor - имя конструктора для экземпляра (необязательно)
113
+ // id - id экземпляра (необязательно)
114
+ remove(constructor, id) {
115
+ // если данные по конструктору
116
+ if (constructor) {
117
+ // .. и данные по конструктору существуют
118
+ if (this._data[constructor]) {
119
+ // если данные по конкретному id
120
+ if (id) {
121
+ // .. и они существуют
122
+ if (this._data[constructor][id]) {
123
+ this.publisher.emit('remove', this._data[constructor][id]);
124
+ delete this._data[constructor][id];
125
+ }
126
+ } else {
127
+ const data = this._data[constructor];
128
+
129
+ for (const id in data) {
130
+ if (Object.hasOwn(data, id)) {
131
+ this.publisher.emit('remove', data[id]);
132
+ }
133
+ }
134
+
135
+ delete this._data[constructor];
136
+ }
137
+ // иначе, если данные существуют в эффектах
138
+ } else if (this._managedEffects[constructor]) {
139
+ const effectsSet = this._managedEffects[constructor];
140
+
141
+ Array.from(effectsSet).forEach(effect => {
142
+ this.publisher.emit('remove', effect);
143
+ });
144
+ }
145
+ // иначе удаление всех данных
146
+ } else {
147
+ for (const constructor in this._data) {
148
+ if (Object.hasOwn(this._data, constructor)) {
149
+ const data = this._data[constructor];
150
+
151
+ for (const id in data) {
152
+ if (Object.hasOwn(data, id)) {
153
+ this.publisher.emit('remove', data[id]);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ this._data = {};
160
+
161
+ Object.keys(this._managedEffects).forEach(constructor => {
162
+ const effectsSet = this._managedEffects[constructor];
163
+
164
+ if (effectsSet) {
165
+ Array.from(effectsSet).forEach(effect => {
166
+ this.publisher.emit('remove', effect);
167
+ });
168
+ }
169
+ });
170
+ }
171
+ }
172
+ }
@@ -0,0 +1,226 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton LobbyModel
4
+
5
+ let lobbyModel;
6
+
7
+ // Модель лобби: список серверов (реестр мастера), пагинация, поиск и умный
8
+ // пинг. Сетевого I/O не делает — публикует события, а controller/main.js
9
+ // выполняют fetch REST и сигнальный ping (как остальные модели шлют 'socket').
10
+ export default class LobbyModel {
11
+ constructor(params) {
12
+ if (lobbyModel) {
13
+ return lobbyModel;
14
+ }
15
+
16
+ lobbyModel = this;
17
+
18
+ this._pageSize = params.pageSize;
19
+ this._pingInterval = params.pingInterval;
20
+
21
+ this._servers = new Map(); // hostId -> { ...server, latency }
22
+ this._order = []; // hostId в порядке выдачи мастером
23
+ this._total = 0; // всего серверов у мастера (для «Загрузить ещё»)
24
+ this._offset = 0;
25
+ this._search = '';
26
+
27
+ this._pingCounter = 0;
28
+ this._pending = new Map(); // pingId -> { hostId, time }
29
+ this._lastPing = new Map(); // hostId -> time последнего пинга
30
+ this._latencies = new Map(); // hostId -> latency (переживает refresh)
31
+
32
+ // Leaderboard выбранной игры (lobby-page-plan) — сетевого I/O не делает,
33
+ // как и остальная модель; fetch живёт в main.js (fetchLeaderboard/fetchPlacement)
34
+ this._leaderboard = [];
35
+ this._leaderboardTotal = 0;
36
+ this._myPlacement = null;
37
+ // отличает "ещё грузится" от "пусто, но ответ уже пришёл" (code review
38
+ // мелочь) — clearLeaderboard сбрасывает список ДО fetch'а (M1), без
39
+ // этого флага view на доли секунды рисует заглушку "No ranked players
40
+ // yet" вместо состояния загрузки
41
+ this._leaderboardLoaded = false;
42
+
43
+ this.publisher = new Publisher();
44
+ }
45
+
46
+ // запрашивает первую страницу с текущим поиском (сброс пагинации)
47
+ refresh() {
48
+ this._offset = 0;
49
+ this._emitFetch();
50
+ }
51
+
52
+ // задаёт поиск по имени и запрашивает список заново
53
+ setSearch(text) {
54
+ this._search = typeof text === 'string' ? text.trim() : '';
55
+ this._offset = 0;
56
+ this._emitFetch();
57
+ }
58
+
59
+ // подгружает следующую страницу
60
+ loadMore() {
61
+ this._offset += this._pageSize;
62
+ this._emitFetch();
63
+ }
64
+
65
+ // применяет ответ REST; append=false заменяет список, true — дополняет
66
+ setList({ total, servers } = {}, append = false) {
67
+ if (!append) {
68
+ this._servers.clear();
69
+ this._order = [];
70
+ }
71
+
72
+ this._total = total || 0;
73
+
74
+ (servers || []).forEach(server => {
75
+ if (!this._servers.has(server.hostId)) {
76
+ this._order.push(server.hostId);
77
+ }
78
+
79
+ // latency живёт в _latencies и переживает refresh/пагинацию
80
+ this._servers.set(server.hostId, {
81
+ ...server,
82
+ latency: this._latencies.get(server.hostId) ?? null,
83
+ });
84
+ });
85
+
86
+ this._emitList();
87
+ }
88
+
89
+ // пользователь выбрал сервер — во внешнего подписчика (P2P-подключение)
90
+ join(hostId) {
91
+ if (this._servers.has(hostId)) {
92
+ this.publisher.emit('join', hostId);
93
+ }
94
+ }
95
+
96
+ // готовит пинг видимого сервера; false — если пинговали недавно/сервер ушёл
97
+ pingHost(hostId, now) {
98
+ const last = this._lastPing.get(hostId);
99
+
100
+ if (last !== undefined && now - last < this._pingInterval) {
101
+ return false;
102
+ }
103
+
104
+ if (!this._servers.has(hostId)) {
105
+ return false;
106
+ }
107
+
108
+ this._lastPing.set(hostId, now);
109
+ this._pingCounter = (this._pingCounter + 1) >>> 0;
110
+
111
+ const pingId = this._pingCounter;
112
+
113
+ this._pending.set(pingId, { hostId, time: now });
114
+ this.publisher.emit('ping-request', { hostId, pingId });
115
+
116
+ return true;
117
+ }
118
+
119
+ // pong: считает приблизительную задержку, обновляет карточку
120
+ resolvePong(pingId, now) {
121
+ const pending = this._pending.get(pingId);
122
+
123
+ if (!pending) {
124
+ return;
125
+ }
126
+
127
+ this._pending.delete(pingId);
128
+
129
+ const server = this._servers.get(pending.hostId);
130
+
131
+ if (!server) {
132
+ return;
133
+ }
134
+
135
+ server.latency = Math.round(now - pending.time);
136
+ this._latencies.set(pending.hostId, server.latency);
137
+
138
+ this.publisher.emit('ping-update', {
139
+ hostId: pending.hostId,
140
+ latency: server.latency,
141
+ });
142
+ }
143
+
144
+ // топ-N рейтинга игры, применяется ответом GET /auth/leaderboard
145
+ setLeaderboard({ leaderboard, total } = {}) {
146
+ this._leaderboard = leaderboard || [];
147
+ this._leaderboardTotal = total || 0;
148
+ this._leaderboardLoaded = true;
149
+
150
+ this._scheduleLeaderboardEmit();
151
+ }
152
+
153
+ // позиция вызывающего, применяется ответом GET /auth/placement;
154
+ // placement === null — игрок ещё не ранжирован в этой игре
155
+ setPlacement({ placement, total, rank } = {}) {
156
+ this._myPlacement = { placement: placement ?? null, total: total || 0, rank: rank || 0 };
157
+
158
+ this._scheduleLeaderboardEmit();
159
+ }
160
+
161
+ // сбрасывает leaderboard/placement (main.js вызывает это перед fetch'ем
162
+ // новой игры — code review M1: иначе данные предыдущей игры «залипают»
163
+ // под заголовком новой, пока не придёт ответ, а при сетевом сбое остаются
164
+ // навсегда)
165
+ clearLeaderboard() {
166
+ this._leaderboard = [];
167
+ this._leaderboardTotal = 0;
168
+ this._myPlacement = null;
169
+ this._leaderboardLoaded = false;
170
+
171
+ this._scheduleLeaderboardEmit();
172
+ }
173
+
174
+ reset() {
175
+ this._servers.clear();
176
+ this._order = [];
177
+ this._total = 0;
178
+ this._offset = 0;
179
+ this._search = '';
180
+ this._pending.clear();
181
+ this._lastPing.clear();
182
+ this._latencies.clear();
183
+ }
184
+
185
+ _emitFetch() {
186
+ this.publisher.emit('fetch', {
187
+ offset: this._offset,
188
+ limit: this._pageSize,
189
+ search: this._search,
190
+ append: this._offset > 0,
191
+ });
192
+ }
193
+
194
+ _emitList() {
195
+ this.publisher.emit('list', {
196
+ servers: this._order.map(id => this._servers.get(id)),
197
+ hasMore: this._order.length < this._total,
198
+ });
199
+ }
200
+
201
+ // code review M2: setLeaderboard/setPlacement обычно приходят из одного
202
+ // Promise.all в main.js — без коалесинга каждый эмитил бы 'leaderboard'
203
+ // отдельно, и первый рендер уходил бы с новым списком, но ещё старым
204
+ // myPlacement. queueMicrotask (изоморфное API, разрешено в этом модуле)
205
+ // схлопывает оба вызова в один эмит на конец текущего таска
206
+ _scheduleLeaderboardEmit() {
207
+ if (this._leaderboardEmitScheduled) {
208
+ return;
209
+ }
210
+
211
+ this._leaderboardEmitScheduled = true;
212
+ queueMicrotask(() => {
213
+ this._leaderboardEmitScheduled = false;
214
+ this._emitLeaderboard();
215
+ });
216
+ }
217
+
218
+ _emitLeaderboard() {
219
+ this.publisher.emit('leaderboard', {
220
+ leaderboard: this._leaderboard,
221
+ total: this._leaderboardTotal,
222
+ myPlacement: this._myPlacement,
223
+ loaded: this._leaderboardLoaded,
224
+ });
225
+ }
226
+ }
@@ -0,0 +1,161 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+ import { decodeJwtPayload } from '../../../lib/jwt.js';
3
+
4
+ // Singleton LobbyAuthModel
5
+
6
+ let lobbyAuthModel;
7
+
8
+ // Модель логина лобби (Этап B2): OAuth-редирект на central auth-сервис
9
+ // (packages/auth), выбор ника при первом входе, identity JWT в localStorage.
10
+ // Хранит состояние сессии и делает единственный сетевой вызов сама (POST
11
+ // /nick) — в отличие от Lobby/Auth-моделей это простой fetch без сигнального
12
+ // сокета, отдельный REST-канал к другому домену.
13
+ export default class LobbyAuthModel {
14
+ constructor(config) {
15
+ if (lobbyAuthModel) {
16
+ return lobbyAuthModel;
17
+ }
18
+
19
+ lobbyAuthModel = this;
20
+
21
+ this._config = config;
22
+ this._pendingToken = null;
23
+ this._identityToken = null;
24
+ this._nick = null;
25
+
26
+ this.publisher = new Publisher();
27
+ }
28
+
29
+ // разбирает query-параметры OAuth-редиректа (?token=/?pendingToken=/
30
+ // ?authError=); при их отсутствии — восстанавливает сессию из localStorage.
31
+ // Возвращает true, если параметры были найдены и вызывающий должен
32
+ // подчистить адресную строку (history.replaceState)
33
+ boot(search) {
34
+ const { queryParams, tokenStorageKey } = this._config;
35
+ const params = new URLSearchParams(search);
36
+
37
+ const error = params.get(queryParams.error);
38
+ const token = params.get(queryParams.token);
39
+ const pendingToken = params.get(queryParams.pendingToken);
40
+
41
+ if (error) {
42
+ this.publisher.emit('login-error', error);
43
+ this._restore(tokenStorageKey);
44
+ return true;
45
+ }
46
+
47
+ if (token) {
48
+ this._setIdentity(token, tokenStorageKey);
49
+ return true;
50
+ }
51
+
52
+ if (pendingToken) {
53
+ this._pendingToken = pendingToken;
54
+ this.publisher.emit('nick-required');
55
+ return true;
56
+ }
57
+
58
+ this._restore(tokenStorageKey);
59
+ return false;
60
+ }
61
+
62
+ // URL редиректа на старт OAuth-потока auth-сервиса; вызывающий должен сам
63
+ // сделать переход (window.location.href) — модель не трогает location
64
+ loginUrl(provider) {
65
+ const returnUrl = `${window.location.origin}${window.location.pathname}`;
66
+
67
+ return `${this._config.serviceUrl}/oauth/${provider}/start?returnUrl=${encodeURIComponent(returnUrl)}`;
68
+ }
69
+
70
+ async submitNick(nick) {
71
+ if (!this._pendingToken) {
72
+ this.publisher.emit('nick-error', 'sessionExpired');
73
+ return;
74
+ }
75
+
76
+ let res;
77
+ let data;
78
+
79
+ try {
80
+ res = await fetch(`${this._config.serviceUrl}/nick`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Content-Type': 'application/json',
84
+ Authorization: `Bearer ${this._pendingToken}`,
85
+ },
86
+ body: JSON.stringify({ nick }),
87
+ });
88
+ data = await res.json().catch(() => ({}));
89
+ } catch {
90
+ this.publisher.emit('nick-error', 'network');
91
+ return;
92
+ }
93
+
94
+ if (!res.ok) {
95
+ this.publisher.emit('nick-error', data.error || 'unknown');
96
+ return;
97
+ }
98
+
99
+ this._pendingToken = null;
100
+ this._setIdentity(data.token, this._config.tokenStorageKey);
101
+ }
102
+
103
+ logout() {
104
+ delete localStorage[this._config.tokenStorageKey];
105
+
106
+ this._identityToken = null;
107
+ this._nick = null;
108
+
109
+ this.publisher.emit('login-required', this._config.providers);
110
+ }
111
+
112
+ getToken() {
113
+ return this._identityToken;
114
+ }
115
+
116
+ getNick() {
117
+ return this._nick;
118
+ }
119
+
120
+ _restore(tokenStorageKey) {
121
+ const token = localStorage[tokenStorageKey];
122
+
123
+ if (token) {
124
+ this._setIdentity(token, tokenStorageKey, false);
125
+ } else {
126
+ this.publisher.emit('login-required', this._config.providers);
127
+ }
128
+ }
129
+
130
+ // payload JWT не проверяется подписью на клиенте (только для отображения
131
+ // ника) — авторитетная проверка по /jwks на хосте (Этап B3). exp
132
+ // проверяется (F5 кодревью) — иначе восстановленная из localStorage сессия
133
+ // показывает «залогинен» токеном, который хост уже отклонит при входе в игру
134
+ _setIdentity(token, tokenStorageKey, persist = true) {
135
+ const payload = decodeJwtPayload(token);
136
+ const isExpired = typeof payload?.exp === 'number' && Date.now() >= payload.exp * 1000;
137
+
138
+ if (!payload || !payload.nick || isExpired) {
139
+ delete localStorage[tokenStorageKey];
140
+
141
+ // Тихое восстановление из localStorage (persist === false): просроченный/
142
+ // битый токен — штатная ситуация возврата на сайт, не ошибка. login-error
143
+ // — только на интерактивном пути.
144
+ if (persist) {
145
+ this.publisher.emit('login-error', isExpired ? 'tokenExpired' : 'invalidToken');
146
+ }
147
+
148
+ this.publisher.emit('login-required', this._config.providers);
149
+ return;
150
+ }
151
+
152
+ this._identityToken = token;
153
+ this._nick = payload.nick;
154
+
155
+ if (persist) {
156
+ localStorage[tokenStorageKey] = token;
157
+ }
158
+
159
+ this.publisher.emit('authenticated', { nick: this._nick });
160
+ }
161
+ }
@@ -0,0 +1,70 @@
1
+ import Publisher from '../../../lib/Publisher.js';
2
+
3
+ // Singleton PanelModel
4
+
5
+ let panelModel;
6
+
7
+ export default class PanelModel {
8
+ // keys — маппинг wire-ключей на имена полей (h → health, wa → activeWeapon);
9
+ // fields — схема полей игры: форматирование задаёт type, а не имя поля
10
+ constructor(keys, fields = []) {
11
+ if (panelModel) {
12
+ return panelModel;
13
+ }
14
+
15
+ panelModel = this;
16
+
17
+ this._keys = keys;
18
+ this._types = Object.fromEntries(
19
+ fields.map(field => [field.name, field.type]),
20
+ );
21
+ this.publisher = new Publisher();
22
+ }
23
+
24
+ // обновляет данные панели пользователя
25
+ // данные - это массив из строковых значение с разделителем ":"
26
+ // [‘h:100’, ’t:54’, ’w1:39’, ‘wa:w1’]
27
+ // или без разделителя:
28
+ // [’t:54’, 't' ’w1’, ‘w2’]
29
+ // первое значение - название поля или действие:
30
+ // h - health
31
+ // t - time
32
+ // w - weapon
33
+ // wa - active weapon (действие сменить активное оружие)
34
+ // если второе значение есть, оно передаётся в поле,
35
+ // если нет, то поле скрывается
36
+ update(arr) {
37
+ for (let i = 0, len = arr.length; i < len; i += 1) {
38
+ const data = arr[i].split(':');
39
+ const name = this._keys[data[0]]; // h -> health, wa -> activeWeapon
40
+ let value = data[1];
41
+
42
+ if (!value) {
43
+ this.publisher.emit('hide', name);
44
+ } else {
45
+ // если смена активного оружия
46
+ // value здесь - это ключ оружия, например 'w1'
47
+ // нужно получить имя панели, например 'bullet'
48
+ if (name === 'activeWeapon') {
49
+ this.publisher.emit('activeWeapon', this._keys[value]);
50
+ } else {
51
+ value = Number(value);
52
+
53
+ if (this._types[name] === 'time') {
54
+ value = this.formatTime(value);
55
+ }
56
+
57
+ this.publisher.emit('data', { name, value });
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // задаёт формат времени
64
+ formatTime(time) {
65
+ const min = ~~(time / 60);
66
+ const sec = time % 60 || 0;
67
+
68
+ return sec < 10 ? `${min}:0${sec}` : `${min}:${sec}`;
69
+ }
70
+ }