@usions/sdk 2.0.1
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 +144 -0
- package/package.json +37 -0
- package/src/browser.js +1793 -0
- package/src/index.js +55 -0
- package/types/index.d.ts +278 -0
package/src/browser.js
ADDED
|
@@ -0,0 +1,1793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usion Mini App SDK v2.0
|
|
3
|
+
*
|
|
4
|
+
* JavaScript utilities for Mini Apps (Iframe Games & Services)
|
|
5
|
+
* Import via: <script src="https://usions.com/usion-sdk.js"></script>
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - User info and authentication
|
|
9
|
+
* - Persistent storage (per-user, per-service)
|
|
10
|
+
* - Wallet/payment integration
|
|
11
|
+
* - Session management
|
|
12
|
+
* - Real-time game support via Socket.IO
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
(function(global) {
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
// Request ID counter for tracking async responses
|
|
19
|
+
let _requestId = 0;
|
|
20
|
+
const _pendingRequests = {};
|
|
21
|
+
|
|
22
|
+
const Usion = {
|
|
23
|
+
version: '2.0.1',
|
|
24
|
+
config: {},
|
|
25
|
+
_initialized: false,
|
|
26
|
+
_initCallback: null,
|
|
27
|
+
_messageHandlerRegistered: false,
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize the SDK with config from parent app
|
|
31
|
+
* @param {function} callback - Called with config when ready
|
|
32
|
+
*/
|
|
33
|
+
init: function(callback) {
|
|
34
|
+
const self = this;
|
|
35
|
+
|
|
36
|
+
// Prevent double initialization - just update callback
|
|
37
|
+
if (self._initialized) {
|
|
38
|
+
if (callback) callback(self.config);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Store callback for when config arrives
|
|
43
|
+
self._initCallback = callback;
|
|
44
|
+
|
|
45
|
+
// Only register message handler once
|
|
46
|
+
if (self._messageHandlerRegistered) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
self._messageHandlerRegistered = true;
|
|
50
|
+
|
|
51
|
+
// Setup global message handler
|
|
52
|
+
window.addEventListener('message', function(event) {
|
|
53
|
+
let data;
|
|
54
|
+
try {
|
|
55
|
+
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Handle INIT message
|
|
61
|
+
if (data.type === 'INIT' && data.config) {
|
|
62
|
+
// Prevent double config - only set once
|
|
63
|
+
if (self._initialized) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
self.config = data.config;
|
|
68
|
+
self._initialized = true;
|
|
69
|
+
// We received INIT from a parent → we are embedded (iframe or WebView)
|
|
70
|
+
self._isEmbedded = true;
|
|
71
|
+
|
|
72
|
+
// Initialize user module with config data
|
|
73
|
+
if (data.config.userId) {
|
|
74
|
+
self.user._id = data.config.userId;
|
|
75
|
+
self.user._name = data.config.userName;
|
|
76
|
+
self.user._avatar = data.config.userAvatar;
|
|
77
|
+
self.user._token = data.config.authToken;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Initialize session module
|
|
81
|
+
if (data.config.sessionId) {
|
|
82
|
+
self.session._id = data.config.sessionId;
|
|
83
|
+
self.session._data = data.config.sessionData || {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Initialize wallet with balance if provided
|
|
87
|
+
if (data.config.balance !== undefined) {
|
|
88
|
+
self.wallet._balance = data.config.balance;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Call the stored init callback
|
|
92
|
+
if (self._initCallback) {
|
|
93
|
+
self._initCallback(data.config);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle response messages for async requests
|
|
98
|
+
if (data._requestId && _pendingRequests[data._requestId]) {
|
|
99
|
+
const { resolve, reject } = _pendingRequests[data._requestId];
|
|
100
|
+
delete _pendingRequests[data._requestId];
|
|
101
|
+
|
|
102
|
+
if (data.error) {
|
|
103
|
+
reject(new Error(data.error));
|
|
104
|
+
} else {
|
|
105
|
+
resolve(data);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Handle balance updates
|
|
110
|
+
if (data.type === 'BALANCE_UPDATE') {
|
|
111
|
+
self.wallet._balance = data.balance;
|
|
112
|
+
if (self.wallet._balanceChangeHandler) {
|
|
113
|
+
self.wallet._balanceChangeHandler(data.balance);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Signal ready to parent
|
|
119
|
+
this._post({ type: 'READY' });
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Send message to parent app
|
|
124
|
+
* @private
|
|
125
|
+
*/
|
|
126
|
+
_post: function(message) {
|
|
127
|
+
const msg = JSON.stringify(message);
|
|
128
|
+
|
|
129
|
+
// React Native WebView
|
|
130
|
+
if (window.ReactNativeWebView) {
|
|
131
|
+
window.ReactNativeWebView.postMessage(msg);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Web iframe
|
|
136
|
+
if (window.parent !== window) {
|
|
137
|
+
window.parent.postMessage(message, '*');
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Send async request to parent and wait for response
|
|
143
|
+
* @private
|
|
144
|
+
*/
|
|
145
|
+
_request: function(type, data, timeout) {
|
|
146
|
+
const self = this;
|
|
147
|
+
timeout = timeout || 5000;
|
|
148
|
+
|
|
149
|
+
return new Promise(function(resolve, reject) {
|
|
150
|
+
const requestId = ++_requestId;
|
|
151
|
+
|
|
152
|
+
// Setup timeout
|
|
153
|
+
const timer = setTimeout(function() {
|
|
154
|
+
delete _pendingRequests[requestId];
|
|
155
|
+
reject(new Error('Request timeout'));
|
|
156
|
+
}, timeout);
|
|
157
|
+
|
|
158
|
+
// Store pending request
|
|
159
|
+
_pendingRequests[requestId] = {
|
|
160
|
+
resolve: function(result) {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
resolve(result);
|
|
163
|
+
},
|
|
164
|
+
reject: function(error) {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
reject(error);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Send request
|
|
171
|
+
self._post({
|
|
172
|
+
type: type,
|
|
173
|
+
_requestId: requestId,
|
|
174
|
+
...data
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// ============================================
|
|
180
|
+
// User Module
|
|
181
|
+
// ============================================
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* User information and authentication
|
|
185
|
+
*/
|
|
186
|
+
user: {
|
|
187
|
+
_id: null,
|
|
188
|
+
_name: null,
|
|
189
|
+
_avatar: null,
|
|
190
|
+
_token: null,
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get the current user's ID
|
|
194
|
+
* @returns {string|null}
|
|
195
|
+
*/
|
|
196
|
+
getId: function() {
|
|
197
|
+
return this._id || Usion.config.userId || null;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get the current user's display name
|
|
202
|
+
* @returns {string|null}
|
|
203
|
+
*/
|
|
204
|
+
getName: function() {
|
|
205
|
+
return this._name || Usion.config.userName || null;
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get the current user's avatar URL
|
|
210
|
+
* @returns {string|null}
|
|
211
|
+
*/
|
|
212
|
+
getAvatar: function() {
|
|
213
|
+
return this._avatar || Usion.config.userAvatar || null;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get the user's auth token for socket connections
|
|
218
|
+
* @returns {string|null}
|
|
219
|
+
*/
|
|
220
|
+
getToken: function() {
|
|
221
|
+
return this._token || Usion.config.authToken || null;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get full user profile
|
|
226
|
+
* @returns {Promise<object>} User profile with id, name, avatar
|
|
227
|
+
*/
|
|
228
|
+
getProfile: function() {
|
|
229
|
+
return Usion._request('GET_USER_PROFILE', {}).then(function(response) {
|
|
230
|
+
return response.profile || {
|
|
231
|
+
id: Usion.user.getId(),
|
|
232
|
+
name: Usion.user.getName(),
|
|
233
|
+
avatar: Usion.user.getAvatar()
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
// ============================================
|
|
240
|
+
// Storage Module
|
|
241
|
+
// ============================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Persistent storage (per-user, per-service)
|
|
245
|
+
*/
|
|
246
|
+
storage: {
|
|
247
|
+
/**
|
|
248
|
+
* Get a stored value
|
|
249
|
+
* @param {string} key - Storage key
|
|
250
|
+
* @returns {Promise<any>} Stored value or null
|
|
251
|
+
*/
|
|
252
|
+
get: function(key) {
|
|
253
|
+
return Usion._request('STORAGE_GET', { key: key }).then(function(response) {
|
|
254
|
+
return response.value;
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Set a stored value
|
|
260
|
+
* @param {string} key - Storage key
|
|
261
|
+
* @param {any} value - Value to store (will be JSON serialized)
|
|
262
|
+
* @returns {Promise<void>}
|
|
263
|
+
*/
|
|
264
|
+
set: function(key, value) {
|
|
265
|
+
return Usion._request('STORAGE_SET', { key: key, value: value }).then(function() {
|
|
266
|
+
return;
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Remove a stored value
|
|
272
|
+
* @param {string} key - Storage key
|
|
273
|
+
* @returns {Promise<void>}
|
|
274
|
+
*/
|
|
275
|
+
remove: function(key) {
|
|
276
|
+
return Usion._request('STORAGE_REMOVE', { key: key }).then(function() {
|
|
277
|
+
return;
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Clear all stored values for this service
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
clear: function() {
|
|
286
|
+
return Usion._request('STORAGE_CLEAR', {}).then(function() {
|
|
287
|
+
return;
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get all keys
|
|
293
|
+
* @returns {Promise<string[]>}
|
|
294
|
+
*/
|
|
295
|
+
keys: function() {
|
|
296
|
+
return Usion._request('STORAGE_KEYS', {}).then(function(response) {
|
|
297
|
+
return response.keys || [];
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
// ============================================
|
|
303
|
+
// Wallet Module
|
|
304
|
+
// ============================================
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Wallet and payment operations
|
|
308
|
+
*/
|
|
309
|
+
wallet: {
|
|
310
|
+
_balance: null,
|
|
311
|
+
_balanceChangeHandler: null,
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Get current wallet balance
|
|
315
|
+
* @returns {Promise<number>} Balance in credits
|
|
316
|
+
*/
|
|
317
|
+
getBalance: function() {
|
|
318
|
+
const self = this;
|
|
319
|
+
|
|
320
|
+
// If we have cached balance, return it
|
|
321
|
+
if (self._balance !== null) {
|
|
322
|
+
return Promise.resolve(self._balance);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return Usion._request('GET_BALANCE', {}).then(function(response) {
|
|
326
|
+
self._balance = response.balance;
|
|
327
|
+
return response.balance;
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Check if user has enough credits
|
|
333
|
+
* @param {number} amount - Amount to check
|
|
334
|
+
* @returns {Promise<boolean>}
|
|
335
|
+
*/
|
|
336
|
+
hasCredits: function(amount) {
|
|
337
|
+
return this.getBalance().then(function(balance) {
|
|
338
|
+
return balance >= amount;
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Request payment from user with balance check
|
|
344
|
+
* @param {number} amount - Credit amount to charge
|
|
345
|
+
* @param {string} reason - Description shown to user
|
|
346
|
+
* @param {object} data - Optional additional data
|
|
347
|
+
* @returns {Promise} Resolves on payment success, rejects on failure
|
|
348
|
+
*/
|
|
349
|
+
requestPayment: function(amount, reason, data) {
|
|
350
|
+
const self = this;
|
|
351
|
+
|
|
352
|
+
return new Promise(function(resolve, reject) {
|
|
353
|
+
const requestId = ++_requestId;
|
|
354
|
+
const timeoutMs = 60000;
|
|
355
|
+
|
|
356
|
+
// Listen for response
|
|
357
|
+
function handler(event) {
|
|
358
|
+
let response;
|
|
359
|
+
try {
|
|
360
|
+
response = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Only accept responses for this specific payment request.
|
|
366
|
+
if (response._requestId !== requestId) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (response.type === 'PAYMENT_SUCCESS') {
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
window.removeEventListener('message', handler);
|
|
373
|
+
// Update cached balance
|
|
374
|
+
if (response.newBalance !== undefined) {
|
|
375
|
+
self._balance = response.newBalance;
|
|
376
|
+
} else if (self._balance !== null) {
|
|
377
|
+
self._balance -= amount;
|
|
378
|
+
}
|
|
379
|
+
resolve(response);
|
|
380
|
+
} else if (response.type === 'PAYMENT_FAILED') {
|
|
381
|
+
clearTimeout(timer);
|
|
382
|
+
window.removeEventListener('message', handler);
|
|
383
|
+
reject(new Error(response.reason || 'Payment failed'));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
window.addEventListener('message', handler);
|
|
388
|
+
const timer = setTimeout(function() {
|
|
389
|
+
window.removeEventListener('message', handler);
|
|
390
|
+
reject(new Error('Payment confirmation timeout'));
|
|
391
|
+
}, timeoutMs);
|
|
392
|
+
|
|
393
|
+
// Send payment request
|
|
394
|
+
Usion._post({
|
|
395
|
+
type: 'PAYMENT_REQUEST',
|
|
396
|
+
_requestId: requestId,
|
|
397
|
+
amount: amount,
|
|
398
|
+
reason: reason,
|
|
399
|
+
data: data
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Listen for balance changes
|
|
406
|
+
* @param {function} callback - Called with new balance
|
|
407
|
+
*/
|
|
408
|
+
onBalanceChange: function(callback) {
|
|
409
|
+
this._balanceChangeHandler = callback;
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
|
|
413
|
+
// ============================================
|
|
414
|
+
// Session Module
|
|
415
|
+
// ============================================
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Session management (ephemeral data for current session)
|
|
419
|
+
*/
|
|
420
|
+
session: {
|
|
421
|
+
_id: null,
|
|
422
|
+
_data: {},
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get the current session ID
|
|
426
|
+
* @returns {string|null}
|
|
427
|
+
*/
|
|
428
|
+
getId: function() {
|
|
429
|
+
return this._id || Usion.config.sessionId || null;
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Get session data
|
|
434
|
+
* @param {string} key - Optional key to get specific value
|
|
435
|
+
* @returns {any} Session data or specific value
|
|
436
|
+
*/
|
|
437
|
+
getData: function(key) {
|
|
438
|
+
if (key) {
|
|
439
|
+
return this._data[key];
|
|
440
|
+
}
|
|
441
|
+
return this._data;
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Set session data (ephemeral, cleared on session end)
|
|
446
|
+
* @param {string|object} keyOrData - Key or object of data to set
|
|
447
|
+
* @param {any} value - Value if key is string
|
|
448
|
+
*/
|
|
449
|
+
setData: function(keyOrData, value) {
|
|
450
|
+
if (typeof keyOrData === 'object') {
|
|
451
|
+
Object.assign(this._data, keyOrData);
|
|
452
|
+
} else {
|
|
453
|
+
this._data[keyOrData] = value;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Notify parent of session data change
|
|
457
|
+
Usion._post({
|
|
458
|
+
type: 'SESSION_DATA_UPDATE',
|
|
459
|
+
data: this._data
|
|
460
|
+
});
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Clear session data
|
|
465
|
+
*/
|
|
466
|
+
clear: function() {
|
|
467
|
+
this._data = {};
|
|
468
|
+
Usion._post({
|
|
469
|
+
type: 'SESSION_DATA_CLEAR'
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
// ============================================
|
|
475
|
+
// Legacy Payment Method (for backwards compatibility)
|
|
476
|
+
// ============================================
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Request payment from user (legacy method)
|
|
480
|
+
* @deprecated Use Usion.wallet.requestPayment instead
|
|
481
|
+
*/
|
|
482
|
+
requestPayment: function(amount, reason, data) {
|
|
483
|
+
return this.wallet.requestPayment(amount, reason, data);
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Submit result and signal completion
|
|
488
|
+
* @param {object} data - Result data to send to parent
|
|
489
|
+
*/
|
|
490
|
+
submit: function(data) {
|
|
491
|
+
this._post({
|
|
492
|
+
type: 'SUBMIT',
|
|
493
|
+
data: data
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Report an error to parent app
|
|
499
|
+
* @param {string} message - Error message
|
|
500
|
+
*/
|
|
501
|
+
error: function(message) {
|
|
502
|
+
this._post({
|
|
503
|
+
type: 'ERROR',
|
|
504
|
+
message: message
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Request to close the mini app
|
|
510
|
+
*/
|
|
511
|
+
exit: function() {
|
|
512
|
+
this._post({ type: 'EXIT' });
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Share content through the app's native share and optionally post to Usions feed
|
|
517
|
+
*
|
|
518
|
+
* @param {string} contentType - Type of content: 'audio' | 'image' | 'video' | 'text' | 'mixed'
|
|
519
|
+
* @param {object} data - Content data to share:
|
|
520
|
+
* - text: Optional text/caption for the post
|
|
521
|
+
* - audioUrl: URL for audio content (when contentType is 'audio')
|
|
522
|
+
* - imageUrl: URL for image content (when contentType is 'image')
|
|
523
|
+
* - videoUrl: URL for video content (when contentType is 'video')
|
|
524
|
+
* - thumbnailUrl: Optional thumbnail URL for video/audio
|
|
525
|
+
* - width: Optional width for image/video
|
|
526
|
+
* - height: Optional height for image/video
|
|
527
|
+
* - duration: Optional duration in seconds for audio/video
|
|
528
|
+
* - media: Array of media items for 'mixed' content type
|
|
529
|
+
* - Each item: { type: 'image'|'video'|'audio', url: string, thumbnailUrl?, width?, height?, duration? }
|
|
530
|
+
*
|
|
531
|
+
* @example
|
|
532
|
+
* // Share audio
|
|
533
|
+
* Usion.share('audio', {
|
|
534
|
+
* text: 'Check out this AI voice!',
|
|
535
|
+
* audioUrl: 'https://cdn.example.com/audio.mp3',
|
|
536
|
+
* duration: 5.2
|
|
537
|
+
* });
|
|
538
|
+
*
|
|
539
|
+
* @example
|
|
540
|
+
* // Share image
|
|
541
|
+
* Usion.share('image', {
|
|
542
|
+
* text: 'AI-generated art',
|
|
543
|
+
* imageUrl: 'https://cdn.example.com/image.webp',
|
|
544
|
+
* thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
|
545
|
+
* width: 1024,
|
|
546
|
+
* height: 1024
|
|
547
|
+
* });
|
|
548
|
+
*
|
|
549
|
+
* @example
|
|
550
|
+
* // Share video
|
|
551
|
+
* Usion.share('video', {
|
|
552
|
+
* text: 'My AI video creation',
|
|
553
|
+
* videoUrl: 'https://cdn.example.com/video.mp4',
|
|
554
|
+
* thumbnailUrl: 'https://cdn.example.com/poster.jpg',
|
|
555
|
+
* duration: 30
|
|
556
|
+
* });
|
|
557
|
+
*
|
|
558
|
+
* @example
|
|
559
|
+
* // Share mixed content (multiple media)
|
|
560
|
+
* Usion.share('mixed', {
|
|
561
|
+
* text: 'Gallery of AI creations',
|
|
562
|
+
* media: [
|
|
563
|
+
* { type: 'image', url: 'https://cdn.example.com/1.webp' },
|
|
564
|
+
* { type: 'image', url: 'https://cdn.example.com/2.webp' },
|
|
565
|
+
* { type: 'video', url: 'https://cdn.example.com/video.mp4', thumbnailUrl: '...' }
|
|
566
|
+
* ]
|
|
567
|
+
* });
|
|
568
|
+
*/
|
|
569
|
+
share: function(contentType, data) {
|
|
570
|
+
var shareData = Object.assign({}, data, {
|
|
571
|
+
contentType: contentType,
|
|
572
|
+
serviceId: this.config.serviceId,
|
|
573
|
+
serviceName: this.config.serviceName
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
this._post({
|
|
577
|
+
type: 'SHARE',
|
|
578
|
+
contentType: contentType,
|
|
579
|
+
data: shareData
|
|
580
|
+
});
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
// ============================================
|
|
584
|
+
// Chat
|
|
585
|
+
// ============================================
|
|
586
|
+
|
|
587
|
+
chat: {
|
|
588
|
+
/**
|
|
589
|
+
* Request to send a message to another user.
|
|
590
|
+
* The parent app will show a confirmation prompt to the user.
|
|
591
|
+
* @param {string} recipientId - Usion user ID of the recipient
|
|
592
|
+
* @param {string} message - Message content to send
|
|
593
|
+
* @returns {Promise<{success: boolean, reason?: string}>}
|
|
594
|
+
*
|
|
595
|
+
* @example
|
|
596
|
+
* const result = await Usion.chat.sendMessage('user_abc', '👋');
|
|
597
|
+
* if (result.success) console.log('Message sent!');
|
|
598
|
+
*/
|
|
599
|
+
sendMessage: function(recipientId, message) {
|
|
600
|
+
return Usion._request('SEND_MESSAGE_REQUEST', {
|
|
601
|
+
recipientId: recipientId,
|
|
602
|
+
message: message
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Create a personal chat with another user (no message sent).
|
|
608
|
+
* @param {string} peerUserId - Usion user ID of the other user
|
|
609
|
+
* @returns {Promise<{chatId: string, peerName: string, peerUsername: string, peerAvatar: string}>}
|
|
610
|
+
*/
|
|
611
|
+
createPersonalChat: function(peerUserId) {
|
|
612
|
+
return Usion._request('CREATE_PERSONAL_CHAT', {
|
|
613
|
+
peerUserId: peerUserId
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Log message to native console (for debugging)
|
|
620
|
+
* @param {string} msg - Message to log
|
|
621
|
+
*/
|
|
622
|
+
log: function(msg) {
|
|
623
|
+
this._post({
|
|
624
|
+
type: 'LOG',
|
|
625
|
+
msg: msg
|
|
626
|
+
});
|
|
627
|
+
console.log('[Usion]', msg);
|
|
628
|
+
},
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Listen for messages from parent app
|
|
632
|
+
* @param {string} type - Message type to listen for
|
|
633
|
+
* @param {function} callback - Handler function
|
|
634
|
+
*/
|
|
635
|
+
on: function(type, callback) {
|
|
636
|
+
window.addEventListener('message', function(event) {
|
|
637
|
+
let data;
|
|
638
|
+
try {
|
|
639
|
+
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
640
|
+
} catch (e) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (data.type === type) {
|
|
645
|
+
callback(data);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
// ============================================
|
|
651
|
+
// UI Utilities
|
|
652
|
+
// ============================================
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Set button to loading state
|
|
656
|
+
* @param {HTMLElement|string} btn - Button element or selector
|
|
657
|
+
* @param {boolean} loading - Whether to show loading state
|
|
658
|
+
*/
|
|
659
|
+
setLoading: function(btn, loading) {
|
|
660
|
+
const el = typeof btn === 'string' ? document.querySelector(btn) : btn;
|
|
661
|
+
if (!el) return;
|
|
662
|
+
|
|
663
|
+
if (loading) {
|
|
664
|
+
el.classList.add('usion-btn-loading');
|
|
665
|
+
el.disabled = true;
|
|
666
|
+
el.dataset.originalText = el.textContent;
|
|
667
|
+
} else {
|
|
668
|
+
el.classList.remove('usion-btn-loading');
|
|
669
|
+
el.disabled = false;
|
|
670
|
+
if (el.dataset.originalText) {
|
|
671
|
+
el.textContent = el.dataset.originalText;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Show/hide an element
|
|
678
|
+
* @param {HTMLElement|string} el - Element or selector
|
|
679
|
+
* @param {boolean} show - Whether to show or hide
|
|
680
|
+
*/
|
|
681
|
+
toggle: function(el, show) {
|
|
682
|
+
const element = typeof el === 'string' ? document.querySelector(el) : el;
|
|
683
|
+
if (!element) return;
|
|
684
|
+
|
|
685
|
+
if (show) {
|
|
686
|
+
element.classList.remove('usion-hidden', 'hidden');
|
|
687
|
+
element.classList.add('usion-visible');
|
|
688
|
+
} else {
|
|
689
|
+
element.classList.add('usion-hidden');
|
|
690
|
+
element.classList.remove('usion-visible');
|
|
691
|
+
}
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Update character count display
|
|
696
|
+
* @param {HTMLElement|string} input - Input element or selector
|
|
697
|
+
* @param {HTMLElement|string} counter - Counter element or selector
|
|
698
|
+
* @param {number} max - Maximum characters
|
|
699
|
+
*/
|
|
700
|
+
charCount: function(input, counter, max) {
|
|
701
|
+
const inputEl = typeof input === 'string' ? document.querySelector(input) : input;
|
|
702
|
+
const counterEl = typeof counter === 'string' ? document.querySelector(counter) : counter;
|
|
703
|
+
|
|
704
|
+
if (!inputEl || !counterEl) return;
|
|
705
|
+
|
|
706
|
+
function update() {
|
|
707
|
+
const count = inputEl.value.length;
|
|
708
|
+
counterEl.textContent = count + ' / ' + max;
|
|
709
|
+
|
|
710
|
+
counterEl.classList.remove('warning', 'error');
|
|
711
|
+
if (count > max * 0.9) {
|
|
712
|
+
counterEl.classList.add('error');
|
|
713
|
+
} else if (count > max * 0.7) {
|
|
714
|
+
counterEl.classList.add('warning');
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
inputEl.addEventListener('input', update);
|
|
719
|
+
update();
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Create a selection handler for grid items
|
|
724
|
+
* @param {string} containerSelector - Container selector
|
|
725
|
+
* @param {string} itemSelector - Item selector
|
|
726
|
+
* @param {function} onChange - Callback when selection changes
|
|
727
|
+
*/
|
|
728
|
+
selectionGrid: function(containerSelector, itemSelector, onChange) {
|
|
729
|
+
const container = document.querySelector(containerSelector);
|
|
730
|
+
if (!container) return;
|
|
731
|
+
|
|
732
|
+
let selected = null;
|
|
733
|
+
|
|
734
|
+
container.querySelectorAll(itemSelector).forEach(function(item) {
|
|
735
|
+
item.addEventListener('click', function() {
|
|
736
|
+
// Remove selection from all
|
|
737
|
+
container.querySelectorAll(itemSelector).forEach(function(i) {
|
|
738
|
+
i.classList.remove('selected');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// Select this one
|
|
742
|
+
item.classList.add('selected');
|
|
743
|
+
selected = item.dataset.value || item.dataset.id;
|
|
744
|
+
|
|
745
|
+
if (onChange) onChange(selected, item);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
getSelected: function() { return selected; },
|
|
751
|
+
clear: function() {
|
|
752
|
+
container.querySelectorAll(itemSelector).forEach(function(i) {
|
|
753
|
+
i.classList.remove('selected');
|
|
754
|
+
});
|
|
755
|
+
selected = null;
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
// ============================================
|
|
761
|
+
// Game/Multiplayer Utilities
|
|
762
|
+
// ============================================
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Game module for multiplayer real-time games
|
|
766
|
+
*/
|
|
767
|
+
game: {
|
|
768
|
+
socket: null,
|
|
769
|
+
directSocket: null,
|
|
770
|
+
roomId: null,
|
|
771
|
+
playerId: null,
|
|
772
|
+
connected: false,
|
|
773
|
+
directMode: false,
|
|
774
|
+
directConfig: null,
|
|
775
|
+
_directSeq: 0,
|
|
776
|
+
_eventHandlers: {},
|
|
777
|
+
_lastSequence: 0,
|
|
778
|
+
_connecting: false,
|
|
779
|
+
_connectPromise: null,
|
|
780
|
+
_joined: false,
|
|
781
|
+
_joinPromise: null,
|
|
782
|
+
_useProxy: false,
|
|
783
|
+
_proxyListenerSetup: false,
|
|
784
|
+
_heartbeatInterval: null,
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Connect to the game socket server
|
|
788
|
+
* @param {string} socketUrl - Socket.IO server URL (optional, uses config)
|
|
789
|
+
* @param {string} token - JWT auth token (optional, uses user.getToken())
|
|
790
|
+
* @returns {Promise} Resolves when connected
|
|
791
|
+
*/
|
|
792
|
+
connect: function(socketUrl, token) {
|
|
793
|
+
const self = this;
|
|
794
|
+
var connectionMode = (Usion.config && Usion.config.connectionMode) || 'platform';
|
|
795
|
+
if (connectionMode === 'direct') {
|
|
796
|
+
return self.connectDirect();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Use config values as defaults
|
|
800
|
+
socketUrl = socketUrl || Usion.config.socketUrl;
|
|
801
|
+
token = token || Usion.user.getToken();
|
|
802
|
+
|
|
803
|
+
if (!socketUrl) {
|
|
804
|
+
return Promise.reject(new Error('No socket URL provided'));
|
|
805
|
+
}
|
|
806
|
+
if (!token) {
|
|
807
|
+
return Promise.reject(new Error('No auth token available'));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// If already connected (direct or proxy), return immediately
|
|
811
|
+
if (self._useProxy && self.connected) {
|
|
812
|
+
return Promise.resolve();
|
|
813
|
+
}
|
|
814
|
+
if (self.socket && self.connected) {
|
|
815
|
+
return Promise.resolve();
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// If currently connecting, return the existing promise
|
|
819
|
+
if (self._connecting && self._connectPromise) {
|
|
820
|
+
return self._connectPromise;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// When running inside an iframe or WebView, always use the parent app
|
|
824
|
+
// as a socket proxy. The parent already has an authenticated socket
|
|
825
|
+
// connection, avoids CORS issues, and avoids mixed-content blocks.
|
|
826
|
+
// Detection order (first truthy wins):
|
|
827
|
+
// (1) __USION_PROXY__ injected by parent before page load (most reliable)
|
|
828
|
+
// (2) iframe check (window.parent !== window)
|
|
829
|
+
// (3) ReactNativeWebView global
|
|
830
|
+
// (4) _isEmbedded flag set when INIT message was received
|
|
831
|
+
var isInFrame = !!window.__USION_PROXY__
|
|
832
|
+
|| window.parent !== window
|
|
833
|
+
|| !!window.ReactNativeWebView
|
|
834
|
+
|| !!Usion._isEmbedded;
|
|
835
|
+
|
|
836
|
+
if (isInFrame) {
|
|
837
|
+
Usion.log('Running in iframe – using parent app as socket proxy');
|
|
838
|
+
return self._connectViaProxy();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
self._connecting = true;
|
|
842
|
+
self._connectPromise = new Promise(function(resolve, reject) {
|
|
843
|
+
// Check if socket.io-client is available
|
|
844
|
+
if (typeof io === 'undefined') {
|
|
845
|
+
// Load socket.io client – try local copy first, fallback to CDN
|
|
846
|
+
var script = document.createElement('script');
|
|
847
|
+
script.src = '/socket.io.min.js';
|
|
848
|
+
script.onload = function() {
|
|
849
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
850
|
+
};
|
|
851
|
+
script.onerror = function() {
|
|
852
|
+
// Local file not available, try CDN as fallback
|
|
853
|
+
var cdnScript = document.createElement('script');
|
|
854
|
+
cdnScript.src = 'https://cdn.socket.io/4.7.2/socket.io.min.js';
|
|
855
|
+
cdnScript.onload = function() {
|
|
856
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
857
|
+
};
|
|
858
|
+
cdnScript.onerror = function() {
|
|
859
|
+
self._connecting = false;
|
|
860
|
+
reject(new Error('Failed to load Socket.IO client'));
|
|
861
|
+
};
|
|
862
|
+
document.head.appendChild(cdnScript);
|
|
863
|
+
};
|
|
864
|
+
document.head.appendChild(script);
|
|
865
|
+
} else {
|
|
866
|
+
self._initSocket(socketUrl, token, resolve, reject);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
return self._connectPromise;
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Connect directly to creator-controlled WebSocket server.
|
|
875
|
+
* Uses backend-issued short-lived room token.
|
|
876
|
+
* @returns {Promise}
|
|
877
|
+
*/
|
|
878
|
+
connectDirect: function(config) {
|
|
879
|
+
var self = this;
|
|
880
|
+
config = config || {};
|
|
881
|
+
|
|
882
|
+
if (self.directMode && self.directSocket && self.connected) {
|
|
883
|
+
return Promise.resolve();
|
|
884
|
+
}
|
|
885
|
+
if (self._connecting && self._connectPromise) {
|
|
886
|
+
return self._connectPromise;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
self._connecting = true;
|
|
890
|
+
self.directMode = true;
|
|
891
|
+
self._connectPromise = self._fetchDirectAccess(config)
|
|
892
|
+
.then(function(access) {
|
|
893
|
+
self.directConfig = access;
|
|
894
|
+
return self._initDirectSocket(access);
|
|
895
|
+
})
|
|
896
|
+
.then(function() {
|
|
897
|
+
self.connected = true;
|
|
898
|
+
self._connecting = false;
|
|
899
|
+
Usion.log('Direct game socket connected');
|
|
900
|
+
})
|
|
901
|
+
.catch(function(err) {
|
|
902
|
+
self._connecting = false;
|
|
903
|
+
self.connected = false;
|
|
904
|
+
self.directMode = false;
|
|
905
|
+
if (self._eventHandlers.connectionError) {
|
|
906
|
+
self._eventHandlers.connectionError(err);
|
|
907
|
+
}
|
|
908
|
+
throw err;
|
|
909
|
+
});
|
|
910
|
+
return self._connectPromise;
|
|
911
|
+
},
|
|
912
|
+
|
|
913
|
+
_fetchDirectAccess: function(config) {
|
|
914
|
+
var roomId = config.roomId || this.roomId || Usion.config.roomId;
|
|
915
|
+
var serviceId = config.serviceId || Usion.config.serviceId;
|
|
916
|
+
var apiUrl = config.apiUrl || Usion.config.apiUrl || '';
|
|
917
|
+
var token = config.token || Usion.user.getToken();
|
|
918
|
+
|
|
919
|
+
if (!roomId) return Promise.reject(new Error('No room ID provided'));
|
|
920
|
+
if (!serviceId) return Promise.reject(new Error('No service ID provided'));
|
|
921
|
+
if (!apiUrl) return Promise.reject(new Error('No API URL provided'));
|
|
922
|
+
if (!token) return Promise.reject(new Error('No auth token available'));
|
|
923
|
+
|
|
924
|
+
this.roomId = roomId;
|
|
925
|
+
this.playerId = Usion.user.getId();
|
|
926
|
+
|
|
927
|
+
var cleanApiUrl = String(apiUrl).replace(/\/$/, '');
|
|
928
|
+
var endpoint = cleanApiUrl + '/games/rooms/' + encodeURIComponent(roomId) + '/access';
|
|
929
|
+
return fetch(endpoint, {
|
|
930
|
+
method: 'POST',
|
|
931
|
+
headers: {
|
|
932
|
+
'Content-Type': 'application/json',
|
|
933
|
+
'Authorization': 'Bearer ' + token
|
|
934
|
+
},
|
|
935
|
+
body: JSON.stringify({
|
|
936
|
+
service_id: serviceId,
|
|
937
|
+
client_type: 'iframe',
|
|
938
|
+
protocol_version: (config.protocolVersion || Usion.config.protocolVersion || Usion.config.protocol_version || '2')
|
|
939
|
+
})
|
|
940
|
+
}).then(function(res) {
|
|
941
|
+
if (!res.ok) {
|
|
942
|
+
return res.text().then(function(text) {
|
|
943
|
+
throw new Error(text || ('Direct access failed: HTTP ' + res.status));
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return res.json();
|
|
947
|
+
});
|
|
948
|
+
},
|
|
949
|
+
|
|
950
|
+
_initDirectSocket: function(access) {
|
|
951
|
+
var self = this;
|
|
952
|
+
return new Promise(function(resolve, reject) {
|
|
953
|
+
if (!access || !access.ws_url || !access.access_token) {
|
|
954
|
+
reject(new Error('Invalid direct access payload'));
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
var wsUrl = access.ws_url;
|
|
959
|
+
var separator = wsUrl.indexOf('?') === -1 ? '?' : '&';
|
|
960
|
+
var urlWithToken = wsUrl + separator + 'token=' + encodeURIComponent(access.access_token);
|
|
961
|
+
var ws = new WebSocket(urlWithToken);
|
|
962
|
+
self.directSocket = ws;
|
|
963
|
+
|
|
964
|
+
var opened = false;
|
|
965
|
+
var joinSent = false;
|
|
966
|
+
var timeout = setTimeout(function() {
|
|
967
|
+
if (!opened) {
|
|
968
|
+
try { ws.close(); } catch (e) {}
|
|
969
|
+
reject(new Error('Direct WebSocket connection timeout'));
|
|
970
|
+
}
|
|
971
|
+
}, 10000);
|
|
972
|
+
|
|
973
|
+
ws.onopen = function() {
|
|
974
|
+
opened = true;
|
|
975
|
+
clearTimeout(timeout);
|
|
976
|
+
if (!joinSent) {
|
|
977
|
+
joinSent = true;
|
|
978
|
+
self._sendDirect('join', {});
|
|
979
|
+
}
|
|
980
|
+
// Start heartbeat for direct mode
|
|
981
|
+
if (self._heartbeatInterval) clearInterval(self._heartbeatInterval);
|
|
982
|
+
self._heartbeatInterval = setInterval(function() {
|
|
983
|
+
if (self.directSocket && self.directSocket.readyState === WebSocket.OPEN) {
|
|
984
|
+
self._sendDirect('heartbeat', {});
|
|
985
|
+
}
|
|
986
|
+
}, 25000);
|
|
987
|
+
resolve();
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
ws.onerror = function() {
|
|
991
|
+
if (!opened) {
|
|
992
|
+
clearTimeout(timeout);
|
|
993
|
+
reject(new Error('Direct WebSocket connection error'));
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
ws.onclose = function(evt) {
|
|
998
|
+
self.connected = false;
|
|
999
|
+
self._joined = false;
|
|
1000
|
+
self._joinPromise = null;
|
|
1001
|
+
if (self._heartbeatInterval) {
|
|
1002
|
+
clearInterval(self._heartbeatInterval);
|
|
1003
|
+
self._heartbeatInterval = null;
|
|
1004
|
+
}
|
|
1005
|
+
if (self._eventHandlers.disconnect) {
|
|
1006
|
+
self._eventHandlers.disconnect(evt && evt.reason ? evt.reason : 'direct socket closed');
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
ws.onmessage = function(evt) {
|
|
1011
|
+
self._handleDirectMessage(evt && evt.data);
|
|
1012
|
+
};
|
|
1013
|
+
});
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
_sendDirect: function(type, payload) {
|
|
1017
|
+
if (!this.directSocket || this.directSocket.readyState !== WebSocket.OPEN) return;
|
|
1018
|
+
this._directSeq = this._directSeq + 1;
|
|
1019
|
+
this.directSocket.send(JSON.stringify({
|
|
1020
|
+
type: type,
|
|
1021
|
+
room_id: this.roomId,
|
|
1022
|
+
ts: Date.now(),
|
|
1023
|
+
seq: this._directSeq,
|
|
1024
|
+
session_id: (this.directConfig && this.directConfig.session_id) ? this.directConfig.session_id : null,
|
|
1025
|
+
protocol_version: (this.directConfig && this.directConfig.protocol_version) ? this.directConfig.protocol_version : '2',
|
|
1026
|
+
payload: payload || {}
|
|
1027
|
+
}));
|
|
1028
|
+
},
|
|
1029
|
+
|
|
1030
|
+
_handleDirectMessage: function(raw) {
|
|
1031
|
+
var data;
|
|
1032
|
+
try {
|
|
1033
|
+
data = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (!data || !data.type) return;
|
|
1038
|
+
var payload = data.payload || {};
|
|
1039
|
+
|
|
1040
|
+
if (data.type === 'joined') {
|
|
1041
|
+
this._joined = true;
|
|
1042
|
+
if (this._eventHandlers.joined) this._eventHandlers.joined(payload);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (data.type === 'player_joined') {
|
|
1046
|
+
if (this._eventHandlers.playerJoined) this._eventHandlers.playerJoined(payload);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (data.type === 'player_left') {
|
|
1050
|
+
if (this._eventHandlers.playerLeft) this._eventHandlers.playerLeft(payload);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
if (data.type === 'state_snapshot' || data.type === 'state_delta') {
|
|
1054
|
+
if (this._eventHandlers.realtime) this._eventHandlers.realtime(payload);
|
|
1055
|
+
if (this._eventHandlers.stateUpdate) this._eventHandlers.stateUpdate(payload);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (data.type === 'pong') {
|
|
1059
|
+
if (this._eventHandlers.sync) this._eventHandlers.sync(payload);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (data.type === 'match_end') {
|
|
1063
|
+
if (this._eventHandlers.finished) this._eventHandlers.finished(payload);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (data.type === 'error' && this._eventHandlers.error) {
|
|
1067
|
+
this._eventHandlers.error(payload);
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Initialize socket connection
|
|
1073
|
+
* @private
|
|
1074
|
+
*/
|
|
1075
|
+
_initSocket: function(socketUrl, token, resolve, reject) {
|
|
1076
|
+
const self = this;
|
|
1077
|
+
|
|
1078
|
+
// Prevent creating duplicate sockets
|
|
1079
|
+
if (self.socket && self.socket.connected) {
|
|
1080
|
+
self._connecting = false;
|
|
1081
|
+
resolve();
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Clean up any existing disconnected socket
|
|
1086
|
+
if (self.socket) {
|
|
1087
|
+
self.socket.disconnect();
|
|
1088
|
+
self.socket = null;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
try {
|
|
1092
|
+
self.socket = io(socketUrl, {
|
|
1093
|
+
path: '/socket.io',
|
|
1094
|
+
transports: ['websocket', 'polling'],
|
|
1095
|
+
auth: { token: token },
|
|
1096
|
+
autoConnect: true,
|
|
1097
|
+
reconnection: true,
|
|
1098
|
+
reconnectionAttempts: 50,
|
|
1099
|
+
reconnectionDelay: 1000,
|
|
1100
|
+
reconnectionDelayMax: 10000
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
self.socket.on('connect', function() {
|
|
1104
|
+
self.connected = true;
|
|
1105
|
+
self._connecting = false;
|
|
1106
|
+
Usion.log('Game socket connected');
|
|
1107
|
+
|
|
1108
|
+
// Start heartbeat to keep game session alive
|
|
1109
|
+
if (self._heartbeatInterval) clearInterval(self._heartbeatInterval);
|
|
1110
|
+
self._heartbeatInterval = setInterval(function() {
|
|
1111
|
+
if (self.socket && self.connected && self.roomId) {
|
|
1112
|
+
self.socket.emit('game:heartbeat', { room_id: self.roomId });
|
|
1113
|
+
}
|
|
1114
|
+
}, 25000);
|
|
1115
|
+
|
|
1116
|
+
// Re-join room after reconnect so this socket gets room broadcasts again
|
|
1117
|
+
if (self.roomId) {
|
|
1118
|
+
self._joined = false;
|
|
1119
|
+
self._joinPromise = null;
|
|
1120
|
+
self.join(self.roomId)
|
|
1121
|
+
.then(function() {
|
|
1122
|
+
Usion.log('Reconnected - joined room ' + self.roomId);
|
|
1123
|
+
self.requestSync(self._lastSequence || 0);
|
|
1124
|
+
})
|
|
1125
|
+
.catch(function(err) {
|
|
1126
|
+
Usion.log('Rejoin failed: ' + (err && err.message ? err.message : String(err)));
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
resolve();
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
self.socket.on('connect_error', function(err) {
|
|
1134
|
+
self._connecting = false;
|
|
1135
|
+
Usion.log('Game socket error: ' + err.message);
|
|
1136
|
+
if (self._eventHandlers.connectionError) {
|
|
1137
|
+
self._eventHandlers.connectionError(err);
|
|
1138
|
+
}
|
|
1139
|
+
reject(err);
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
self.socket.on('disconnect', function(reason) {
|
|
1143
|
+
self.connected = false;
|
|
1144
|
+
self._joined = false;
|
|
1145
|
+
self._joinPromise = null;
|
|
1146
|
+
if (self._heartbeatInterval) {
|
|
1147
|
+
clearInterval(self._heartbeatInterval);
|
|
1148
|
+
self._heartbeatInterval = null;
|
|
1149
|
+
}
|
|
1150
|
+
Usion.log('Game socket disconnected: ' + reason);
|
|
1151
|
+
if (self._eventHandlers.disconnect) {
|
|
1152
|
+
self._eventHandlers.disconnect(reason);
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
self.socket.on('reconnect', function(attemptNumber) {
|
|
1157
|
+
Usion.log('Game socket reconnected after ' + attemptNumber + ' attempts');
|
|
1158
|
+
if (self._eventHandlers.reconnect) {
|
|
1159
|
+
self._eventHandlers.reconnect(attemptNumber);
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
// Game event handlers
|
|
1164
|
+
self.socket.on('game:joined', function(data) {
|
|
1165
|
+
if (data.sequence !== undefined) {
|
|
1166
|
+
self._lastSequence = data.sequence;
|
|
1167
|
+
}
|
|
1168
|
+
if (self._eventHandlers.joined) {
|
|
1169
|
+
self._eventHandlers.joined(data);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
self.socket.on('game:player_joined', function(data) {
|
|
1174
|
+
if (self._eventHandlers.playerJoined) {
|
|
1175
|
+
self._eventHandlers.playerJoined(data);
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
self.socket.on('game:player_left', function(data) {
|
|
1180
|
+
if (self._eventHandlers.playerLeft) {
|
|
1181
|
+
self._eventHandlers.playerLeft(data);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
self.socket.on('game:state', function(data) {
|
|
1186
|
+
if (data.sequence !== undefined) {
|
|
1187
|
+
self._lastSequence = Math.max(self._lastSequence, data.sequence);
|
|
1188
|
+
}
|
|
1189
|
+
if (self._eventHandlers.stateUpdate) {
|
|
1190
|
+
self._eventHandlers.stateUpdate(data);
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
self.socket.on('game:sync', function(data) {
|
|
1195
|
+
if (data.sequence !== undefined) {
|
|
1196
|
+
self._lastSequence = data.sequence;
|
|
1197
|
+
}
|
|
1198
|
+
if (self._eventHandlers.sync) {
|
|
1199
|
+
self._eventHandlers.sync(data);
|
|
1200
|
+
}
|
|
1201
|
+
// Also trigger stateUpdate for backwards compat
|
|
1202
|
+
if (self._eventHandlers.stateUpdate) {
|
|
1203
|
+
self._eventHandlers.stateUpdate(data);
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
self.socket.on('game:action', function(data) {
|
|
1208
|
+
if (data.sequence !== undefined) {
|
|
1209
|
+
self._lastSequence = Math.max(self._lastSequence, data.sequence);
|
|
1210
|
+
}
|
|
1211
|
+
if (self._eventHandlers.action) {
|
|
1212
|
+
self._eventHandlers.action(data);
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
self.socket.on('game:realtime', function(data) {
|
|
1217
|
+
if (self._eventHandlers.realtime) {
|
|
1218
|
+
self._eventHandlers.realtime(data);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
self.socket.on('game:finished', function(data) {
|
|
1223
|
+
if (data.sequence !== undefined) {
|
|
1224
|
+
self._lastSequence = data.sequence;
|
|
1225
|
+
}
|
|
1226
|
+
if (self._eventHandlers.finished) {
|
|
1227
|
+
self._eventHandlers.finished(data);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
self.socket.on('game:error', function(data) {
|
|
1232
|
+
Usion.log('Game error: ' + (data.message || data.code));
|
|
1233
|
+
if (self._eventHandlers.error) {
|
|
1234
|
+
self._eventHandlers.error(data);
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
self.socket.on('game:rematch_request', function(data) {
|
|
1239
|
+
if (self._eventHandlers.rematchRequest) {
|
|
1240
|
+
self._eventHandlers.rematchRequest(data);
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
self.socket.on('game:restarted', function(data) {
|
|
1245
|
+
self._lastSequence = 0; // Reset sequence on rematch
|
|
1246
|
+
if (self._eventHandlers.restarted) {
|
|
1247
|
+
self._eventHandlers.restarted(data);
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
reject(err);
|
|
1253
|
+
}
|
|
1254
|
+
},
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Connect via parent app proxy (postMessage relay).
|
|
1258
|
+
* Used when mixed content prevents direct socket connection.
|
|
1259
|
+
* @private
|
|
1260
|
+
*/
|
|
1261
|
+
_connectViaProxy: function() {
|
|
1262
|
+
var self = this;
|
|
1263
|
+
|
|
1264
|
+
if (self._useProxy && self.connected) {
|
|
1265
|
+
return Promise.resolve();
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
self._useProxy = true;
|
|
1269
|
+
self._connecting = true;
|
|
1270
|
+
self._setupProxyListener();
|
|
1271
|
+
|
|
1272
|
+
self._connectPromise = new Promise(function(resolve, reject) {
|
|
1273
|
+
// Listen for GAME_CONNECTED from parent
|
|
1274
|
+
self._proxyConnectResolve = function() {
|
|
1275
|
+
self.connected = true;
|
|
1276
|
+
self._connecting = false;
|
|
1277
|
+
Usion.log('Game socket connected via parent proxy');
|
|
1278
|
+
resolve();
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
// Send connect request to parent
|
|
1282
|
+
Usion._post({ type: 'GAME_CONNECT' });
|
|
1283
|
+
|
|
1284
|
+
// Timeout after 10s
|
|
1285
|
+
setTimeout(function() {
|
|
1286
|
+
if (!self.connected) {
|
|
1287
|
+
self._connecting = false;
|
|
1288
|
+
reject(new Error('Proxy connection timeout'));
|
|
1289
|
+
}
|
|
1290
|
+
}, 10000);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
return self._connectPromise;
|
|
1294
|
+
},
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Set up message listener for proxy game events from parent.
|
|
1298
|
+
* @private
|
|
1299
|
+
*/
|
|
1300
|
+
_setupProxyListener: function() {
|
|
1301
|
+
var self = this;
|
|
1302
|
+
if (self._proxyListenerSetup) return;
|
|
1303
|
+
self._proxyListenerSetup = true;
|
|
1304
|
+
|
|
1305
|
+
window.addEventListener('message', function(event) {
|
|
1306
|
+
var data;
|
|
1307
|
+
try {
|
|
1308
|
+
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
1309
|
+
} catch (e) {
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (!data || typeof data !== 'object' || !self._useProxy) return;
|
|
1313
|
+
|
|
1314
|
+
switch (data.type) {
|
|
1315
|
+
case 'GAME_CONNECTED':
|
|
1316
|
+
if (self._proxyConnectResolve) {
|
|
1317
|
+
self._proxyConnectResolve();
|
|
1318
|
+
self._proxyConnectResolve = null;
|
|
1319
|
+
}
|
|
1320
|
+
break;
|
|
1321
|
+
|
|
1322
|
+
case 'GAME_CONNECT_ERROR':
|
|
1323
|
+
self.connected = false;
|
|
1324
|
+
self._connecting = false;
|
|
1325
|
+
break;
|
|
1326
|
+
|
|
1327
|
+
case 'GAME_JOINED':
|
|
1328
|
+
self._joined = true;
|
|
1329
|
+
if (data.sequence !== undefined) self._lastSequence = data.sequence;
|
|
1330
|
+
if (self._proxyJoinResolve) {
|
|
1331
|
+
self._proxyJoinResolve(data);
|
|
1332
|
+
self._proxyJoinResolve = null;
|
|
1333
|
+
}
|
|
1334
|
+
if (self._eventHandlers.joined) self._eventHandlers.joined(data);
|
|
1335
|
+
break;
|
|
1336
|
+
|
|
1337
|
+
case 'GAME_JOIN_ERROR':
|
|
1338
|
+
self._joined = false;
|
|
1339
|
+
if (self._proxyJoinReject) {
|
|
1340
|
+
self._proxyJoinReject(new Error(data.message || 'Join failed'));
|
|
1341
|
+
self._proxyJoinReject = null;
|
|
1342
|
+
}
|
|
1343
|
+
break;
|
|
1344
|
+
|
|
1345
|
+
case 'GAME_PLAYER_JOINED':
|
|
1346
|
+
if (self._eventHandlers.playerJoined) self._eventHandlers.playerJoined(data);
|
|
1347
|
+
break;
|
|
1348
|
+
|
|
1349
|
+
case 'GAME_PLAYER_LEFT':
|
|
1350
|
+
if (self._eventHandlers.playerLeft) self._eventHandlers.playerLeft(data);
|
|
1351
|
+
break;
|
|
1352
|
+
|
|
1353
|
+
case 'GAME_STATE':
|
|
1354
|
+
if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
|
|
1355
|
+
if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
|
|
1356
|
+
break;
|
|
1357
|
+
|
|
1358
|
+
case 'GAME_ACTION_DATA':
|
|
1359
|
+
if (data.sequence !== undefined) self._lastSequence = Math.max(self._lastSequence, data.sequence);
|
|
1360
|
+
if (self._eventHandlers.action) self._eventHandlers.action(data);
|
|
1361
|
+
break;
|
|
1362
|
+
|
|
1363
|
+
case 'GAME_REALTIME_DATA':
|
|
1364
|
+
if (self._eventHandlers.realtime) self._eventHandlers.realtime(data);
|
|
1365
|
+
break;
|
|
1366
|
+
|
|
1367
|
+
case 'GAME_FINISHED':
|
|
1368
|
+
if (data.sequence !== undefined) self._lastSequence = data.sequence;
|
|
1369
|
+
if (self._eventHandlers.finished) self._eventHandlers.finished(data);
|
|
1370
|
+
break;
|
|
1371
|
+
|
|
1372
|
+
case 'GAME_ERROR':
|
|
1373
|
+
Usion.log('Game error via proxy: ' + (data.message || data.code));
|
|
1374
|
+
if (self._eventHandlers.error) self._eventHandlers.error(data);
|
|
1375
|
+
break;
|
|
1376
|
+
|
|
1377
|
+
case 'GAME_RESTARTED':
|
|
1378
|
+
self._lastSequence = 0;
|
|
1379
|
+
if (self._eventHandlers.restarted) self._eventHandlers.restarted(data);
|
|
1380
|
+
break;
|
|
1381
|
+
|
|
1382
|
+
case 'GAME_REMATCH_REQUEST':
|
|
1383
|
+
if (self._eventHandlers.rematchRequest) self._eventHandlers.rematchRequest(data);
|
|
1384
|
+
break;
|
|
1385
|
+
|
|
1386
|
+
case 'GAME_SYNC':
|
|
1387
|
+
if (data.sequence !== undefined) self._lastSequence = data.sequence;
|
|
1388
|
+
if (self._eventHandlers.sync) self._eventHandlers.sync(data);
|
|
1389
|
+
if (self._eventHandlers.stateUpdate) self._eventHandlers.stateUpdate(data);
|
|
1390
|
+
break;
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
},
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Join a game room
|
|
1397
|
+
* @param {string} roomId - Game room ID (optional, uses config)
|
|
1398
|
+
* @returns {Promise} Resolves with join data
|
|
1399
|
+
*/
|
|
1400
|
+
join: function(roomId) {
|
|
1401
|
+
const self = this;
|
|
1402
|
+
roomId = roomId || Usion.config.roomId;
|
|
1403
|
+
|
|
1404
|
+
// If already joined this room, return cached promise/data
|
|
1405
|
+
if (self._joined && self.roomId === roomId && self._joinPromise) {
|
|
1406
|
+
return self._joinPromise;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
self.roomId = roomId;
|
|
1410
|
+
self.playerId = Usion.user.getId();
|
|
1411
|
+
|
|
1412
|
+
if (self.directMode) {
|
|
1413
|
+
self._joined = true;
|
|
1414
|
+
self._joinPromise = Promise.resolve({
|
|
1415
|
+
room_id: roomId,
|
|
1416
|
+
player_id: self.playerId
|
|
1417
|
+
});
|
|
1418
|
+
return self._joinPromise;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Proxy mode: send join request to parent
|
|
1422
|
+
if (self._useProxy) {
|
|
1423
|
+
self._joinPromise = new Promise(function(resolve, reject) {
|
|
1424
|
+
self._proxyJoinResolve = resolve;
|
|
1425
|
+
self._proxyJoinReject = reject;
|
|
1426
|
+
Usion._post({ type: 'GAME_JOIN', room_id: roomId });
|
|
1427
|
+
setTimeout(function() {
|
|
1428
|
+
if (!self._joined && self._proxyJoinReject) {
|
|
1429
|
+
self._proxyJoinReject = null;
|
|
1430
|
+
reject(new Error('Join timeout'));
|
|
1431
|
+
}
|
|
1432
|
+
}, 15000);
|
|
1433
|
+
});
|
|
1434
|
+
return self._joinPromise;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
self._joinPromise = new Promise(function(resolve, reject) {
|
|
1438
|
+
if (!self.socket || !self.connected) {
|
|
1439
|
+
reject(new Error('Not connected'));
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (!roomId) {
|
|
1444
|
+
reject(new Error('No room ID provided'));
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
self.socket.emit('game:join', { room_id: roomId }, function(response) {
|
|
1449
|
+
if (response.error) {
|
|
1450
|
+
self._joined = false;
|
|
1451
|
+
reject(new Error(response.message || response.error));
|
|
1452
|
+
} else {
|
|
1453
|
+
self._joined = true;
|
|
1454
|
+
if (response.sequence !== undefined) {
|
|
1455
|
+
self._lastSequence = response.sequence;
|
|
1456
|
+
}
|
|
1457
|
+
resolve(response);
|
|
1458
|
+
}
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
return self._joinPromise;
|
|
1463
|
+
},
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Leave the current game room
|
|
1467
|
+
*/
|
|
1468
|
+
leave: function() {
|
|
1469
|
+
const self = this;
|
|
1470
|
+
|
|
1471
|
+
if (self.directMode) {
|
|
1472
|
+
if (self.roomId) self._sendDirect('leave', {});
|
|
1473
|
+
self.roomId = null;
|
|
1474
|
+
self._lastSequence = 0;
|
|
1475
|
+
self._joined = false;
|
|
1476
|
+
self._joinPromise = null;
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
if (self._useProxy) {
|
|
1481
|
+
if (self.roomId) Usion._post({ type: 'GAME_LEAVE', room_id: self.roomId });
|
|
1482
|
+
self.roomId = null;
|
|
1483
|
+
self._lastSequence = 0;
|
|
1484
|
+
self._joined = false;
|
|
1485
|
+
self._joinPromise = null;
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (self.socket && self.connected && self.roomId) {
|
|
1490
|
+
self.socket.emit('game:leave', { room_id: self.roomId });
|
|
1491
|
+
self.roomId = null;
|
|
1492
|
+
self._lastSequence = 0;
|
|
1493
|
+
self._joined = false;
|
|
1494
|
+
self._joinPromise = null;
|
|
1495
|
+
}
|
|
1496
|
+
},
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Send a game action
|
|
1500
|
+
* @param {string} actionType - Type of action (e.g., 'move')
|
|
1501
|
+
* @param {object} actionData - Action data
|
|
1502
|
+
* @returns {Promise} Resolves when action is processed
|
|
1503
|
+
*/
|
|
1504
|
+
action: function(actionType, actionData) {
|
|
1505
|
+
const self = this;
|
|
1506
|
+
|
|
1507
|
+
if (self.directMode) {
|
|
1508
|
+
self._sendDirect(actionType || 'action', actionData || {});
|
|
1509
|
+
return Promise.resolve({ success: true });
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (self._useProxy) {
|
|
1513
|
+
Usion._post({ type: 'GAME_ACTION', room_id: self.roomId, action_type: actionType, action_data: actionData });
|
|
1514
|
+
return Promise.resolve({ success: true });
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
return new Promise(function(resolve, reject) {
|
|
1518
|
+
if (!self.socket || !self.connected) {
|
|
1519
|
+
reject(new Error('Not connected'));
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
self.socket.emit('game:action', {
|
|
1524
|
+
room_id: self.roomId,
|
|
1525
|
+
action_type: actionType,
|
|
1526
|
+
action_data: actionData
|
|
1527
|
+
}, function(response) {
|
|
1528
|
+
if (response.error) {
|
|
1529
|
+
reject(new Error(response.message || response.error));
|
|
1530
|
+
} else {
|
|
1531
|
+
if (response.sequence !== undefined) {
|
|
1532
|
+
self._lastSequence = response.sequence;
|
|
1533
|
+
}
|
|
1534
|
+
resolve(response);
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
});
|
|
1538
|
+
},
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* Send a real-time game update (no locking, no rate limiting, fire-and-forget).
|
|
1542
|
+
* Use this for high-frequency updates like position, input, state broadcasts.
|
|
1543
|
+
* @param {string} actionType - Type of action (e.g., 'snake_input', 'position')
|
|
1544
|
+
* @param {object} actionData - Action data
|
|
1545
|
+
*/
|
|
1546
|
+
realtime: function(actionType, actionData) {
|
|
1547
|
+
const self = this;
|
|
1548
|
+
|
|
1549
|
+
if (self.directMode) {
|
|
1550
|
+
self._sendDirect('input', {
|
|
1551
|
+
action_type: actionType,
|
|
1552
|
+
action_data: actionData || {}
|
|
1553
|
+
});
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
if (self._useProxy) {
|
|
1558
|
+
Usion._post({ type: 'GAME_REALTIME', room_id: self.roomId, action_type: actionType, action_data: actionData });
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (!self.socket || !self.connected) {
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
self.socket.emit('game:realtime', {
|
|
1567
|
+
room_id: self.roomId,
|
|
1568
|
+
action_type: actionType,
|
|
1569
|
+
action_data: actionData
|
|
1570
|
+
});
|
|
1571
|
+
},
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
* Request game state sync (for reconnection)
|
|
1575
|
+
* @param {number} lastSequence - Last known sequence number
|
|
1576
|
+
*/
|
|
1577
|
+
requestSync: function(lastSequence) {
|
|
1578
|
+
const self = this;
|
|
1579
|
+
lastSequence = lastSequence !== undefined ? lastSequence : self._lastSequence;
|
|
1580
|
+
|
|
1581
|
+
if (self.directMode) {
|
|
1582
|
+
self._sendDirect('ping', { last_sequence: lastSequence || 0 });
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (self._useProxy && self.roomId) {
|
|
1587
|
+
Usion._post({ type: 'GAME_SYNC_REQUEST', room_id: self.roomId, last_sequence: lastSequence || 0 });
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (self.socket && self.connected && self.roomId) {
|
|
1592
|
+
self.socket.emit('game:sync_request', {
|
|
1593
|
+
room_id: self.roomId,
|
|
1594
|
+
last_sequence: lastSequence || 0
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
},
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Request a rematch
|
|
1601
|
+
*/
|
|
1602
|
+
requestRematch: function() {
|
|
1603
|
+
const self = this;
|
|
1604
|
+
|
|
1605
|
+
if (self.directMode) {
|
|
1606
|
+
self._sendDirect('rematch', {});
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
if (self._useProxy && self.roomId) {
|
|
1611
|
+
Usion._post({ type: 'GAME_REMATCH', room_id: self.roomId });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if (self.socket && self.connected && self.roomId) {
|
|
1616
|
+
self.socket.emit('game:rematch', { room_id: self.roomId });
|
|
1617
|
+
}
|
|
1618
|
+
},
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* Forfeit the current game
|
|
1622
|
+
* @returns {Promise}
|
|
1623
|
+
*/
|
|
1624
|
+
forfeit: function() {
|
|
1625
|
+
const self = this;
|
|
1626
|
+
|
|
1627
|
+
if (self.directMode) {
|
|
1628
|
+
self._sendDirect('forfeit', {});
|
|
1629
|
+
return Promise.resolve({ success: true });
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (self._useProxy) {
|
|
1633
|
+
Usion._post({ type: 'GAME_FORFEIT', room_id: self.roomId });
|
|
1634
|
+
return Promise.resolve({ success: true });
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
return new Promise(function(resolve, reject) {
|
|
1638
|
+
if (!self.socket || !self.connected) {
|
|
1639
|
+
reject(new Error('Not connected'));
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
self.socket.emit('game:forfeit', { room_id: self.roomId }, function(response) {
|
|
1644
|
+
if (response.error) {
|
|
1645
|
+
reject(new Error(response.message || response.error));
|
|
1646
|
+
} else {
|
|
1647
|
+
resolve(response);
|
|
1648
|
+
}
|
|
1649
|
+
});
|
|
1650
|
+
});
|
|
1651
|
+
},
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Disconnect from the game socket
|
|
1655
|
+
*/
|
|
1656
|
+
disconnect: function() {
|
|
1657
|
+
const self = this;
|
|
1658
|
+
|
|
1659
|
+
// Always clear heartbeat
|
|
1660
|
+
if (self._heartbeatInterval) {
|
|
1661
|
+
clearInterval(self._heartbeatInterval);
|
|
1662
|
+
self._heartbeatInterval = null;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
if (self.directMode) {
|
|
1666
|
+
if (self.directSocket) {
|
|
1667
|
+
try { self.directSocket.close(); } catch (e) {}
|
|
1668
|
+
}
|
|
1669
|
+
self.directSocket = null;
|
|
1670
|
+
self.connected = false;
|
|
1671
|
+
self.roomId = null;
|
|
1672
|
+
self._lastSequence = 0;
|
|
1673
|
+
self._connecting = false;
|
|
1674
|
+
self._connectPromise = null;
|
|
1675
|
+
self._joined = false;
|
|
1676
|
+
self._joinPromise = null;
|
|
1677
|
+
self.directMode = false;
|
|
1678
|
+
self.directConfig = null;
|
|
1679
|
+
self._directSeq = 0;
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (self._useProxy) {
|
|
1684
|
+
Usion._post({ type: 'GAME_DISCONNECT' });
|
|
1685
|
+
self.connected = false;
|
|
1686
|
+
self.roomId = null;
|
|
1687
|
+
self._lastSequence = 0;
|
|
1688
|
+
self._connecting = false;
|
|
1689
|
+
self._connectPromise = null;
|
|
1690
|
+
self._joined = false;
|
|
1691
|
+
self._joinPromise = null;
|
|
1692
|
+
self._useProxy = false;
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
if (self.socket) {
|
|
1697
|
+
self.socket.disconnect();
|
|
1698
|
+
self.socket = null;
|
|
1699
|
+
self.connected = false;
|
|
1700
|
+
self.roomId = null;
|
|
1701
|
+
self._lastSequence = 0;
|
|
1702
|
+
self._connecting = false;
|
|
1703
|
+
self._connectPromise = null;
|
|
1704
|
+
self._joined = false;
|
|
1705
|
+
self._joinPromise = null;
|
|
1706
|
+
}
|
|
1707
|
+
},
|
|
1708
|
+
|
|
1709
|
+
/**
|
|
1710
|
+
* Get connection status
|
|
1711
|
+
* @returns {boolean}
|
|
1712
|
+
*/
|
|
1713
|
+
isConnected: function() {
|
|
1714
|
+
if (this.directMode) {
|
|
1715
|
+
return this.connected && this.directSocket && this.directSocket.readyState === WebSocket.OPEN;
|
|
1716
|
+
}
|
|
1717
|
+
return this.connected && this.socket && this.socket.connected;
|
|
1718
|
+
},
|
|
1719
|
+
|
|
1720
|
+
// Event handler registrations
|
|
1721
|
+
onJoined: function(callback) {
|
|
1722
|
+
this._eventHandlers.joined = callback;
|
|
1723
|
+
},
|
|
1724
|
+
|
|
1725
|
+
onPlayerJoined: function(callback) {
|
|
1726
|
+
this._eventHandlers.playerJoined = callback;
|
|
1727
|
+
},
|
|
1728
|
+
|
|
1729
|
+
onPlayerLeft: function(callback) {
|
|
1730
|
+
this._eventHandlers.playerLeft = callback;
|
|
1731
|
+
},
|
|
1732
|
+
|
|
1733
|
+
onStateUpdate: function(callback) {
|
|
1734
|
+
this._eventHandlers.stateUpdate = callback;
|
|
1735
|
+
},
|
|
1736
|
+
|
|
1737
|
+
onSync: function(callback) {
|
|
1738
|
+
this._eventHandlers.sync = callback;
|
|
1739
|
+
},
|
|
1740
|
+
|
|
1741
|
+
onAction: function(callback) {
|
|
1742
|
+
this._eventHandlers.action = callback;
|
|
1743
|
+
},
|
|
1744
|
+
|
|
1745
|
+
onRealtime: function(callback) {
|
|
1746
|
+
this._eventHandlers.realtime = callback;
|
|
1747
|
+
},
|
|
1748
|
+
|
|
1749
|
+
onGameFinished: function(callback) {
|
|
1750
|
+
this._eventHandlers.finished = callback;
|
|
1751
|
+
},
|
|
1752
|
+
|
|
1753
|
+
onGameRestarted: function(callback) {
|
|
1754
|
+
this._eventHandlers.restarted = callback;
|
|
1755
|
+
},
|
|
1756
|
+
|
|
1757
|
+
onError: function(callback) {
|
|
1758
|
+
this._eventHandlers.error = callback;
|
|
1759
|
+
},
|
|
1760
|
+
|
|
1761
|
+
onRematchRequest: function(callback) {
|
|
1762
|
+
this._eventHandlers.rematchRequest = callback;
|
|
1763
|
+
},
|
|
1764
|
+
|
|
1765
|
+
onDisconnect: function(callback) {
|
|
1766
|
+
this._eventHandlers.disconnect = callback;
|
|
1767
|
+
},
|
|
1768
|
+
|
|
1769
|
+
onReconnect: function(callback) {
|
|
1770
|
+
this._eventHandlers.reconnect = callback;
|
|
1771
|
+
},
|
|
1772
|
+
|
|
1773
|
+
onConnectionError: function(callback) {
|
|
1774
|
+
this._eventHandlers.connectionError = callback;
|
|
1775
|
+
},
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* Register a generic event handler
|
|
1779
|
+
* @param {string} event - Event name
|
|
1780
|
+
* @param {function} callback - Handler function
|
|
1781
|
+
*/
|
|
1782
|
+
on: function(event, callback) {
|
|
1783
|
+
if (this.socket) {
|
|
1784
|
+
this.socket.on(event, callback);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
// Expose to global
|
|
1791
|
+
global.Usion = Usion;
|
|
1792
|
+
|
|
1793
|
+
})(typeof window !== 'undefined' ? window : this);
|