app.hypergames.hypersdk 1.4.21 → 1.4.22
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/CHANGELOG.md +7 -0
- package/Editor/Scripts/Tools/HyperRandomMigrationTool.cs +122 -5
- package/HyperLoader.unity +4 -4
- package/Runtime/Internal/Managers/BackendManager.cs +43 -21
- package/Runtime/Internal/Managers/GameManager.cs +0 -5
- package/Runtime/Internal/UI/Modals/GameOverModal.cs +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.4.22](https://github.com/mvmhyper/hyper-sdk/compare/v1.4.21...v1.4.22) (2026-01-26)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* timeout updates, remove app pause, RNG migrator UX improvements ([3529f50](https://github.com/mvmhyper/hyper-sdk/commit/3529f50fd22f25563f1852be56d7cef246c06008))
|
|
7
|
+
|
|
1
8
|
## [1.4.21](https://github.com/mvmhyper/hyper-sdk/compare/v1.4.20...v1.4.21) (2026-01-12)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -24,13 +24,17 @@ namespace Hyper.Editor
|
|
|
24
24
|
public bool IsSelected;
|
|
25
25
|
public int UnityRandomCount;
|
|
26
26
|
public int SystemRandomCount;
|
|
27
|
+
public List<int> UnityRandomLineNumbers;
|
|
28
|
+
public List<int> SystemRandomLineNumbers;
|
|
27
29
|
|
|
28
|
-
public FileEntry(string path, int unityCount, int systemCount)
|
|
30
|
+
public FileEntry(string path, int unityCount, int systemCount, List<int> unityLines, List<int> systemLines)
|
|
29
31
|
{
|
|
30
32
|
Path = path;
|
|
31
33
|
IsSelected = true;
|
|
32
34
|
UnityRandomCount = unityCount;
|
|
33
35
|
SystemRandomCount = systemCount;
|
|
36
|
+
UnityRandomLineNumbers = unityLines ?? new List<int>();
|
|
37
|
+
SystemRandomLineNumbers = systemLines ?? new List<int>();
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -141,7 +145,16 @@ namespace Hyper.Editor
|
|
|
141
145
|
usageInfo = $"System: {scannedFiles[i].SystemRandomCount}";
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
|
|
148
|
+
// Make the file name clickable
|
|
149
|
+
GUIStyle clickableStyle = new GUIStyle(EditorStyles.label);
|
|
150
|
+
ColorUtility.TryParseHtmlString("#93C7FF", out Color blueColor);
|
|
151
|
+
clickableStyle.hover.textColor = blueColor;
|
|
152
|
+
|
|
153
|
+
if (GUILayout.Button($"{fileName} ({usageInfo})", clickableStyle, GUILayout.Width(350), GUILayout.Height(18)))
|
|
154
|
+
{
|
|
155
|
+
OpenScriptAtFirstInstance(scannedFiles[i]);
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
EditorGUILayout.LabelField(relativePath, EditorStyles.miniLabel);
|
|
146
159
|
|
|
147
160
|
EditorGUILayout.EndHorizontal();
|
|
@@ -217,12 +230,15 @@ namespace Hyper.Editor
|
|
|
217
230
|
(float)i / csFiles.Length);
|
|
218
231
|
|
|
219
232
|
string content = File.ReadAllText(filePath);
|
|
220
|
-
|
|
221
|
-
int
|
|
233
|
+
string[] lines = content.Split('\n');
|
|
234
|
+
List<int> unityLines = FindUnityRandomLines(content, lines);
|
|
235
|
+
List<int> systemLines = FindSystemRandomLines(content, lines);
|
|
236
|
+
int unityUsageCount = unityLines.Count;
|
|
237
|
+
int systemUsageCount = systemLines.Count;
|
|
222
238
|
|
|
223
239
|
if (unityUsageCount > 0 || systemUsageCount > 0)
|
|
224
240
|
{
|
|
225
|
-
scannedFiles.Add(new FileEntry(filePath, unityUsageCount, systemUsageCount));
|
|
241
|
+
scannedFiles.Add(new FileEntry(filePath, unityUsageCount, systemUsageCount, unityLines, systemLines));
|
|
226
242
|
}
|
|
227
243
|
}
|
|
228
244
|
|
|
@@ -245,6 +261,43 @@ namespace Hyper.Editor
|
|
|
245
261
|
}
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
private void OpenScriptAtFirstInstance(FileEntry fileEntry)
|
|
265
|
+
{
|
|
266
|
+
string relativePath = fileEntry.Path.Replace(Application.dataPath, "Assets");
|
|
267
|
+
|
|
268
|
+
// Find the first line number where Random is used
|
|
269
|
+
int firstLine = 0;
|
|
270
|
+
if (fileEntry.UnityRandomLineNumbers.Count > 0)
|
|
271
|
+
{
|
|
272
|
+
firstLine = fileEntry.UnityRandomLineNumbers[0];
|
|
273
|
+
}
|
|
274
|
+
else if (fileEntry.SystemRandomLineNumbers.Count > 0)
|
|
275
|
+
{
|
|
276
|
+
firstLine = fileEntry.SystemRandomLineNumbers[0];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Load the MonoScript asset
|
|
280
|
+
MonoScript script = AssetDatabase.LoadAssetAtPath<MonoScript>(relativePath);
|
|
281
|
+
if (script != null)
|
|
282
|
+
{
|
|
283
|
+
// Open the script at the specified line number (0 means no line specified)
|
|
284
|
+
if (firstLine > 0)
|
|
285
|
+
{
|
|
286
|
+
AssetDatabase.OpenAsset(script, firstLine);
|
|
287
|
+
}
|
|
288
|
+
else
|
|
289
|
+
{
|
|
290
|
+
AssetDatabase.OpenAsset(script);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else
|
|
294
|
+
{
|
|
295
|
+
// Fallback: just ping/select the file
|
|
296
|
+
EditorUtility.DisplayDialog("File Not Found",
|
|
297
|
+
$"Could not load script: {relativePath}\n\nThe file may not be in the AssetDatabase.", "OK");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
248
301
|
private bool ShouldExcludeFile(string filePath)
|
|
249
302
|
{
|
|
250
303
|
string normalizedPath = filePath.Replace("\\", "/");
|
|
@@ -313,6 +366,70 @@ namespace Hyper.Editor
|
|
|
313
366
|
return count;
|
|
314
367
|
}
|
|
315
368
|
|
|
369
|
+
private List<int> FindUnityRandomLines(string content, string[] lines)
|
|
370
|
+
{
|
|
371
|
+
List<int> lineNumbers = new List<int>();
|
|
372
|
+
var patterns = GetUnityRandomPatterns();
|
|
373
|
+
|
|
374
|
+
for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++)
|
|
375
|
+
{
|
|
376
|
+
string line = lines[lineIndex];
|
|
377
|
+
|
|
378
|
+
// Check each pattern
|
|
379
|
+
foreach (var pattern in patterns.Keys)
|
|
380
|
+
{
|
|
381
|
+
if (Regex.IsMatch(line, pattern))
|
|
382
|
+
{
|
|
383
|
+
lineNumbers.Add(lineIndex + 1); // Unity line numbers are 1-based
|
|
384
|
+
break; // Only count each line once
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Check for namespace aliases
|
|
389
|
+
if (Regex.IsMatch(line, @"using\s+\w+\s*=\s*(?:global::)?UnityEngine\.Random\s*;", RegexOptions.Multiline))
|
|
390
|
+
{
|
|
391
|
+
lineNumbers.Add(lineIndex + 1);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return lineNumbers;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private List<int> FindSystemRandomLines(string content, string[] lines)
|
|
399
|
+
{
|
|
400
|
+
List<int> lineNumbers = new List<int>();
|
|
401
|
+
|
|
402
|
+
for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++)
|
|
403
|
+
{
|
|
404
|
+
string line = lines[lineIndex];
|
|
405
|
+
bool foundMatch = false;
|
|
406
|
+
|
|
407
|
+
// Check for System.Random instantiations
|
|
408
|
+
if (Regex.IsMatch(line, @"new\s+Random\s*\(") ||
|
|
409
|
+
Regex.IsMatch(line, @"new\s+System\.Random\s*\("))
|
|
410
|
+
{
|
|
411
|
+
lineNumbers.Add(lineIndex + 1);
|
|
412
|
+
foundMatch = true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Check for Random type declarations
|
|
416
|
+
if (!foundMatch && (Regex.IsMatch(line, @"\bRandom\s+\w+\s*=") ||
|
|
417
|
+
Regex.IsMatch(line, @"\bSystem\.Random\s+\w+")))
|
|
418
|
+
{
|
|
419
|
+
lineNumbers.Add(lineIndex + 1);
|
|
420
|
+
foundMatch = true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Check for namespace aliases
|
|
424
|
+
if (!foundMatch && Regex.IsMatch(line, @"using\s+\w+\s*=\s*(?:global::)?System\.Random\s*;", RegexOptions.Multiline))
|
|
425
|
+
{
|
|
426
|
+
lineNumbers.Add(lineIndex + 1);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return lineNumbers;
|
|
431
|
+
}
|
|
432
|
+
|
|
316
433
|
private void MigrateFiles()
|
|
317
434
|
{
|
|
318
435
|
var selectedFiles = scannedFiles.Where(f => f.IsSelected).ToList();
|
package/HyperLoader.unity
CHANGED
|
@@ -304,8 +304,8 @@ RectTransform:
|
|
|
304
304
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
|
305
305
|
m_AnchorMin: {x: 0, y: 1}
|
|
306
306
|
m_AnchorMax: {x: 0, y: 1}
|
|
307
|
-
m_AnchoredPosition: {x: 489.
|
|
308
|
-
m_SizeDelta: {x:
|
|
307
|
+
m_AnchoredPosition: {x: 489.10004, y: -280.1698}
|
|
308
|
+
m_SizeDelta: {x: 0, y: 239.347}
|
|
309
309
|
m_Pivot: {x: 0.5, y: 0.5}
|
|
310
310
|
--- !u!114 &751153944
|
|
311
311
|
MonoBehaviour:
|
|
@@ -840,7 +840,7 @@ GameObject:
|
|
|
840
840
|
m_Component:
|
|
841
841
|
- component: {fileID: 1064166296}
|
|
842
842
|
m_Layer: 0
|
|
843
|
-
m_Name:
|
|
843
|
+
m_Name: __HyperSDK_BuildMarker_639047227933615465
|
|
844
844
|
m_TagString: Untagged
|
|
845
845
|
m_Icon: {fileID: 0}
|
|
846
846
|
m_NavMeshLayer: 0
|
|
@@ -1092,7 +1092,7 @@ RectTransform:
|
|
|
1092
1092
|
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
|
1093
1093
|
m_AnchorMin: {x: 0, y: 1}
|
|
1094
1094
|
m_AnchorMax: {x: 0, y: 1}
|
|
1095
|
-
m_AnchoredPosition: {x: 489.
|
|
1095
|
+
m_AnchoredPosition: {x: 489.10004, y: -133.2274}
|
|
1096
1096
|
m_SizeDelta: {x: 875, y: 54.5378}
|
|
1097
1097
|
m_Pivot: {x: 0.5, y: 0.5}
|
|
1098
1098
|
--- !u!1 &2874543288936963705
|
|
@@ -42,7 +42,7 @@ namespace Hyper.Internal.Managers
|
|
|
42
42
|
|
|
43
43
|
#region Public Properties (Internal API)
|
|
44
44
|
|
|
45
|
-
public int scoreSubmissionTimeout { get; private set; } =
|
|
45
|
+
public int scoreSubmissionTimeout { get; private set; } = 7;
|
|
46
46
|
public int CurrentRetryCount => _currentRetryCount;
|
|
47
47
|
public bool isSocketConnected { get; private set; }
|
|
48
48
|
public bool tutorialFetchSuccess { get; private set; }
|
|
@@ -156,7 +156,7 @@ namespace Hyper.Internal.Managers
|
|
|
156
156
|
entryId = _gameManager?.Config.EntryId,
|
|
157
157
|
host = _gameManager?.Config.Host,
|
|
158
158
|
time = _gameManager?.Config.TimeLimit,
|
|
159
|
-
|
|
159
|
+
userName = _gameManager?.Config.Username,
|
|
160
160
|
userId = _gameManager?.Config.UserId,
|
|
161
161
|
pvp = _gameManager?.Config.isPVP,
|
|
162
162
|
oldTournamentId = _gameManager?.Config.OldTournamentId
|
|
@@ -299,6 +299,7 @@ namespace Hyper.Internal.Managers
|
|
|
299
299
|
public void StopWebSocket()
|
|
300
300
|
{
|
|
301
301
|
_isManuallyClosed = true;
|
|
302
|
+
isSocketConnected = false;
|
|
302
303
|
CloseWebSocket();
|
|
303
304
|
}
|
|
304
305
|
|
|
@@ -354,6 +355,14 @@ namespace Hyper.Internal.Managers
|
|
|
354
355
|
yield break;
|
|
355
356
|
}
|
|
356
357
|
|
|
358
|
+
// Don't send if socket is not connected - prevents wasting retry attempts
|
|
359
|
+
if (!isSocketConnected)
|
|
360
|
+
{
|
|
361
|
+
HyperDebug.LogWarning("Cannot send gameResumed: WebSocket not connected");
|
|
362
|
+
_sendGameResumedCoroutine = null;
|
|
363
|
+
yield break;
|
|
364
|
+
}
|
|
365
|
+
|
|
357
366
|
_gameResumed = false;
|
|
358
367
|
|
|
359
368
|
while (!_gameResumed && attempts < maxAttempts)
|
|
@@ -454,6 +463,11 @@ namespace Hyper.Internal.Managers
|
|
|
454
463
|
|
|
455
464
|
public void OnSocketConnected()
|
|
456
465
|
{
|
|
466
|
+
// Mark connected before any follow-up actions (resumeGame, UI updates, etc.)
|
|
467
|
+
isSocketConnected = true;
|
|
468
|
+
_isManuallyClosed = false;
|
|
469
|
+
_isSocketErrorCalled = false;
|
|
470
|
+
|
|
457
471
|
if (_gameManager.HasGameStarted)
|
|
458
472
|
{
|
|
459
473
|
_gameManager?.ShowConnectionRestored();
|
|
@@ -463,20 +477,18 @@ namespace Hyper.Internal.Managers
|
|
|
463
477
|
{
|
|
464
478
|
_eventManager?.InvokeConnectionFoundEvent();
|
|
465
479
|
}
|
|
466
|
-
|
|
467
|
-
isSocketConnected = true;
|
|
468
|
-
_isManuallyClosed = false;
|
|
469
|
-
_isSocketErrorCalled = false;
|
|
470
480
|
}
|
|
471
481
|
|
|
472
482
|
public void OnSocketDisconnected()
|
|
473
483
|
{
|
|
484
|
+
isSocketConnected = false;
|
|
485
|
+
|
|
474
486
|
if (!_isManuallyClosed)
|
|
475
487
|
{
|
|
476
488
|
_gameManager?.ShowConnectionLost();
|
|
477
489
|
}
|
|
478
490
|
|
|
479
|
-
if (!_gameManager.HasGameStarted
|
|
491
|
+
if (!_gameManager.HasGameStarted)
|
|
480
492
|
{
|
|
481
493
|
_isSocketErrorCalled = true;
|
|
482
494
|
_gameManager?.ShowLoadingFailedModal(true);
|
|
@@ -530,10 +542,11 @@ namespace Hyper.Internal.Managers
|
|
|
530
542
|
_eventManager?.InvokeWebSocketScoreSubmitted(_lastScoreData);
|
|
531
543
|
}
|
|
532
544
|
|
|
533
|
-
if (
|
|
545
|
+
if (_isWaitingForFinalScoreResponse)
|
|
534
546
|
{
|
|
535
547
|
_finalScoreSubmitted = true;
|
|
536
|
-
|
|
548
|
+
_isWaitingForFinalScoreResponse = false;
|
|
549
|
+
return;
|
|
537
550
|
}
|
|
538
551
|
|
|
539
552
|
TryUpdateCSRFToken(message);
|
|
@@ -663,11 +676,7 @@ namespace Hyper.Internal.Managers
|
|
|
663
676
|
|
|
664
677
|
_finalScoreSubmissionCoroutine = null;
|
|
665
678
|
|
|
666
|
-
|
|
667
|
-
_finalScoreSubmissionCoroutine = StartCoroutine(FinalScoreSubmissionCoroutine());
|
|
668
|
-
#else
|
|
669
|
-
_finalScoreSubmissionCoroutine = StartCoroutine(FinalScoreSubmissionCoroutineDemo());
|
|
670
|
-
#endif
|
|
679
|
+
SubmitScore();
|
|
671
680
|
}
|
|
672
681
|
|
|
673
682
|
private IEnumerator FinalScoreSubmissionCoroutine()
|
|
@@ -727,7 +736,7 @@ namespace Hyper.Internal.Managers
|
|
|
727
736
|
|
|
728
737
|
// Sending final score via WebSocket
|
|
729
738
|
_finalScoreSubmitted = false;
|
|
730
|
-
|
|
739
|
+
_isWaitingForFinalScoreResponse = true;
|
|
731
740
|
_lastSentBatch = new List<ScoreData> { finalScoreData };
|
|
732
741
|
|
|
733
742
|
bool sendFailed = false;
|
|
@@ -740,7 +749,8 @@ namespace Hyper.Internal.Managers
|
|
|
740
749
|
catch (Exception ex)
|
|
741
750
|
{
|
|
742
751
|
HyperDebug.LogError($"Failed to send final score via WebSocket: {ex.Message}");
|
|
743
|
-
|
|
752
|
+
// Keep _isWaitingForFinalScoreResponse = true since we're still in final score submission mode
|
|
753
|
+
// and will retry. We're still waiting for a final score response.
|
|
744
754
|
sendFailed = true;
|
|
745
755
|
}
|
|
746
756
|
|
|
@@ -765,7 +775,8 @@ namespace Hyper.Internal.Managers
|
|
|
765
775
|
if (_hasSessionEnded)
|
|
766
776
|
{
|
|
767
777
|
_gameManager?.ShowRetryTimeExpiredModal();
|
|
768
|
-
|
|
778
|
+
// Session ended - can't retry anymore, so set to false
|
|
779
|
+
_isWaitingForFinalScoreResponse = false;
|
|
769
780
|
_finalScoreSubmissionCoroutine = null;
|
|
770
781
|
yield break;
|
|
771
782
|
}
|
|
@@ -783,13 +794,16 @@ namespace Hyper.Internal.Managers
|
|
|
783
794
|
else if (_hasSessionEnded)
|
|
784
795
|
{
|
|
785
796
|
_gameManager?.ShowRetryTimeExpiredModal();
|
|
786
|
-
|
|
797
|
+
// Session ended - can't retry anymore, so set to false
|
|
798
|
+
_isWaitingForFinalScoreResponse = false;
|
|
787
799
|
_finalScoreSubmissionCoroutine = null;
|
|
788
800
|
yield break;
|
|
789
801
|
}
|
|
790
802
|
else
|
|
791
803
|
{
|
|
792
|
-
|
|
804
|
+
// Timeout occurred, but keep _isWaitingForFinalScoreResponse = true
|
|
805
|
+
// because we're still in final score submission mode and will retry.
|
|
806
|
+
// If backend responds during retry wait, we'll catch it.
|
|
793
807
|
_currentRetryCount++;
|
|
794
808
|
_gameManager?.GameOverModalComponent?.UpdateAttemptCount(_currentRetryCount, MAX_RETRY_COUNT);
|
|
795
809
|
|
|
@@ -800,7 +814,7 @@ namespace Hyper.Internal.Managers
|
|
|
800
814
|
}
|
|
801
815
|
}
|
|
802
816
|
|
|
803
|
-
|
|
817
|
+
_isWaitingForFinalScoreResponse = false;
|
|
804
818
|
_gameManager?.GameOverModalComponent?.AllAttemptsHaveFailed();
|
|
805
819
|
_finalScoreSubmissionCoroutine = null;
|
|
806
820
|
}
|
|
@@ -831,7 +845,7 @@ namespace Hyper.Internal.Managers
|
|
|
831
845
|
private List<ScoreData> _lastSentBatch = new List<ScoreData>();
|
|
832
846
|
private ScoreData _lastScoreData;
|
|
833
847
|
private readonly WaitForSeconds _scoreSendInterval = new WaitForSeconds(1f);
|
|
834
|
-
private bool
|
|
848
|
+
private bool _isWaitingForFinalScoreResponse;
|
|
835
849
|
private bool _finalScoreSubmitted;
|
|
836
850
|
|
|
837
851
|
public void StartSendingScores()
|
|
@@ -883,6 +897,14 @@ namespace Hyper.Internal.Managers
|
|
|
883
897
|
{
|
|
884
898
|
if (scores == null || scores.Count == 0) return;
|
|
885
899
|
|
|
900
|
+
// Don't send if socket is not connected - prevents sending with expired tokens
|
|
901
|
+
// and ensures we wait for reconnection to get fresh token via SendGameResumed()
|
|
902
|
+
if (!isSocketConnected)
|
|
903
|
+
{
|
|
904
|
+
HyperDebug.LogWarning("Cannot send scores: WebSocket not connected");
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
886
908
|
try
|
|
887
909
|
{
|
|
888
910
|
_lastSentBatch = new List<ScoreData>(scores);
|
|
@@ -190,11 +190,6 @@ namespace Hyper.Internal.Managers
|
|
|
190
190
|
HandlePauseState(!hasFocus);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
void OnApplicationPause(bool pauseStatus)
|
|
194
|
-
{
|
|
195
|
-
HandlePauseState(pauseStatus);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
193
|
void HandlePauseState(bool shouldPause)
|
|
199
194
|
{
|
|
200
195
|
if (shouldPause && !_isPaused)
|
|
@@ -104,7 +104,7 @@ namespace Hyper.Internal
|
|
|
104
104
|
// Called from the SlideRight script once the animation is finished
|
|
105
105
|
public void StartAction()
|
|
106
106
|
{
|
|
107
|
-
StartLerpingSlider(0.
|
|
107
|
+
StartLerpingSlider(0.75f, HyperRuntime.BackendManager.scoreSubmissionTimeout);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
public void ScoreHasBeenSubmitted()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "app.hypergames.hypersdk",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.22",
|
|
4
4
|
"displayName": "HyperSDK",
|
|
5
5
|
"description": "Official Unity SDK for Hyper: multiplayer battles, leaderboards, deterministic RNG, session management, tutorials, and WebGL optimizations for production-ready games.",
|
|
6
6
|
"unity": "6000.0",
|