@hezzlgames/sdk 1.1.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/README.md +415 -0
- package/bin/hezzl-sdk.mjs +150 -0
- package/demo/centre.html +481 -0
- package/demo/economy.json +8 -0
- package/demo/game.html +380 -0
- package/demo/hezzl.json +18 -0
- package/hezzl-sdk.js +767 -0
- package/index.cjs +7 -0
- package/index.d.ts +180 -0
- package/index.mjs +7 -0
- package/package.json +56 -0
package/hezzl-sdk.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hezzl Games SDK — договор игры с игровым центром.
|
|
3
|
+
*
|
|
4
|
+
* Один файл без зависимостей. Кладётся в папку игры и подключается
|
|
5
|
+
* до её кода:
|
|
6
|
+
*
|
|
7
|
+
* <script src="hezzl-sdk.js"></script>
|
|
8
|
+
*
|
|
9
|
+
* С нашего домена его не подключают: игра обязана работать по прямой
|
|
10
|
+
* ссылке и без сети, а обращения на сторонние домены запрещены
|
|
11
|
+
* требованиями. Обновление SDK приезжает вместе с новой сборкой игры.
|
|
12
|
+
*
|
|
13
|
+
* Что он берёт на себя:
|
|
14
|
+
* — разбор настроек из адреса;
|
|
15
|
+
* — проверку источника сообщений и разговор с центром;
|
|
16
|
+
* — рукопожатие и определение, есть ли центр рядом;
|
|
17
|
+
* — хранилище: через центр, а без него — своё, с префиксом;
|
|
18
|
+
* — дисциплину частоты сообщений;
|
|
19
|
+
* — валюту центра: баланс, товары, покупку, списание и кошелёк;
|
|
20
|
+
* — window.HezzlGame для игр на нашем домене.
|
|
21
|
+
*
|
|
22
|
+
* Полные требования — docs/GAME-REQUIREMENTS.md. Ссылки на разделы
|
|
23
|
+
* в комментариях ведут туда.
|
|
24
|
+
*/
|
|
25
|
+
(function (root) {
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
var SDK = 1; /* версия протокола, раздел 3.2 */
|
|
29
|
+
var VERSION = '1.1.0'; /* версия пакета @hezzlgames/sdk; растёт на каждый выпуск */
|
|
30
|
+
var ACK_WAIT = 700; /* сколько ждём ответа центра, 3.8 */
|
|
31
|
+
var BUDGET = 10; /* сообщений в секунду, 3.4 */
|
|
32
|
+
var SCORE_GAP = 1000; /* не чаще раза в секунду, 3.4 */
|
|
33
|
+
|
|
34
|
+
/* Команды, которые центр может прислать. Имена — из таблицы 3.1
|
|
35
|
+
и ниоткуда больше: синонимов у них нет. */
|
|
36
|
+
var COMMANDS = ['mute', 'haptics', 'pause', 'resume', 'restart',
|
|
37
|
+
'setlang', 'settheme', 'setaccent'];
|
|
38
|
+
|
|
39
|
+
/* Сообщения центра, которые командами не являются: он их шлёт сам,
|
|
40
|
+
а не в ответ, и в supports игры они не попадают (3.1). */
|
|
41
|
+
/* signincancelled — ответ на signin, когда человек закрыл экран, так
|
|
42
|
+
и не войдя. Без него молчание центра неотличимо от «ещё думает»,
|
|
43
|
+
и игра остаётся с кнопкой ожидания, которая никогда не погаснет. */
|
|
44
|
+
var NOTICES = ['signedin', 'signedout', 'signincancelled', 'profile',
|
|
45
|
+
'balance', 'walletclosed'];
|
|
46
|
+
|
|
47
|
+
/* Просьбы, на которые центр отвечает сообщением того же имени (3.8).
|
|
48
|
+
Ответ узнаётся по rid; без rid — как ответ на последнюю просьбу
|
|
49
|
+
того же типа. balance и wallet центр шлёт и сам, без просьбы:
|
|
50
|
+
тогда это уведомление, и оно уходит в обработчик игры. */
|
|
51
|
+
var ANSWERED = ['auth', 'profile', 'balance', 'goods', 'buy', 'spend', 'wallet'];
|
|
52
|
+
|
|
53
|
+
/* Сколько ждём ответа. Обычная просьба — пять секунд. Денежная —
|
|
54
|
+
дольше: центр ходит на сервер, а потом ещё и разговаривает
|
|
55
|
+
с человеком. На время разговора срок снимается вовсе (hold). */
|
|
56
|
+
var ASK_WAIT = 5000;
|
|
57
|
+
var MONEY_WAIT = 30000;
|
|
58
|
+
|
|
59
|
+
/* Соответствие имени умения методу window.HezzlGame. Выводится
|
|
60
|
+
по правилу «то же имя в camelCase», кроме двух случаев: звук
|
|
61
|
+
и вибрация названы по предмету, а методы у них сеттеры. */
|
|
62
|
+
var METHOD = {
|
|
63
|
+
mute: 'setMuted', haptics: 'setHaptics',
|
|
64
|
+
pause: 'pause', resume: 'resume', restart: 'restart',
|
|
65
|
+
setlang: 'setLang', settheme: 'setTheme', setaccent: 'setAccent'
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
var game = null; /* что игра сказала о себе в init */
|
|
69
|
+
var handlers = {}; /* её обработчики */
|
|
70
|
+
var listeners = {}; /* подписки через Hezzl.on */
|
|
71
|
+
var centre = null; /* окно центра, если мы в кадре */
|
|
72
|
+
var mode = 'unknown'; /* unknown | centre | standalone */
|
|
73
|
+
var centreCan = []; /* что умеет центр, из ack */
|
|
74
|
+
var started = false; /* звали ли ready */
|
|
75
|
+
var waiting = []; /* команды, пришедшие до готовности */
|
|
76
|
+
var pending = {}; /* просьбы, ждущие ответа, по rid */
|
|
77
|
+
var afterMode = []; /* что отложено до определения режима */
|
|
78
|
+
var purse = null; /* последнее известное о валюте центра, 7.9 */
|
|
79
|
+
var debug = false;
|
|
80
|
+
|
|
81
|
+
/* ── мелочи ──────────────────────────────────────────────────────── */
|
|
82
|
+
|
|
83
|
+
function has(list, one) {
|
|
84
|
+
for (var i = 0; i < list.length; i++) if (list[i] === one) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function note() {
|
|
89
|
+
/* В собранной игре отладочного вывода быть не должно (раздел 38):
|
|
90
|
+
консоль в кадре общая с центром. Включается только явно. */
|
|
91
|
+
if (!debug || !root.console || !console.info) return;
|
|
92
|
+
try { console.info.apply(console, ['[hezzl-sdk]'].concat([].slice.call(arguments))); } catch (e) {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function warn(what) {
|
|
96
|
+
if (!root.console || !console.warn) return;
|
|
97
|
+
try { console.warn('[hezzl-sdk] ' + what); } catch (e) {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rid() {
|
|
101
|
+
return 'r' + Math.random().toString(36).slice(2, 10);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ── настройки из адреса ─────────────────────────────────────────── */
|
|
105
|
+
|
|
106
|
+
/* Отсутствие параметра — осмысленный ответ, а не упущение (раздел 2):
|
|
107
|
+
человек оставил настройку на «Системе». Поэтому пустая строка,
|
|
108
|
+
а не подставленное значение по умолчанию. */
|
|
109
|
+
function fromUrl() {
|
|
110
|
+
var q = {};
|
|
111
|
+
try {
|
|
112
|
+
var raw = root.location.search.replace(/^\?/, '').split('&');
|
|
113
|
+
for (var i = 0; i < raw.length; i++) {
|
|
114
|
+
var pair = raw[i].split('=');
|
|
115
|
+
if (!pair[0]) continue;
|
|
116
|
+
q[decodeURIComponent(pair[0]).toLowerCase()] = decodeURIComponent((pair[1] || '').replace(/\+/g, ' '));
|
|
117
|
+
}
|
|
118
|
+
} catch (e) {}
|
|
119
|
+
|
|
120
|
+
var lang = q.lang === 'ru' || q.lang === 'en' ? q.lang : '';
|
|
121
|
+
var theme = q.theme === 'light' || q.theme === 'dark' ? q.theme : '';
|
|
122
|
+
var accent = /^[0-9a-f]{6}$/i.test(q.accent || '') ? q.accent.toLowerCase() : '';
|
|
123
|
+
/* Метка партнёра уходит в базу игры и в отчёт по комиссии (раздел 2).
|
|
124
|
+
Это ровно тот путь, по которому в отчёт попадает чужой текст, —
|
|
125
|
+
проверяем маской до того, как отдать игре. */
|
|
126
|
+
var ref = /^[A-Za-z0-9_-]{1,32}$/.test(q.ref || '') ? q.ref : '';
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
lang: lang,
|
|
130
|
+
theme: theme,
|
|
131
|
+
accent: accent,
|
|
132
|
+
muted: q.muted === '1',
|
|
133
|
+
haptics: q.haptics === '0' ? false : true,
|
|
134
|
+
ref: ref
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var settings = fromUrl();
|
|
139
|
+
|
|
140
|
+
/* ── отправка ────────────────────────────────────────────────────── */
|
|
141
|
+
|
|
142
|
+
/* Ведро на десять сообщений в секунду (3.4). Игра, шлющая событие
|
|
143
|
+
на каждый ход в каскаде, создаёт очередь, которую центр разгребает
|
|
144
|
+
между кадрами; лишнее отбрасываем здесь, а не там. */
|
|
145
|
+
var spent = [];
|
|
146
|
+
|
|
147
|
+
function affordable() {
|
|
148
|
+
var now = Date.now();
|
|
149
|
+
while (spent.length && now - spent[0] > 1000) spent.shift();
|
|
150
|
+
if (spent.length >= BUDGET) return false;
|
|
151
|
+
spent.push(now);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function post(type, payload, extra) {
|
|
156
|
+
if (!centre) return false;
|
|
157
|
+
var msg = {
|
|
158
|
+
source: 'hezzl-game',
|
|
159
|
+
sdk: SDK,
|
|
160
|
+
game: game ? game.id : '',
|
|
161
|
+
version: game ? game.version : '',
|
|
162
|
+
type: type,
|
|
163
|
+
payload: payload || {}
|
|
164
|
+
};
|
|
165
|
+
if (extra) for (var key in extra) if (extra.hasOwnProperty(key)) msg[key] = extra[key];
|
|
166
|
+
try {
|
|
167
|
+
/* Адрес получателя не сужаем: центр бывает и на нашем домене,
|
|
168
|
+
и внутри страницы партнёра (3.2). Поэтому в сообщениях нет
|
|
169
|
+
ничего личного — только про игру. */
|
|
170
|
+
centre.postMessage(msg, '*');
|
|
171
|
+
note('->', type, payload);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (e) { return false; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function send(type, payload, extra) {
|
|
177
|
+
if (!affordable()) { note('пропущено по лимиту:', type); return false; }
|
|
178
|
+
return post(type, payload, extra);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ── счёт ────────────────────────────────────────────────────────── */
|
|
182
|
+
|
|
183
|
+
var lastScoreAt = 0;
|
|
184
|
+
var heldScore = null;
|
|
185
|
+
var scoreTimer = null;
|
|
186
|
+
|
|
187
|
+
function flushScore() {
|
|
188
|
+
if (scoreTimer) { clearTimeout(scoreTimer); scoreTimer = null; }
|
|
189
|
+
if (!heldScore) return;
|
|
190
|
+
lastScoreAt = Date.now();
|
|
191
|
+
send('score', heldScore);
|
|
192
|
+
heldScore = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* ── приём ───────────────────────────────────────────────────────── */
|
|
196
|
+
|
|
197
|
+
function dispatch(name, data) {
|
|
198
|
+
var fn = handlers[name];
|
|
199
|
+
var subs = listeners[name] || [];
|
|
200
|
+
var value = valueOf(name, data);
|
|
201
|
+
if (fn) { try { fn(value, data); } catch (e) { warn('обработчик ' + name + ' упал: ' + e); } }
|
|
202
|
+
for (var i = 0; i < subs.length; i++) {
|
|
203
|
+
try { subs[i](value, data); } catch (e) { warn('подписка ' + name + ' упала: ' + e); }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Первым доводом отдаём то, ради чего команду и прислали: флаг,
|
|
208
|
+
язык, цвет. Целое сообщение остаётся вторым — на случай полей,
|
|
209
|
+
которых сегодня нет. */
|
|
210
|
+
function valueOf(name, data) {
|
|
211
|
+
if (name === 'mute') return !!data.muted;
|
|
212
|
+
if (name === 'haptics') return !!data.enabled;
|
|
213
|
+
if (name === 'setlang') return data.lang;
|
|
214
|
+
if (name === 'settheme') return data.theme;
|
|
215
|
+
if (name === 'setaccent') return data.accent;
|
|
216
|
+
if (name === 'signedin') return data.userId;
|
|
217
|
+
if (name === 'profile') return data.profile || null;
|
|
218
|
+
if (name === 'balance' || name === 'walletclosed') {
|
|
219
|
+
return typeof data.amount === 'number' ? data.amount : null;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function handle(name, data) {
|
|
225
|
+
/* Неизвестный тип игнорируется молча (3.3): протокол будет расти,
|
|
226
|
+
и игра, падающая на незнакомом сообщении, сломается на следующем
|
|
227
|
+
обновлении центра. */
|
|
228
|
+
if (name === 'ack') { settle(data); return; }
|
|
229
|
+
|
|
230
|
+
if (has(ANSWERED, name)) { answer(name, data); return; }
|
|
231
|
+
|
|
232
|
+
if (name === 'storage') { storageAnswer(data); return; }
|
|
233
|
+
|
|
234
|
+
if (!has(COMMANDS, name) && !has(NOTICES, name)) return;
|
|
235
|
+
|
|
236
|
+
/* Команда может прийти раньше, чем игра готова (3.3). Складываем
|
|
237
|
+
в очередь: молча терять нельзя. */
|
|
238
|
+
if (!started) { waiting.push([name, data]); return; }
|
|
239
|
+
dispatch(name, data);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Ответ на просьбу. Сначала — кому он адресован, потом — что в нём
|
|
243
|
+
нового для всех: баланс мог измениться, кошелёк мог закрыться. */
|
|
244
|
+
function answer(name, data) {
|
|
245
|
+
var wait = data.rid ? pending[data.rid] : lastPending(name);
|
|
246
|
+
|
|
247
|
+
if (wait && data.hold) {
|
|
248
|
+
/* Центр занят разговором с человеком: подтверждает списание,
|
|
249
|
+
показал кошелёк, поднял вход. Срок ожидания снимаем — ответ
|
|
250
|
+
придёт, когда человек решит, а решает он не за пять секунд. */
|
|
251
|
+
if (wait.timer) { clearTimeout(wait.timer); wait.timer = null; }
|
|
252
|
+
wait.hold = data.hold;
|
|
253
|
+
note('центр держит', name, 'по причине', data.hold);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
var changed = remember(data);
|
|
258
|
+
|
|
259
|
+
if (wait) {
|
|
260
|
+
delete pending[wait.rid];
|
|
261
|
+
if (wait.timer) clearTimeout(wait.timer);
|
|
262
|
+
if (data.ok === false) wait.reject(refuseWith(data));
|
|
263
|
+
else wait.resolve(data);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* Ответа никто не ждал — значит центр прислал сам: человек
|
|
267
|
+
вошёл, переименовался или сменил картинку (7.8); пополнил
|
|
268
|
+
баланс в кошельке или получил награду за задание (7.9). */
|
|
269
|
+
if (name === 'profile') notify('profile', data);
|
|
270
|
+
if (name === 'wallet') notify('walletclosed', data);
|
|
271
|
+
if (name === 'balance' ? (!wait || changed) : changed) notify('balance', data);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* Уведомление может прийти раньше готовности, как и команда (3.3):
|
|
275
|
+
складываем, не теряем. */
|
|
276
|
+
function notify(name, data) {
|
|
277
|
+
if (!started) { waiting.push([name, data]); return; }
|
|
278
|
+
dispatch(name, data);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* Что известно о валюте центра: ключ, название, значок — из ack;
|
|
282
|
+
сумма — из каждого ответа, где она есть. Возвращает, изменилась
|
|
283
|
+
ли сумма: по этому игра получает уведомление balance один раз,
|
|
284
|
+
а не на каждое сообщение. */
|
|
285
|
+
function remember(data) {
|
|
286
|
+
if (!data) return false;
|
|
287
|
+
var before = purse ? purse.amount : null;
|
|
288
|
+
if (data.currency && typeof data.currency === 'object') {
|
|
289
|
+
purse = purse || { amount: null };
|
|
290
|
+
for (var key in data.currency) {
|
|
291
|
+
if (data.currency.hasOwnProperty(key) && key !== 'amount') purse[key] = data.currency[key];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (typeof data.amount === 'number') {
|
|
295
|
+
purse = purse || {};
|
|
296
|
+
purse.amount = data.amount;
|
|
297
|
+
}
|
|
298
|
+
return !!purse && purse.amount !== before;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function lastPending(type) {
|
|
302
|
+
/* Ответ без rid обрабатывается как ответ на последнюю просьбу того
|
|
303
|
+
же типа (3.8). */
|
|
304
|
+
var found = null;
|
|
305
|
+
for (var key in pending) {
|
|
306
|
+
if (!pending.hasOwnProperty(key)) continue;
|
|
307
|
+
if (pending[key].type === type && (!found || pending[key].at > found.at)) found = pending[key];
|
|
308
|
+
}
|
|
309
|
+
return found;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* Вне браузера — сборка, тесты, серверный рендер — слушать нечего:
|
|
313
|
+
пакет должен импортироваться, а не падать на первой строке. */
|
|
314
|
+
if (typeof root.addEventListener === 'function') root.addEventListener('message', function (event) {
|
|
315
|
+
/* Две проверки, и первая обязательна отдельно от второй (3.2).
|
|
316
|
+
Условие «parent !== window && source !== parent» при прямом заходе
|
|
317
|
+
вырождается в false и пропускает любое сообщение от любого скрипта
|
|
318
|
+
на странице — то есть защита отключается ровно там, ради чего
|
|
319
|
+
писалась. */
|
|
320
|
+
if (root.parent === root) return;
|
|
321
|
+
if (event.source !== root.parent) return;
|
|
322
|
+
|
|
323
|
+
var data = event.data;
|
|
324
|
+
if (!data || typeof data.type !== 'string') return;
|
|
325
|
+
if (data.type.indexOf('hezzl:') !== 0) return;
|
|
326
|
+
note('<-', data.type, data);
|
|
327
|
+
handle(data.type.slice(6), data);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
/* ── рукопожатие ─────────────────────────────────────────────────── */
|
|
331
|
+
|
|
332
|
+
var ackTimer = null;
|
|
333
|
+
|
|
334
|
+
function settle(data) {
|
|
335
|
+
if (mode !== 'unknown') return;
|
|
336
|
+
if (ackTimer) { clearTimeout(ackTimer); ackTimer = null; }
|
|
337
|
+
mode = 'centre';
|
|
338
|
+
centreCan = (data && data.centreSupports) || [];
|
|
339
|
+
/* Валюта центра описывается в рукопожатии (7.9): игра рисует
|
|
340
|
+
значок и название до первого обращения за балансом. */
|
|
341
|
+
if (data) remember({ currency: data.currency });
|
|
342
|
+
note('центр рядом, умеет:', centreCan.join(', '));
|
|
343
|
+
drain();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function alone(why) {
|
|
347
|
+
if (mode !== 'unknown') return;
|
|
348
|
+
if (ackTimer) { clearTimeout(ackTimer); ackTimer = null; }
|
|
349
|
+
mode = 'standalone';
|
|
350
|
+
centre = null;
|
|
351
|
+
centreCan = [];
|
|
352
|
+
note('центра рядом нет:', why);
|
|
353
|
+
drain();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function drain() {
|
|
357
|
+
var queue = afterMode;
|
|
358
|
+
afterMode = [];
|
|
359
|
+
for (var i = 0; i < queue.length; i++) queue[i]();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* ── хранилище ───────────────────────────────────────────────────── */
|
|
363
|
+
|
|
364
|
+
/* Отказ типизирован (7.6): игра ветвится по reason, а не по разбору
|
|
365
|
+
строки. Значения ровно четыре. */
|
|
366
|
+
function refuse(reason, why) {
|
|
367
|
+
var error = new Error(why || reason);
|
|
368
|
+
error.reason = reason;
|
|
369
|
+
return error;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* Отказ центра по денежной просьбе: причина в error, рядом — сколько
|
|
373
|
+
на балансе и сколько не хватило, чтобы игра сказала человеку
|
|
374
|
+
число, а не «недостаточно». */
|
|
375
|
+
function refuseWith(data) {
|
|
376
|
+
var error = refuse(data.error || 'unknown', data.message);
|
|
377
|
+
if (typeof data.amount === 'number') error.amount = data.amount;
|
|
378
|
+
if (typeof data.need === 'number') error.need = data.need;
|
|
379
|
+
return error;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function prefix() {
|
|
383
|
+
return 'hezzl.' + (game && game.id ? game.id : 'game') + '.';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function local() {
|
|
387
|
+
try {
|
|
388
|
+
var probe = root.localStorage;
|
|
389
|
+
probe.setItem('hezzl.probe', '1');
|
|
390
|
+
probe.removeItem('hezzl.probe');
|
|
391
|
+
return probe;
|
|
392
|
+
} catch (e) { return null; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function quotaish(e) {
|
|
396
|
+
/* У переполнения разные имена в разных браузерах, а у Safari
|
|
397
|
+
в приватном режиме — свой код. Отличать его от прочих отказов
|
|
398
|
+
обязательно: на квоту игра отвечает освобождением места, а на
|
|
399
|
+
недоступность — сообщением человеку. */
|
|
400
|
+
if (!e) return false;
|
|
401
|
+
return e.name === 'QuotaExceededError'
|
|
402
|
+
|| e.name === 'NS_ERROR_DOM_QUOTA_REACHED'
|
|
403
|
+
|| e.code === 22 || e.code === 1014;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
var storeWaiting = {}; /* запросы к центру, по rid */
|
|
407
|
+
|
|
408
|
+
function storageAnswer(data) {
|
|
409
|
+
var wait = data.rid && storeWaiting[data.rid];
|
|
410
|
+
if (!wait) return;
|
|
411
|
+
delete storeWaiting[data.rid];
|
|
412
|
+
if (data.error) wait.reject(refuse(data.error, data.message));
|
|
413
|
+
else wait.resolve(data.value === undefined ? null : data.value);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function viaCentre(op, key, value, opts) {
|
|
417
|
+
return new Promise(function (resolve, reject) {
|
|
418
|
+
var id = rid();
|
|
419
|
+
storeWaiting[id] = { resolve: resolve, reject: reject };
|
|
420
|
+
var payload = { op: op, key: key };
|
|
421
|
+
if (value !== undefined) payload.value = value;
|
|
422
|
+
if (opts) {
|
|
423
|
+
payload.sync = !!opts.sync;
|
|
424
|
+
if (opts.schema !== undefined) payload.schema = opts.schema;
|
|
425
|
+
}
|
|
426
|
+
if (!post('storage', payload, { rid: id })) {
|
|
427
|
+
delete storeWaiting[id];
|
|
428
|
+
reject(refuse('unavailable', 'центр не принял запрос'));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
/* Ответа может не быть — центр старой версии или ушёл. Ждать
|
|
432
|
+
вечно нельзя: игра держит на этом промисе загрузку прогресса. */
|
|
433
|
+
setTimeout(function () {
|
|
434
|
+
if (!storeWaiting[id]) return;
|
|
435
|
+
delete storeWaiting[id];
|
|
436
|
+
reject(refuse('unknown', 'центр не ответил'));
|
|
437
|
+
}, 5000);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function viaLocal(op, key, value) {
|
|
442
|
+
return new Promise(function (resolve, reject) {
|
|
443
|
+
var box = local();
|
|
444
|
+
if (!box) { reject(refuse('unavailable', 'хранилище недоступно')); return; }
|
|
445
|
+
var full = prefix() + key;
|
|
446
|
+
try {
|
|
447
|
+
if (op === 'get') {
|
|
448
|
+
var raw = box.getItem(full);
|
|
449
|
+
resolve(raw === null ? null : JSON.parse(raw));
|
|
450
|
+
} else if (op === 'set') {
|
|
451
|
+
box.setItem(full, JSON.stringify(value));
|
|
452
|
+
resolve();
|
|
453
|
+
} else if (op === 'remove') {
|
|
454
|
+
box.removeItem(full);
|
|
455
|
+
resolve();
|
|
456
|
+
} else if (op === 'keys') {
|
|
457
|
+
var out = [], mark = prefix();
|
|
458
|
+
for (var i = 0; i < box.length; i++) {
|
|
459
|
+
var name = box.key(i);
|
|
460
|
+
if (name && name.indexOf(mark) === 0) out.push(name.slice(mark.length));
|
|
461
|
+
}
|
|
462
|
+
resolve(out);
|
|
463
|
+
}
|
|
464
|
+
} catch (e) {
|
|
465
|
+
if (quotaish(e)) reject(refuse('quota', 'место кончилось'));
|
|
466
|
+
else if (e instanceof SyntaxError) reject(refuse('invalid', 'сохранение повреждено'));
|
|
467
|
+
else reject(refuse('unknown', String(e && e.message || e)));
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function storage(op, key, value, opts) {
|
|
473
|
+
if (op !== 'keys' && (typeof key !== 'string' || !key)) {
|
|
474
|
+
return Promise.reject(refuse('invalid', 'ключ должен быть непустой строкой'));
|
|
475
|
+
}
|
|
476
|
+
/* До рукопожатия хранилище уже работает (7.2): вызовы копятся
|
|
477
|
+
и разрешаются, как только известен режим. Игра не ждёт ack,
|
|
478
|
+
чтобы прочитать прогресс, — иначе первые 700 мс уходят
|
|
479
|
+
в ожидание, а бюджет первого кадра их не предусматривает. */
|
|
480
|
+
if (mode === 'unknown') {
|
|
481
|
+
return new Promise(function (resolve, reject) {
|
|
482
|
+
afterMode.push(function () {
|
|
483
|
+
storage(op, key, value, opts).then(resolve, reject);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
if (mode === 'centre' && has(centreCan, 'storage')) return viaCentre(op, key, value, opts);
|
|
488
|
+
return viaLocal(op, key, value);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ── window.HezzlGame ────────────────────────────────────────────── */
|
|
492
|
+
|
|
493
|
+
/* Для игр на нашем домене центр проверяет умение свойством, не
|
|
494
|
+
дожидаясь рукопожатия (3.7). Имена методов — из таблицы 3.1. */
|
|
495
|
+
function expose() {
|
|
496
|
+
/* sdk в объекте — не украшение. По нему центр отличает игру,
|
|
497
|
+
собранную на этой библиотеке, от игры со своим объектом того же
|
|
498
|
+
имени: у Hezzl Merge, например, HezzlGame есть, а ready она
|
|
499
|
+
не шлёт, и ждать его от неё нельзя. */
|
|
500
|
+
var api = { sdk: SDK, id: game.id, version: game.version, supports: supports() };
|
|
501
|
+
for (var i = 0; i < COMMANDS.length; i++) {
|
|
502
|
+
var name = COMMANDS[i];
|
|
503
|
+
if (!handlers[name]) continue;
|
|
504
|
+
api[METHOD[name]] = (function (which) {
|
|
505
|
+
return function (arg) {
|
|
506
|
+
var data = {};
|
|
507
|
+
if (which === 'mute') data.muted = !!arg;
|
|
508
|
+
else if (which === 'haptics') data.enabled = !!arg;
|
|
509
|
+
else if (which === 'setlang') data.lang = arg;
|
|
510
|
+
else if (which === 'settheme') data.theme = arg;
|
|
511
|
+
else if (which === 'setaccent') data.accent = arg;
|
|
512
|
+
dispatch(which, data);
|
|
513
|
+
};
|
|
514
|
+
})(name);
|
|
515
|
+
}
|
|
516
|
+
root.HezzlGame = api;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/* Умения выводим из того, что игра действительно обрабатывает.
|
|
520
|
+
Объявить mute и не реализовать его нельзя по построению —
|
|
521
|
+
а это самое частое расхождение между манифестом и поведением. */
|
|
522
|
+
function supports() {
|
|
523
|
+
var out = [];
|
|
524
|
+
for (var i = 0; i < COMMANDS.length; i++) {
|
|
525
|
+
if (handlers[COMMANDS[i]]) out.push(COMMANDS[i]);
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/* ── просьбы ─────────────────────────────────────────────────────── */
|
|
531
|
+
|
|
532
|
+
function ask(type, payload, money) {
|
|
533
|
+
return new Promise(function (resolve, reject) {
|
|
534
|
+
if (mode === 'standalone') { reject(refuse('unavailable', 'центра рядом нет')); return; }
|
|
535
|
+
var id = rid();
|
|
536
|
+
var wait = { rid: id, type: type, at: Date.now(), resolve: resolve, reject: reject, timer: null, hold: '' };
|
|
537
|
+
pending[id] = wait;
|
|
538
|
+
/* Денежные просьбы идут мимо ведра (3.4): человек нажал «купить»,
|
|
539
|
+
и потерять это нажатие из-за всплеска score — худший размен.
|
|
540
|
+
От злоупотребления защищает не ведро, а подтверждение
|
|
541
|
+
в центре (7.9). */
|
|
542
|
+
if (!(money ? post : send)(type, payload, { rid: id })) {
|
|
543
|
+
delete pending[id];
|
|
544
|
+
reject(refuse('unavailable', 'сообщение не ушло'));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
wait.timer = setTimeout(function () {
|
|
548
|
+
if (!pending[id]) return;
|
|
549
|
+
delete pending[id];
|
|
550
|
+
reject(refuse('unknown', 'центр не ответил'));
|
|
551
|
+
}, money ? MONEY_WAIT : ASK_WAIT);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* ── валюта центра ───────────────────────────────────────────────── */
|
|
556
|
+
|
|
557
|
+
/* Денежная просьба (7.9). До рукопожатия откладывается, как
|
|
558
|
+
хранилище: игра вправе спросить баланс сразу после init. Центр
|
|
559
|
+
без валюты отказывает сразу и типизированно — кнопку магазина
|
|
560
|
+
игра при этом не показывает вовсе, см. can('balance'). */
|
|
561
|
+
function ledger(type, payload) {
|
|
562
|
+
if (mode === 'unknown') {
|
|
563
|
+
return new Promise(function (resolve, reject) {
|
|
564
|
+
afterMode.push(function () { ledger(type, payload).then(resolve, reject); });
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (mode === 'centre' && !has(centreCan, 'balance')) {
|
|
568
|
+
return Promise.reject(refuse('unavailable', 'у центра нет валюты'));
|
|
569
|
+
}
|
|
570
|
+
return ask(type, payload, true);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function wholeNumber(value) {
|
|
574
|
+
return typeof value === 'number' && isFinite(value) && value > 0 && Math.floor(value) === value;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function goodKey(value) {
|
|
578
|
+
if (wholeNumber(value)) return value;
|
|
579
|
+
if (typeof value === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(value)) return value;
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* ── наружу ──────────────────────────────────────────────────────── */
|
|
584
|
+
|
|
585
|
+
var Hezzl = {
|
|
586
|
+
sdk: SDK,
|
|
587
|
+
version: VERSION,
|
|
588
|
+
settings: settings,
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Рассказать о себе. Зовётся один раз, до ready.
|
|
592
|
+
* id, version — как в манифесте hezzl.json
|
|
593
|
+
* modes — ключи из реестра 20.1
|
|
594
|
+
* orientation — portrait | landscape | any
|
|
595
|
+
* on — обработчики команд центра
|
|
596
|
+
*/
|
|
597
|
+
init: function (about) {
|
|
598
|
+
if (game) { warn('init уже был'); return Hezzl; }
|
|
599
|
+
about = about || {};
|
|
600
|
+
if (!about.id) warn('не задан id игры — он нужен в каждом сообщении и в префиксе хранилища');
|
|
601
|
+
game = {
|
|
602
|
+
id: about.id || '',
|
|
603
|
+
version: about.version || '0.0.0',
|
|
604
|
+
modes: about.modes || [],
|
|
605
|
+
orientation: about.orientation || 'any'
|
|
606
|
+
};
|
|
607
|
+
debug = !!about.debug;
|
|
608
|
+
var on = about.on || {};
|
|
609
|
+
for (var name in on) {
|
|
610
|
+
if (on.hasOwnProperty(name) && typeof on[name] === 'function') handlers[name] = on[name];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
centre = root.parent && root.parent !== root ? root.parent : null;
|
|
614
|
+
if (!centre) alone('открыты напрямую');
|
|
615
|
+
expose();
|
|
616
|
+
return Hezzl;
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
/** Игра приняла ввод. После этого центр гасит заставку (раздел 5). */
|
|
620
|
+
ready: function (extra) {
|
|
621
|
+
if (started) return Hezzl;
|
|
622
|
+
if (!game) { warn('ready до init'); return Hezzl; }
|
|
623
|
+
started = true;
|
|
624
|
+
|
|
625
|
+
var payload = {
|
|
626
|
+
supports: supports(),
|
|
627
|
+
modes: game.modes,
|
|
628
|
+
lang: settings.lang || '',
|
|
629
|
+
orientation: game.orientation,
|
|
630
|
+
sdkVersion: VERSION /* какой пакет собран в игру — видно в статистике */
|
|
631
|
+
};
|
|
632
|
+
if (extra) for (var key in extra) if (extra.hasOwnProperty(key)) payload[key] = extra[key];
|
|
633
|
+
send('ready', payload);
|
|
634
|
+
|
|
635
|
+
if (centre && mode === 'unknown') {
|
|
636
|
+
/* Ответа нет за 700 мс — считаем, что центра рядом нет либо он
|
|
637
|
+
старой версии (3.8). Игра при этом уже работает: ждать
|
|
638
|
+
рукопожатия, чтобы принимать ввод, нельзя. */
|
|
639
|
+
ackTimer = setTimeout(function () { alone('ack не пришёл'); }, ACK_WAIT);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
var queue = waiting;
|
|
643
|
+
waiting = [];
|
|
644
|
+
for (var i = 0; i < queue.length; i++) dispatch(queue[i][0], queue[i][1]);
|
|
645
|
+
return Hezzl;
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
/** centre | standalone | unknown */
|
|
649
|
+
mode: function () { return mode; },
|
|
650
|
+
|
|
651
|
+
/** Умеет ли центр это: share, support, exit, auth, profile, storage, balance. */
|
|
652
|
+
can: function (what) { return mode === 'centre' && has(centreCan, what); },
|
|
653
|
+
|
|
654
|
+
/** Подписка на команду центра сверх той, что задана в init.on. */
|
|
655
|
+
on: function (name, fn) {
|
|
656
|
+
(listeners[name] = listeners[name] || []).push(fn);
|
|
657
|
+
return Hezzl;
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Порядок настройки: адрес → своя сохранённая → определение
|
|
662
|
+
* по браузеру (раздел 2). Пишется в каждой игре и в каждой
|
|
663
|
+
* ошибается, поэтому лежит здесь.
|
|
664
|
+
*/
|
|
665
|
+
resolve: function (name, saved, fallback) {
|
|
666
|
+
if (settings[name]) return settings[name];
|
|
667
|
+
if (saved) return saved;
|
|
668
|
+
return typeof fallback === 'function' ? fallback() : fallback;
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
/* события, раздел 3.4 */
|
|
672
|
+
gameStart: function (about) { send('game_start', about || {}); },
|
|
673
|
+
milestone: function (what, value) { send('milestone', { what: what, value: value }); },
|
|
674
|
+
levelComplete: function (about) { send('level_complete', about || {}); },
|
|
675
|
+
levelFailed: function (about) { send('level_failed', about || {}); },
|
|
676
|
+
|
|
677
|
+
score: function (value, about) {
|
|
678
|
+
var payload = { score: value };
|
|
679
|
+
if (about) for (var key in about) if (about.hasOwnProperty(key)) payload[key] = about[key];
|
|
680
|
+
heldScore = payload;
|
|
681
|
+
var since = Date.now() - lastScoreAt;
|
|
682
|
+
if (since >= SCORE_GAP) { flushScore(); return; }
|
|
683
|
+
/* Не чаще раза в секунду, но и не терять последнее: держим
|
|
684
|
+
значение и отправляем хвостом. */
|
|
685
|
+
if (!scoreTimer) scoreTimer = setTimeout(flushScore, SCORE_GAP - since);
|
|
686
|
+
},
|
|
687
|
+
|
|
688
|
+
gameOver: function (about) {
|
|
689
|
+
/* Финальный счёт — исключение из ограничения частоты (3.4).
|
|
690
|
+
Иначе центр покажет предпоследний результат, а человек свой
|
|
691
|
+
настоящий, и разойдутся они на самом заметном для него числе. */
|
|
692
|
+
flushScore();
|
|
693
|
+
send('gameover', about || {});
|
|
694
|
+
},
|
|
695
|
+
|
|
696
|
+
/* просьбы, раздел 3.5 */
|
|
697
|
+
share: function (about) { send('share', about || {}); },
|
|
698
|
+
support: function (about) { send('support', about || {}); },
|
|
699
|
+
exit: function () { send('exit', {}); },
|
|
700
|
+
signin: function () { send('signin', {}); },
|
|
701
|
+
auth: function () { return ask('auth', {}); },
|
|
702
|
+
profile: function (need) { return ask('profile', { need: need || ['name', 'avatar'] }); },
|
|
703
|
+
|
|
704
|
+
error: function (code, message, fatal) {
|
|
705
|
+
/* Отказы шлём мимо ведра: они редки, и потерять их из-за
|
|
706
|
+
всплеска обычных событий — худший размен. */
|
|
707
|
+
post('error', { code: code, message: message || '', fatal: !!fatal });
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
/* валюта центра, раздел 7.9 */
|
|
711
|
+
|
|
712
|
+
/** Последнее известное о валюте: { key, title, icon, amount } или null. */
|
|
713
|
+
currency: function () { return purse; },
|
|
714
|
+
|
|
715
|
+
/** Сколько у человека валюты центра. Ответ: { signedIn, amount, currency }. */
|
|
716
|
+
balance: function () { return ledger('balance', {}); },
|
|
717
|
+
|
|
718
|
+
/** Товары этой игры с ценами, как они заведены у нас. Ответ: { goods: [...] }. */
|
|
719
|
+
goods: function () { return ledger('goods', {}); },
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Купить товар из нашего каталога за валюту центра (кейс с сервером).
|
|
723
|
+
* Центр сам спросит подтверждение и сам покажет кошелёк, если
|
|
724
|
+
* не хватает; опция wallet: false отменяет второе.
|
|
725
|
+
* Ответ: { goodId, amount, award, issued }; отказ — по reason.
|
|
726
|
+
*/
|
|
727
|
+
buy: function (goodId, opts) {
|
|
728
|
+
var key = goodKey(goodId);
|
|
729
|
+
if (key === null) return Promise.reject(refuse('invalid', 'goodId — целое число или короткая строка'));
|
|
730
|
+
opts = opts || {};
|
|
731
|
+
return ledger('buy', { goodId: key, wallet: opts.wallet !== false });
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Списать валюту центра за то, что игра выдаёт сама (кейс без
|
|
736
|
+
* сервера). item — что куплено, для подтверждения и отчёта.
|
|
737
|
+
* Ответ: { amount, spent }; отказ — по reason.
|
|
738
|
+
*/
|
|
739
|
+
spend: function (amount, opts) {
|
|
740
|
+
if (!wholeNumber(amount)) return Promise.reject(refuse('invalid', 'amount — целое число больше нуля'));
|
|
741
|
+
opts = opts || {};
|
|
742
|
+
var payload = { amount: amount, item: typeof opts.item === 'string' ? opts.item.slice(0, 80) : '', wallet: opts.wallet !== false };
|
|
743
|
+
return ledger('spend', payload);
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Открыть кошелёк центра: где взять валюту. Разрешается, когда
|
|
748
|
+
* человек его закроет: { amount, bought }.
|
|
749
|
+
*/
|
|
750
|
+
wallet: function (opts) {
|
|
751
|
+
opts = opts || {};
|
|
752
|
+
var payload = {};
|
|
753
|
+
if (wholeNumber(opts.need)) payload.need = opts.need;
|
|
754
|
+
if (typeof opts.reason === 'string') payload.reason = opts.reason.slice(0, 80);
|
|
755
|
+
return ledger('wallet', payload);
|
|
756
|
+
},
|
|
757
|
+
|
|
758
|
+
storage: {
|
|
759
|
+
get: function (key) { return storage('get', key); },
|
|
760
|
+
set: function (key, value, opts) { return storage('set', key, value, opts); },
|
|
761
|
+
remove: function (key) { return storage('remove', key); },
|
|
762
|
+
keys: function () { return storage('keys'); }
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
root.Hezzl = Hezzl;
|
|
767
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this);
|