@myvillage/cli 1.9.0 → 1.10.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/package.json +1 -1
- package/src/commands/create-game.js +119 -6
- package/src/commands/deploy.js +267 -19
- package/src/commands/game.js +712 -0
- package/src/index.js +78 -47
- package/src/utils/api.js +44 -0
- package/src/utils/templates.js +608 -2
package/src/utils/templates.js
CHANGED
|
@@ -36,7 +36,7 @@ export function createGameProject(targetDir, options) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Write common files
|
|
39
|
-
writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type));
|
|
39
|
+
writeFileSync(join(targetDir, 'package.json'), generatePackageJson(name, description, slug, type, ageGroup));
|
|
40
40
|
writeFileSync(join(targetDir, 'vite.config.js'), generateViteConfig());
|
|
41
41
|
writeFileSync(join(targetDir, '.gitignore'), generateGitignore());
|
|
42
42
|
writeFileSync(join(targetDir, 'README.md'), generateReadme(name, description, type, ageGroup));
|
|
@@ -66,7 +66,611 @@ export function createGameProject(targetDir, options) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// ── Unity Game Project ─────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export function createUnityGameProject(targetDir, options) {
|
|
72
|
+
const { name, description, type, ageGroup, platform } = options;
|
|
73
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
74
|
+
const className = name.replace(/[^a-zA-Z0-9]/g, '');
|
|
75
|
+
|
|
76
|
+
const dirs = [
|
|
77
|
+
'',
|
|
78
|
+
'Scripts',
|
|
79
|
+
'Scripts/MyVillageOS',
|
|
80
|
+
'builds',
|
|
81
|
+
'builds/ios',
|
|
82
|
+
'builds/android',
|
|
83
|
+
'builds/webgl',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const dir of dirs) {
|
|
87
|
+
mkdirSync(join(targetDir, dir), { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Config file (CLI reads this instead of package.json for Unity projects)
|
|
91
|
+
writeFileSync(join(targetDir, 'myvillage.json'), JSON.stringify({
|
|
92
|
+
name,
|
|
93
|
+
slug,
|
|
94
|
+
description,
|
|
95
|
+
engine: 'unity',
|
|
96
|
+
gameType: platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE',
|
|
97
|
+
category: type.toUpperCase(),
|
|
98
|
+
targetAge: ageGroup,
|
|
99
|
+
gameId: null,
|
|
100
|
+
lastDeployed: null,
|
|
101
|
+
}, null, 2) + '\n');
|
|
102
|
+
|
|
103
|
+
writeFileSync(join(targetDir, '.gitignore'), [
|
|
104
|
+
'builds/*/*.meta', 'Library/', 'Temp/', 'Logs/', 'obj/',
|
|
105
|
+
'UserSettings/', '.vs/', '*.csproj', '*.sln',
|
|
106
|
+
].join('\n') + '\n');
|
|
107
|
+
|
|
108
|
+
// MyVillageOS integration scripts
|
|
109
|
+
writeFileSync(join(targetDir, 'Scripts/MyVillageOS/EdPlatformConfig.cs'), generateEdPlatformConfig());
|
|
110
|
+
writeFileSync(join(targetDir, 'Scripts/MyVillageOS/GameSessionReporter.cs'), generateGameSessionReporter());
|
|
111
|
+
writeFileSync(join(targetDir, 'Scripts/MyVillageOS/MyVillageAuth.cs'), generateMyVillageAuth());
|
|
112
|
+
writeFileSync(join(targetDir, 'Scripts/MyVillageOS/PKCEHelper.cs'), generatePKCEHelper());
|
|
113
|
+
writeFileSync(join(targetDir, 'Scripts/MyVillageOS/MyVillageEvents.cs'), generateMyVillageEvents());
|
|
114
|
+
|
|
115
|
+
// Game-specific loader template
|
|
116
|
+
writeFileSync(join(targetDir, `Scripts/${className}Loader.cs`), generateGameLoader(className, slug));
|
|
117
|
+
|
|
118
|
+
// README
|
|
119
|
+
writeFileSync(join(targetDir, 'README.md'), generateUnityReadme(name, slug, className, platform));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function generateEdPlatformConfig() {
|
|
123
|
+
return `using UnityEngine;
|
|
124
|
+
using UnityEngine.Networking;
|
|
125
|
+
|
|
126
|
+
/// <summary>
|
|
127
|
+
/// MyVillageOS ed-platform API configuration.
|
|
128
|
+
/// All endpoints use dynamic game slugs — no hardcoded game IDs.
|
|
129
|
+
/// </summary>
|
|
130
|
+
public static class EdPlatformConfig
|
|
131
|
+
{
|
|
132
|
+
private const string ProductionUrl = "https://portal.myvillageproject.ai";
|
|
133
|
+
private const string DevelopmentUrl = "http://localhost:3000";
|
|
134
|
+
|
|
135
|
+
public static string BaseUrl
|
|
136
|
+
{
|
|
137
|
+
get
|
|
138
|
+
{
|
|
139
|
+
#if UNITY_EDITOR || DEVELOPMENT_BUILD
|
|
140
|
+
return DevelopmentUrl;
|
|
141
|
+
#else
|
|
142
|
+
return ProductionUrl;
|
|
143
|
+
#endif
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Game catalog
|
|
148
|
+
public static string GamesListEndpoint => $"{BaseUrl}/api/v1/games";
|
|
149
|
+
|
|
150
|
+
// Per-game sessions
|
|
151
|
+
public static string SessionsEndpoint(string gameSlug) =>
|
|
152
|
+
$"{BaseUrl}/api/v1/games/{gameSlug}/sessions";
|
|
153
|
+
|
|
154
|
+
public static string SessionCompleteEndpoint(string gameSlug, string sessionId) =>
|
|
155
|
+
$"{SessionsEndpoint(gameSlug)}/{sessionId}/complete";
|
|
156
|
+
|
|
157
|
+
// Bundle URLs
|
|
158
|
+
public static string BundleUrl(string gameSlug, string bundleName)
|
|
159
|
+
{
|
|
160
|
+
string escaped = UnityWebRequest.EscapeURL(bundleName.ToLower());
|
|
161
|
+
return $"{BaseUrl}/api/v1/games/game-assets/games/{gameSlug}/bundles/{CurrentPlatform}/{escaped}";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
public static string CurrentPlatform
|
|
165
|
+
{
|
|
166
|
+
get
|
|
167
|
+
{
|
|
168
|
+
#if UNITY_IOS
|
|
169
|
+
return "ios";
|
|
170
|
+
#elif UNITY_ANDROID
|
|
171
|
+
return "android";
|
|
172
|
+
#elif UNITY_WEBGL
|
|
173
|
+
return "webgl";
|
|
174
|
+
#else
|
|
175
|
+
return "desktop";
|
|
176
|
+
#endif
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function generateGameSessionReporter() {
|
|
184
|
+
return `using UnityEngine;
|
|
185
|
+
using UnityEngine.Networking;
|
|
186
|
+
using System;
|
|
187
|
+
using System.Collections;
|
|
188
|
+
using System.Text;
|
|
189
|
+
|
|
190
|
+
/// <summary>
|
|
191
|
+
/// Manages game session lifecycle with the MyVillageOS ed-platform.
|
|
192
|
+
/// Attach to a persistent GameObject. Call StartSession() when gameplay
|
|
193
|
+
/// begins and CompleteSession() when the player finishes.
|
|
194
|
+
/// </summary>
|
|
195
|
+
public class GameSessionReporter : MonoBehaviour
|
|
196
|
+
{
|
|
197
|
+
public static GameSessionReporter Instance { get; private set; }
|
|
198
|
+
|
|
199
|
+
[Header("Game Settings")]
|
|
200
|
+
[Tooltip("Your game's slug on the ed-platform (e.g. 'eternal-equations')")]
|
|
201
|
+
public string GameSlug;
|
|
202
|
+
|
|
203
|
+
private string currentSessionId;
|
|
204
|
+
private float sessionStartTime;
|
|
205
|
+
|
|
206
|
+
void Awake()
|
|
207
|
+
{
|
|
208
|
+
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
|
209
|
+
Instance = this;
|
|
210
|
+
DontDestroyOnLoad(gameObject);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// <summary>Start a new play session. Call when gameplay begins.</summary>
|
|
214
|
+
public void StartSession()
|
|
215
|
+
{
|
|
216
|
+
if (string.IsNullOrEmpty(GameSlug))
|
|
217
|
+
{
|
|
218
|
+
Debug.LogWarning("[MyVillageOS] GameSlug not set on GameSessionReporter");
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
StartCoroutine(StartSessionCoroutine());
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// <summary>Complete the current session with a score. Call when the player finishes.</summary>
|
|
225
|
+
public void CompleteSession(int score, int correct = 0, int incorrect = 0, string difficulty = "medium")
|
|
226
|
+
{
|
|
227
|
+
if (string.IsNullOrEmpty(currentSessionId))
|
|
228
|
+
{
|
|
229
|
+
Debug.LogWarning("[MyVillageOS] No active session to complete");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
StartCoroutine(CompleteSessionCoroutine(score, correct, incorrect, difficulty));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private IEnumerator StartSessionCoroutine()
|
|
236
|
+
{
|
|
237
|
+
string url = EdPlatformConfig.SessionsEndpoint(GameSlug);
|
|
238
|
+
string json = JsonUtility.ToJson(new SessionStartRequest { platform = EdPlatformConfig.CurrentPlatform });
|
|
239
|
+
|
|
240
|
+
using (var req = new UnityWebRequest(url, "POST"))
|
|
241
|
+
{
|
|
242
|
+
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
|
|
243
|
+
req.downloadHandler = new DownloadHandlerBuffer();
|
|
244
|
+
req.SetRequestHeader("Content-Type", "application/json");
|
|
245
|
+
AddAuthHeader(req);
|
|
246
|
+
|
|
247
|
+
yield return req.SendWebRequest();
|
|
248
|
+
|
|
249
|
+
if (req.result == UnityWebRequest.Result.Success)
|
|
250
|
+
{
|
|
251
|
+
var response = JsonUtility.FromJson<SessionStartResponse>(req.downloadHandler.text);
|
|
252
|
+
currentSessionId = response.session?.id;
|
|
253
|
+
sessionStartTime = Time.realtimeSinceStartup;
|
|
254
|
+
MyVillageEvents.OnSessionStarted?.Invoke(currentSessionId);
|
|
255
|
+
Debug.Log($"[MyVillageOS] Session started: {currentSessionId}");
|
|
256
|
+
}
|
|
257
|
+
else
|
|
258
|
+
{
|
|
259
|
+
Debug.LogWarning($"[MyVillageOS] Failed to start session: {req.error}");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private IEnumerator CompleteSessionCoroutine(int score, int correct, int incorrect, string difficulty)
|
|
265
|
+
{
|
|
266
|
+
string url = EdPlatformConfig.SessionCompleteEndpoint(GameSlug, currentSessionId);
|
|
267
|
+
int duration = Mathf.RoundToInt(Time.realtimeSinceStartup - sessionStartTime);
|
|
268
|
+
|
|
269
|
+
var body = new SessionCompleteRequest
|
|
270
|
+
{
|
|
271
|
+
score = score,
|
|
272
|
+
duration = duration,
|
|
273
|
+
sessionData = new SessionData { correct = correct, incorrect = incorrect, difficulty = difficulty }
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
string json = JsonUtility.ToJson(body);
|
|
277
|
+
|
|
278
|
+
using (var req = new UnityWebRequest(url, "POST"))
|
|
279
|
+
{
|
|
280
|
+
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json));
|
|
281
|
+
req.downloadHandler = new DownloadHandlerBuffer();
|
|
282
|
+
req.SetRequestHeader("Content-Type", "application/json");
|
|
283
|
+
AddAuthHeader(req);
|
|
284
|
+
|
|
285
|
+
yield return req.SendWebRequest();
|
|
286
|
+
|
|
287
|
+
if (req.result == UnityWebRequest.Result.Success)
|
|
288
|
+
{
|
|
289
|
+
var response = JsonUtility.FromJson<SessionCompleteResponse>(req.downloadHandler.text);
|
|
290
|
+
int mvt = response.mvtAwarded;
|
|
291
|
+
MyVillageEvents.OnSessionCompleted?.Invoke(score, duration);
|
|
292
|
+
if (mvt > 0) MyVillageEvents.OnMVTAwarded?.Invoke(mvt);
|
|
293
|
+
Debug.Log($"[MyVillageOS] Session complete. Score: {score}, MVT: {mvt}");
|
|
294
|
+
}
|
|
295
|
+
else
|
|
296
|
+
{
|
|
297
|
+
Debug.LogWarning($"[MyVillageOS] Failed to complete session: {req.error}");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
currentSessionId = null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private void AddAuthHeader(UnityWebRequest req)
|
|
305
|
+
{
|
|
306
|
+
string token = PlayerPrefs.GetString("jwt_token", "");
|
|
307
|
+
if (!string.IsNullOrEmpty(token))
|
|
308
|
+
req.SetRequestHeader("Authorization", $"Bearer {token}");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Request/Response DTOs ──
|
|
312
|
+
|
|
313
|
+
[Serializable] private class SessionStartRequest { public string platform; }
|
|
314
|
+
[Serializable] private class SessionStartResponse { public SessionInfo session; }
|
|
315
|
+
[Serializable] private class SessionInfo { public string id; }
|
|
316
|
+
[Serializable] private class SessionCompleteRequest { public int score; public int duration; public SessionData sessionData; }
|
|
317
|
+
[Serializable] private class SessionData { public int correct; public int incorrect; public string difficulty; }
|
|
318
|
+
[Serializable] private class SessionCompleteResponse { public int mvtAwarded; public bool rewardSkipped; }
|
|
319
|
+
}
|
|
320
|
+
`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function generateMyVillageAuth() {
|
|
324
|
+
return `using UnityEngine;
|
|
325
|
+
using UnityEngine.Networking;
|
|
326
|
+
using System;
|
|
327
|
+
using System.Collections;
|
|
328
|
+
using System.Text;
|
|
329
|
+
|
|
330
|
+
/// <summary>
|
|
331
|
+
/// OAuth 2.0 + PKCE authentication with MyVillageOS.
|
|
332
|
+
/// Call SignIn() to open the browser login flow. The JWT token is
|
|
333
|
+
/// stored in PlayerPrefs and used automatically by GameSessionReporter.
|
|
334
|
+
/// </summary>
|
|
335
|
+
public class MyVillageAuth : MonoBehaviour
|
|
336
|
+
{
|
|
337
|
+
public static MyVillageAuth Instance { get; private set; }
|
|
338
|
+
|
|
339
|
+
private const string ClientId = "mvos_0L7avBZtpKSIyAT_kZT0Lg";
|
|
340
|
+
private const string Scopes = "openid profile email phone villager offline_access";
|
|
341
|
+
|
|
342
|
+
private string codeVerifier;
|
|
343
|
+
private string state;
|
|
344
|
+
|
|
345
|
+
public bool IsSignedIn => !string.IsNullOrEmpty(PlayerPrefs.GetString("jwt_token", ""));
|
|
346
|
+
|
|
347
|
+
void Awake()
|
|
348
|
+
{
|
|
349
|
+
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
|
350
|
+
Instance = this;
|
|
351
|
+
DontDestroyOnLoad(gameObject);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/// <summary>Open the MyVillageOS login page in the system browser.</summary>
|
|
355
|
+
public void SignIn()
|
|
356
|
+
{
|
|
357
|
+
codeVerifier = PKCEHelper.GenerateCodeVerifier();
|
|
358
|
+
string codeChallenge = PKCEHelper.GenerateCodeChallenge(codeVerifier);
|
|
359
|
+
state = Guid.NewGuid().ToString("N");
|
|
360
|
+
|
|
361
|
+
string redirectUri = GetRedirectUri();
|
|
362
|
+
string url = $"{EdPlatformConfig.BaseUrl}/api/oauth/authorize" +
|
|
363
|
+
$"?response_type=code&client_id={ClientId}" +
|
|
364
|
+
$"&redirect_uri={UnityWebRequest.EscapeURL(redirectUri)}" +
|
|
365
|
+
$"&scope={UnityWebRequest.EscapeURL(Scopes)}" +
|
|
366
|
+
$"&code_challenge={codeChallenge}&code_challenge_method=S256" +
|
|
367
|
+
$"&state={state}";
|
|
368
|
+
|
|
369
|
+
Application.OpenURL(url);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/// <summary>Call this when you receive the auth callback with a code.</summary>
|
|
373
|
+
public void HandleCallback(string code, string returnedState)
|
|
374
|
+
{
|
|
375
|
+
if (returnedState != state)
|
|
376
|
+
{
|
|
377
|
+
Debug.LogError("[MyVillageOS] OAuth state mismatch — possible CSRF attack");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
StartCoroutine(ExchangeCodeForToken(code));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/// <summary>Clear the stored token.</summary>
|
|
384
|
+
public void SignOut()
|
|
385
|
+
{
|
|
386
|
+
PlayerPrefs.DeleteKey("jwt_token");
|
|
387
|
+
PlayerPrefs.DeleteKey("refresh_token");
|
|
388
|
+
PlayerPrefs.Save();
|
|
389
|
+
MyVillageEvents.OnSignedOut?.Invoke();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private IEnumerator ExchangeCodeForToken(string code)
|
|
393
|
+
{
|
|
394
|
+
string url = $"{EdPlatformConfig.BaseUrl}/api/oauth/token";
|
|
395
|
+
string body = $"grant_type=authorization_code&code={code}" +
|
|
396
|
+
$"&redirect_uri={UnityWebRequest.EscapeURL(GetRedirectUri())}" +
|
|
397
|
+
$"&client_id={ClientId}&code_verifier={codeVerifier}";
|
|
398
|
+
|
|
399
|
+
using (var req = new UnityWebRequest(url, "POST"))
|
|
400
|
+
{
|
|
401
|
+
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
|
|
402
|
+
req.downloadHandler = new DownloadHandlerBuffer();
|
|
403
|
+
req.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
|
404
|
+
req.timeout = 15;
|
|
405
|
+
|
|
406
|
+
yield return req.SendWebRequest();
|
|
407
|
+
|
|
408
|
+
if (req.result == UnityWebRequest.Result.Success)
|
|
409
|
+
{
|
|
410
|
+
var token = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text);
|
|
411
|
+
PlayerPrefs.SetString("jwt_token", token.access_token);
|
|
412
|
+
if (!string.IsNullOrEmpty(token.refresh_token))
|
|
413
|
+
PlayerPrefs.SetString("refresh_token", token.refresh_token);
|
|
414
|
+
PlayerPrefs.Save();
|
|
415
|
+
MyVillageEvents.OnSignedIn?.Invoke();
|
|
416
|
+
Debug.Log("[MyVillageOS] Signed in successfully");
|
|
417
|
+
}
|
|
418
|
+
else
|
|
419
|
+
{
|
|
420
|
+
Debug.LogError($"[MyVillageOS] Token exchange failed: {req.error}");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private string GetRedirectUri()
|
|
426
|
+
{
|
|
427
|
+
#if UNITY_EDITOR || UNITY_STANDALONE
|
|
428
|
+
return "http://localhost:8429/auth/callback";
|
|
429
|
+
#else
|
|
430
|
+
// Replace with your app's deep link scheme
|
|
431
|
+
return "myvillagegame://auth/callback";
|
|
432
|
+
#endif
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
[Serializable] private class TokenResponse
|
|
436
|
+
{
|
|
437
|
+
public string access_token;
|
|
438
|
+
public string refresh_token;
|
|
439
|
+
public int expires_in;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function generatePKCEHelper() {
|
|
446
|
+
return `using System;
|
|
447
|
+
using System.Security.Cryptography;
|
|
448
|
+
using System.Text;
|
|
449
|
+
|
|
450
|
+
/// <summary>PKCE (Proof Key for Code Exchange) helper for OAuth 2.0.</summary>
|
|
451
|
+
public static class PKCEHelper
|
|
452
|
+
{
|
|
453
|
+
public static string GenerateCodeVerifier()
|
|
454
|
+
{
|
|
455
|
+
byte[] bytes = new byte[32];
|
|
456
|
+
using (var rng = RandomNumberGenerator.Create()) { rng.GetBytes(bytes); }
|
|
457
|
+
return Convert.ToBase64String(bytes)
|
|
458
|
+
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
public static string GenerateCodeChallenge(string codeVerifier)
|
|
462
|
+
{
|
|
463
|
+
using (var sha256 = SHA256.Create())
|
|
464
|
+
{
|
|
465
|
+
byte[] hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(codeVerifier));
|
|
466
|
+
return Convert.ToBase64String(hash)
|
|
467
|
+
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function generateMyVillageEvents() {
|
|
475
|
+
return `using System;
|
|
476
|
+
|
|
477
|
+
/// <summary>
|
|
478
|
+
/// Global events for MyVillageOS integration.
|
|
479
|
+
/// Subscribe to these in your game scripts to react to auth and session changes.
|
|
480
|
+
/// </summary>
|
|
481
|
+
public static class MyVillageEvents
|
|
482
|
+
{
|
|
483
|
+
// Auth
|
|
484
|
+
public static Action OnSignedIn;
|
|
485
|
+
public static Action OnSignedOut;
|
|
486
|
+
|
|
487
|
+
// Sessions
|
|
488
|
+
public static Action<string> OnSessionStarted; // sessionId
|
|
489
|
+
public static Action<int, int> OnSessionCompleted; // score, duration
|
|
490
|
+
public static Action<int> OnMVTAwarded; // amount
|
|
491
|
+
}
|
|
492
|
+
`;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function generateGameLoader(className, slug) {
|
|
496
|
+
return `using UnityEngine;
|
|
497
|
+
using UnityEngine.Networking;
|
|
498
|
+
using System.Collections;
|
|
499
|
+
|
|
500
|
+
/// <summary>
|
|
501
|
+
/// Downloads and loads the AssetBundle for this mini-game.
|
|
502
|
+
/// Attach to a GameObject in your loading scene.
|
|
503
|
+
/// Set AssetName in the Inspector to your bundle file name.
|
|
504
|
+
///
|
|
505
|
+
/// Game slug: "${slug}"
|
|
506
|
+
/// </summary>
|
|
507
|
+
public class ${className}Loader : MonoBehaviour
|
|
508
|
+
{
|
|
509
|
+
[Header("Bundle Settings")]
|
|
510
|
+
[Tooltip("The AssetBundle file name (e.g. 'my-game')")]
|
|
511
|
+
public string AssetName;
|
|
512
|
+
|
|
513
|
+
[Tooltip("The scene name inside the AssetBundle to load")]
|
|
514
|
+
public string SceneName;
|
|
515
|
+
|
|
516
|
+
private const string GameSlug = "${slug}";
|
|
517
|
+
|
|
518
|
+
void Start()
|
|
519
|
+
{
|
|
520
|
+
if (!string.IsNullOrEmpty(AssetName))
|
|
521
|
+
StartCoroutine(LoadBundle());
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private IEnumerator LoadBundle()
|
|
525
|
+
{
|
|
526
|
+
string url = EdPlatformConfig.BundleUrl(GameSlug, AssetName);
|
|
527
|
+
Debug.Log($"[${className}Loader] Downloading bundle from: {url}");
|
|
528
|
+
|
|
529
|
+
using (var req = UnityWebRequestAssetBundle.GetAssetBundle(url))
|
|
530
|
+
{
|
|
531
|
+
var op = req.SendWebRequest();
|
|
532
|
+
|
|
533
|
+
while (!op.isDone)
|
|
534
|
+
{
|
|
535
|
+
// You can update a progress bar here: req.downloadProgress
|
|
536
|
+
yield return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (req.result != UnityWebRequest.Result.Success)
|
|
540
|
+
{
|
|
541
|
+
Debug.LogError($"[${className}Loader] Bundle download failed: {req.error}");
|
|
542
|
+
yield break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(req);
|
|
546
|
+
|
|
547
|
+
// Start a game session
|
|
548
|
+
if (GameSessionReporter.Instance != null)
|
|
549
|
+
{
|
|
550
|
+
GameSessionReporter.Instance.GameSlug = GameSlug;
|
|
551
|
+
GameSessionReporter.Instance.StartSession();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Load the scene from the bundle
|
|
555
|
+
if (!string.IsNullOrEmpty(SceneName))
|
|
556
|
+
{
|
|
557
|
+
string[] scenes = bundle.GetAllScenePaths();
|
|
558
|
+
if (scenes.Length > 0)
|
|
559
|
+
UnityEngine.SceneManagement.SceneManager.LoadScene(scenes[0]);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/// <summary>
|
|
565
|
+
/// Call this when the player finishes your game.
|
|
566
|
+
/// It reports the score to MyVillageOS and awards MVT tokens.
|
|
567
|
+
/// </summary>
|
|
568
|
+
public void OnGameComplete(int score, int correct = 0, int incorrect = 0)
|
|
569
|
+
{
|
|
570
|
+
if (GameSessionReporter.Instance != null)
|
|
571
|
+
GameSessionReporter.Instance.CompleteSession(score, correct, incorrect);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function generateUnityReadme(name, slug, className, platform) {
|
|
578
|
+
const gameType = platform === 'webgl' ? 'UNITY_WEBGL' : 'UNITY_NATIVE';
|
|
579
|
+
return `# ${name}
|
|
580
|
+
|
|
581
|
+
A MyVillageOS mini-game built with Unity.
|
|
582
|
+
|
|
583
|
+
- **Slug:** \`${slug}\`
|
|
584
|
+
- **Game Type:** \`${gameType}\`
|
|
585
|
+
- **Platform:** ${platform || 'ios / android'}
|
|
586
|
+
|
|
587
|
+
## Setup
|
|
588
|
+
|
|
589
|
+
1. Open your Unity project (or create a new one)
|
|
590
|
+
2. Copy the \`Scripts/\` folder into your Unity project's \`Assets/\` directory
|
|
591
|
+
3. Create a GameObject and attach \`GameSessionReporter\` — set GameSlug to \`${slug}\`
|
|
592
|
+
4. Create a GameObject and attach \`MyVillageAuth\` (for player sign-in)
|
|
593
|
+
5. Build your game scenes
|
|
594
|
+
|
|
595
|
+
## MyVillageOS Integration
|
|
596
|
+
|
|
597
|
+
The generated scripts handle:
|
|
598
|
+
|
|
599
|
+
| Script | Purpose |
|
|
600
|
+
|--------|---------|
|
|
601
|
+
| \`EdPlatformConfig.cs\` | API URL construction (auto-detects platform) |
|
|
602
|
+
| \`GameSessionReporter.cs\` | Session start/complete + MVT rewards |
|
|
603
|
+
| \`MyVillageAuth.cs\` | OAuth 2.0 + PKCE sign-in via browser |
|
|
604
|
+
| \`PKCEHelper.cs\` | Crypto utilities for OAuth PKCE |
|
|
605
|
+
| \`MyVillageEvents.cs\` | Global events (OnSignedIn, OnSessionCompleted, OnMVTAwarded) |
|
|
606
|
+
| \`${className}Loader.cs\` | Downloads your AssetBundle from the ed-platform |
|
|
607
|
+
|
|
608
|
+
## Building AssetBundles
|
|
609
|
+
|
|
610
|
+
### For Mobile (iOS / Android)
|
|
611
|
+
|
|
612
|
+
1. In Unity: **Assets → Build AssetBundles** (or use your build script)
|
|
613
|
+
2. Output bundles to \`builds/ios/\` and/or \`builds/android/\`
|
|
614
|
+
3. Deploy: \`myvillage deploy --platform ios\`
|
|
615
|
+
|
|
616
|
+
### For Web (WebGL)
|
|
617
|
+
|
|
618
|
+
1. In Unity: **File → Build Settings → WebGL → Build**
|
|
619
|
+
2. Output to \`builds/webgl/\`
|
|
620
|
+
3. Deploy: \`myvillage deploy --platform webgl\`
|
|
621
|
+
|
|
622
|
+
## Reporting Scores
|
|
623
|
+
|
|
624
|
+
When the player finishes your game, call:
|
|
625
|
+
|
|
626
|
+
\`\`\`csharp
|
|
627
|
+
// From any script:
|
|
628
|
+
GameSessionReporter.Instance.CompleteSession(
|
|
629
|
+
score: 850,
|
|
630
|
+
correct: 8,
|
|
631
|
+
incorrect: 2,
|
|
632
|
+
difficulty: "medium"
|
|
633
|
+
);
|
|
634
|
+
\`\`\`
|
|
635
|
+
|
|
636
|
+
This reports the score to MyVillageOS and awards MVT tokens to the player.
|
|
637
|
+
|
|
638
|
+
## Listening to Events
|
|
639
|
+
|
|
640
|
+
\`\`\`csharp
|
|
641
|
+
void OnEnable()
|
|
642
|
+
{
|
|
643
|
+
MyVillageEvents.OnMVTAwarded += ShowRewardPopup;
|
|
644
|
+
MyVillageEvents.OnSessionCompleted += OnGameDone;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
void OnDisable()
|
|
648
|
+
{
|
|
649
|
+
MyVillageEvents.OnMVTAwarded -= ShowRewardPopup;
|
|
650
|
+
MyVillageEvents.OnSessionCompleted -= OnGameDone;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
void ShowRewardPopup(int amount) => Debug.Log($"Earned {amount} MVT!");
|
|
654
|
+
void OnGameDone(int score, int duration) => Debug.Log($"Score: {score} in {duration}s");
|
|
655
|
+
\`\`\`
|
|
656
|
+
|
|
657
|
+
## Deploy
|
|
658
|
+
|
|
659
|
+
\`\`\`bash
|
|
660
|
+
myvillage deploy --platform ios # Upload iOS bundles
|
|
661
|
+
myvillage deploy --platform android # Upload Android bundles
|
|
662
|
+
myvillage deploy --platform webgl # Upload WebGL build
|
|
663
|
+
myvillage status # Check review status
|
|
664
|
+
\`\`\`
|
|
665
|
+
|
|
666
|
+
After deploying, an admin will review your game, set MVT rewards, and publish it.
|
|
667
|
+
Published games appear automatically in the Izi Graham mobile app or M-UNI web platform.
|
|
668
|
+
`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── Three.js Templates ─────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
function generatePackageJson(name, description, slug, type, ageGroup) {
|
|
70
674
|
return JSON.stringify({
|
|
71
675
|
name: slug,
|
|
72
676
|
version: '1.0.0',
|
|
@@ -85,8 +689,10 @@ function generatePackageJson(name, description, slug, type) {
|
|
|
85
689
|
vite: '^5.0.0',
|
|
86
690
|
},
|
|
87
691
|
myvillage: {
|
|
692
|
+
name: name,
|
|
88
693
|
gameId: null,
|
|
89
694
|
gameType: type,
|
|
695
|
+
targetAge: ageGroup,
|
|
90
696
|
lastDeployed: null,
|
|
91
697
|
},
|
|
92
698
|
}, null, 2) + '\n';
|