@skillprint/cocos-sdk 0.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.
@@ -0,0 +1,484 @@
1
+ import { _decorator, Component, director, Camera, sys, Enum } from 'cc';
2
+ import { SkillprintConfig, ParameterDefinition, ParameterType, ParameterInfo, ParameterUpdateResult, Mood, ApiEnvironment } from './SkillprintTypes';
3
+ import { SkillprintAPIClient } from './SkillprintAPIClient';
4
+ import { ScreenshotUtility } from './ScreenshotUtility';
5
+
6
+ const { ccclass, property } = _decorator;
7
+
8
+ Enum(ApiEnvironment);
9
+
10
+ @ccclass('SkillprintManager')
11
+ export class SkillprintManager extends Component {
12
+ private static _instance: SkillprintManager | null = null;
13
+
14
+ public static get instance(): SkillprintManager {
15
+ if (!this._instance) {
16
+ // Log warning, developers should ensure a node has the manager component,
17
+ // or we will instantiate it programmatically
18
+ console.warn('[SkillprintSDK] SkillprintManager instance is requested but not initialized yet.');
19
+ }
20
+ return this._instance!;
21
+ }
22
+
23
+ private config: SkillprintConfig | null = null;
24
+ private currentSessionId: string | null = null;
25
+ private isSessionActive = false;
26
+ private apiClient: SkillprintAPIClient | null = null;
27
+ private screenshotUtility: ScreenshotUtility | null = null;
28
+
29
+ private screenshotQueue: (Blob | ArrayBuffer)[] = [];
30
+ private registeredParameters = new Map<string, ParameterDefinition>();
31
+
32
+ // References to the scheduler callbacks
33
+ private captureCallback: Function | null = null;
34
+ private postCallback: Function | null = null;
35
+ private pollCallback: Function | null = null;
36
+
37
+ onLoad() {
38
+ if (SkillprintManager._instance === null) {
39
+ SkillprintManager._instance = this;
40
+ director.addPersistRootNode(this.node); // Keep SDK persistent across scenes
41
+ } else if (SkillprintManager._instance !== this) {
42
+ this.log('Another instance of SkillprintManager already exists. Destroying this one.', 'Warning');
43
+ this.node.destroy();
44
+ }
45
+ }
46
+
47
+ public init(config: SkillprintConfig) {
48
+ this.config = config;
49
+
50
+ const activeApiKey = this.config.targetEnvironment === 'Production'
51
+ ? this.config.productionPartnerApiKey
52
+ : (this.config.stagingPartnerApiKey || this.config.productionPartnerApiKey);
53
+
54
+ const activeBaseUrl = this.config.targetEnvironment === 'Production'
55
+ ? (this.config.productionApiBaseUrl || 'https://api.skillprint.co')
56
+ : (this.config.stagingApiBaseUrl || 'https://api.staging.skillprint.co');
57
+
58
+ if (!activeApiKey) {
59
+ this.log(`Partner API Key for ${this.config.targetEnvironment} environment is not set in config. SDK will not function.`, 'Error');
60
+ this.enabled = false;
61
+ return;
62
+ }
63
+
64
+ this.apiClient = new SkillprintAPIClient(
65
+ activeBaseUrl,
66
+ activeApiKey,
67
+ (msg, lvl) => this.log(msg, lvl)
68
+ );
69
+
70
+ this.screenshotUtility = new ScreenshotUtility(
71
+ (msg, lvl) => this.log(msg, lvl)
72
+ );
73
+
74
+ // Pre-register parameters defined in config
75
+ if (this.config.gameParameters) {
76
+ this.config.gameParameters.forEach((param) => {
77
+ if (!this.registeredParameters.has(param.parameterName)) {
78
+ this.registeredParameters.set(param.parameterName, param);
79
+ } else {
80
+ this.log(`Duplicate parameter name found in config: ${param.parameterName}. Using first occurrence.`, 'Warning');
81
+ }
82
+ });
83
+ }
84
+
85
+ this.log(`Skillprint SDK Initialized for ${this.config.targetEnvironment} environment (URL: ${activeBaseUrl}).`);
86
+ }
87
+
88
+ public registerParameterModifier<T>(
89
+ parameterName: string,
90
+ updateAction: (value: T) => void,
91
+ description?: string,
92
+ howItWorks?: string
93
+ ) {
94
+ let paramDef = this.registeredParameters.get(parameterName);
95
+
96
+ if (!paramDef) {
97
+ // Auto-create definition if it doesn't exist yet
98
+ let inferredType = ParameterType.Float;
99
+ let defaultMin = 0;
100
+ let defaultMax = 1;
101
+
102
+ paramDef = {
103
+ parameterName: parameterName,
104
+ description: description || `Game parameter '${parameterName}' (auto-detected)`,
105
+ howSDKChangesIt: howItWorks || '',
106
+ type: inferredType,
107
+ minValue: defaultMin,
108
+ maxValue: defaultMax
109
+ };
110
+
111
+ this.registeredParameters.set(parameterName, paramDef);
112
+ this.log(`Parameter '${parameterName}' was not in config. Auto-created definition. Consider defining it in the config file.`, 'Warning');
113
+ }
114
+
115
+ paramDef.UpdateAction = (value: any) => {
116
+ updateAction(value as T);
117
+ };
118
+
119
+ this.log(`Parameter '${parameterName}' modifier registered successfully.`);
120
+ }
121
+
122
+ public async startGameSession(targetMood: string, customPlayerId: string | null = null) {
123
+ if (this.isSessionActive) {
124
+ this.log('Session already active. Call stopGameSession first.', 'Warning');
125
+ return;
126
+ }
127
+
128
+ if (!this.config || !this.apiClient) {
129
+ this.log('SDK not configured properly. Cannot start session.', 'Error');
130
+ return;
131
+ }
132
+
133
+ // Validate mood parameter
134
+ const validMoods = ['relax', 'focus', 'creativity', 'collaborate', 'grit', 'joy', 'curiosity', 'empathy', 'awe'];
135
+ if (validMoods.indexOf(targetMood) === -1) {
136
+ this.log(`Invalid targetMood: '${targetMood}'. Valid moods are: ${validMoods.join(', ')}`, 'Error');
137
+ return;
138
+ }
139
+
140
+ this.currentSessionId = this.generateUUID();
141
+ this.isSessionActive = true;
142
+ this.log(`Starting game session for ${this.config.targetEnvironment} environment.`);
143
+
144
+ // Map registered parameters to API format
145
+ const parameterInfos: ParameterInfo[] = Array.from(this.registeredParameters.values()).map((p) => {
146
+ return {
147
+ name: p.parameterName,
148
+ type: p.type.toString(),
149
+ description: p.description || '',
150
+ adjustmentGuide: p.howSDKChangesIt || null,
151
+ minValue: (p.type === ParameterType.Float || p.type === ParameterType.Integer) && p.minValue !== undefined ? p.minValue.toString() : null,
152
+ maxValue: (p.type === ParameterType.Float || p.type === ParameterType.Integer) && p.maxValue !== undefined ? p.maxValue.toString() : null
153
+ };
154
+ });
155
+
156
+ this.log(`Sending ${parameterInfos.length} game parameter(s) to API: ` + parameterInfos.map(p => p.name).join(', '), 'Warning');
157
+
158
+ const success = await this.apiClient.startSession(
159
+ this.currentSessionId,
160
+ targetMood,
161
+ customPlayerId,
162
+ this.config.gameName,
163
+ parameterInfos
164
+ );
165
+
166
+ if (success) {
167
+ this.log(`Skillprint session started: ${this.currentSessionId}`);
168
+ this.startLoops();
169
+ } else {
170
+ this.log('Failed to start Skillprint session', 'Error');
171
+ this.isSessionActive = false;
172
+ this.currentSessionId = null;
173
+ }
174
+ }
175
+
176
+ public async stopGameSession() {
177
+ if (!this.isSessionActive || !this.currentSessionId || !this.apiClient) {
178
+ this.log('No active session to stop.', 'Warning');
179
+ return;
180
+ }
181
+
182
+ const closingSessionId = this.currentSessionId;
183
+ this.log(`Stopping Skillprint session: ${closingSessionId}`);
184
+ this.isSessionActive = false;
185
+
186
+ this.stopLoops();
187
+
188
+ // Copy remaining screenshots and upload with isLastChunk = true
189
+ const finalBatch = [...this.screenshotQueue];
190
+ this.screenshotQueue = [];
191
+
192
+ try {
193
+ await this.apiClient.postScreenshots(closingSessionId, finalBatch, true);
194
+ this.log('Session close signal sent successfully.');
195
+ } catch (err: any) {
196
+ this.log(`Failed to send session close signal: ${err.message || err}`, 'Error');
197
+ }
198
+
199
+ this.currentSessionId = null;
200
+ this.log('Skillprint session stopped.');
201
+ }
202
+
203
+ private startLoops() {
204
+ const intervalCapture = this.config?.screenshotIntervalSeconds || 2.0;
205
+ const intervalPost = this.config?.screenshotPostIntervalSeconds || 5.0;
206
+ const intervalPoll = this.config?.pollResultsIntervalSeconds || 5.0;
207
+
208
+ // Capture loop
209
+ this.captureCallback = async () => {
210
+ if (!this.isSessionActive || !this.screenshotUtility) return;
211
+ const camera = this.findActiveCamera();
212
+ if (!camera) {
213
+ this.log('Active camera not found in scene for screenshot capture', 'Warning');
214
+ return;
215
+ }
216
+
217
+ const imgData = await this.screenshotUtility.captureScreenshot(
218
+ camera,
219
+ this.config?.screenshotMaxWidth || 960,
220
+ this.config?.screenshotJpegQuality || 60
221
+ );
222
+
223
+ if (imgData) {
224
+ if (this.screenshotQueue.length < 50) {
225
+ this.screenshotQueue.push(imgData);
226
+ this.log(`Screenshot captured. Queue size: ${this.screenshotQueue.length}`);
227
+ } else {
228
+ this.log('Screenshot queue full. Discarding new screenshot.', 'Warning');
229
+ }
230
+ }
231
+ };
232
+ this.schedule(this.captureCallback, intervalCapture);
233
+
234
+ // Post loop
235
+ this.postCallback = async () => {
236
+ if (!this.isSessionActive || !this.apiClient || this.screenshotQueue.length === 0) return;
237
+
238
+ // Determine batch size (based on cadence ratio)
239
+ const batchSize = Math.max(1, Math.ceil(intervalPost / intervalCapture));
240
+ const batchToPost = this.screenshotQueue.splice(0, batchSize * 2);
241
+
242
+ this.log(`Posting ${batchToPost.length} screenshots...`);
243
+ const ok = await this.apiClient.postScreenshots(this.currentSessionId!, batchToPost, false);
244
+ if (ok) {
245
+ this.log(`Successfully posted ${batchToPost.length} screenshots.`);
246
+ } else {
247
+ this.log('Failed to post screenshots batch.', 'Error');
248
+ }
249
+ };
250
+ this.schedule(this.postCallback, intervalPost);
251
+
252
+ // Poll loop
253
+ this.pollCallback = async () => {
254
+ if (!this.isSessionActive || !this.apiClient) return;
255
+
256
+ const updates = await this.apiClient.pollParameterResults(this.currentSessionId!);
257
+ if (updates && updates.length > 0) {
258
+ this.log(`Received ${updates.length} parameter updates from API.`);
259
+ this.applyParameterUpdates(updates);
260
+ }
261
+ };
262
+ this.schedule(this.pollCallback, intervalPoll);
263
+ }
264
+
265
+ private stopLoops() {
266
+ if (this.captureCallback) {
267
+ this.unschedule(this.captureCallback);
268
+ this.captureCallback = null;
269
+ }
270
+ if (this.postCallback) {
271
+ this.unschedule(this.postCallback);
272
+ this.postCallback = null;
273
+ }
274
+ if (this.pollCallback) {
275
+ this.unschedule(this.pollCallback);
276
+ this.pollCallback = null;
277
+ }
278
+ }
279
+
280
+ private applyParameterUpdates(updates: ParameterUpdateResult[]) {
281
+ if (!this.apiClient) return;
282
+
283
+ updates.forEach((update) => {
284
+ const rawValue = this.apiClient!.getParsedValue(update);
285
+ this.log(`[DEBUG] Received update - Name: ${update.parameterName}, RawValue: '${rawValue}'`);
286
+
287
+ if (rawValue === null || rawValue === undefined) {
288
+ this.log(`Parameter '${update.parameterName}' received null/undefined value from API. Skipping.`, 'Warning');
289
+ return;
290
+ }
291
+
292
+ const paramDef = this.registeredParameters.get(update.parameterName);
293
+ if (!paramDef) {
294
+ this.log(`Received update for unknown parameter: ${update.parameterName}. Skipping.`, 'Warning');
295
+ return;
296
+ }
297
+
298
+ if (!paramDef.UpdateAction) {
299
+ this.log(`Parameter '${update.parameterName}' received from API but has no registered modifier callback. Skipping.`, 'Warning');
300
+ return;
301
+ }
302
+
303
+ const convertedValue = this.convertValue(rawValue, paramDef);
304
+ if (convertedValue !== null && this.isValidValue(convertedValue, paramDef)) {
305
+ try {
306
+ this.log(`Applying update: ${paramDef.parameterName} = ${convertedValue} (Type: ${paramDef.type})`);
307
+ paramDef.UpdateAction(convertedValue);
308
+ } catch (e: any) {
309
+ this.log(`Error applying update for ${paramDef.parameterName}: ${e.message || e}`, 'Error');
310
+ }
311
+ } else {
312
+ this.log(`Invalid value or type conversion failed for parameter ${paramDef.parameterName}: '${rawValue}'`, 'Warning');
313
+ }
314
+ });
315
+ }
316
+
317
+ private convertValue(rawValue: any, paramDef: ParameterDefinition): any {
318
+ try {
319
+ switch (paramDef.type) {
320
+ case ParameterType.Float:
321
+ const fVal = parseFloat(rawValue);
322
+ return isNaN(fVal) ? null : fVal;
323
+ case ParameterType.Integer:
324
+ const iVal = parseInt(rawValue, 10);
325
+ return isNaN(iVal) ? null : iVal;
326
+ case ParameterType.Boolean:
327
+ if (typeof rawValue === 'boolean') return rawValue;
328
+ if (typeof rawValue === 'string') {
329
+ return rawValue.toLowerCase() === 'true' || rawValue === '1';
330
+ }
331
+ if (typeof rawValue === 'number') {
332
+ return rawValue !== 0;
333
+ }
334
+ return null;
335
+ default:
336
+ return rawValue;
337
+ }
338
+ } catch (err: any) {
339
+ this.log(`Conversion failed: ${err.message || err}`, 'Error');
340
+ return null;
341
+ }
342
+ }
343
+
344
+ private isValidValue(value: any, paramDef: ParameterDefinition): boolean {
345
+ switch (paramDef.type) {
346
+ case ParameterType.Float:
347
+ case ParameterType.Integer:
348
+ const num = value as number;
349
+ if (paramDef.minValue !== undefined && num < paramDef.minValue) return false;
350
+ if (paramDef.maxValue !== undefined && num > paramDef.maxValue) return false;
351
+ return true;
352
+ case ParameterType.Boolean:
353
+ return typeof value === 'boolean';
354
+ }
355
+ return false;
356
+ }
357
+
358
+ private findActiveCamera(): Camera | null {
359
+ // Query scene for active camera components
360
+ const rootNodes = director.getScene()?.children;
361
+ if (!rootNodes) return null;
362
+
363
+ for (let i = 0; i < rootNodes.length; i++) {
364
+ const camera = rootNodes[i].getComponentInChildren(Camera);
365
+ if (camera && camera.node.active && camera.enabled) {
366
+ return camera;
367
+ }
368
+ }
369
+ return null;
370
+ }
371
+
372
+ // Web URL parameters methods
373
+
374
+ public startGameSessionFromUrl(fallbackMood = 'relax', fallbackPlayerId: string | null = null) {
375
+ this.startGameSessionWithOverrides(fallbackMood, fallbackPlayerId);
376
+ }
377
+
378
+ public startGameSessionWithOverrides(
379
+ fallbackMood = 'relax',
380
+ fallbackPlayerId: string | null = null,
381
+ overrideMood: string | null = null,
382
+ overridePlayerId: string | null = null
383
+ ) {
384
+ let targetMood = overrideMood;
385
+ let playerId = overridePlayerId;
386
+
387
+ // If not forced, try browser query string
388
+ if (!targetMood || !playerId) {
389
+ const urlParams = this.getSkillprintUrlParameters();
390
+
391
+ if (!targetMood) {
392
+ targetMood = urlParams.targetMood || fallbackMood;
393
+ }
394
+ if (!playerId) {
395
+ playerId = urlParams.playerId || fallbackPlayerId;
396
+ }
397
+ }
398
+
399
+ // Validate target mood
400
+ if (!targetMood) {
401
+ this.log("No target mood specified and no fallback provided. Using 'focus' as default.", 'Warning');
402
+ targetMood = 'focus';
403
+ }
404
+
405
+ const validMoods = ['relax', 'focus', 'creativity', 'collaborate', 'grit', 'joy', 'curiosity', 'empathy', 'awe'];
406
+ if (validMoods.indexOf(targetMood) === -1) {
407
+ this.log(`Invalid mood '${targetMood}'. Valid moods: ${validMoods.join(', ')}. Using 'focus' as fallback.`, 'Warning');
408
+ targetMood = 'focus';
409
+ }
410
+
411
+ this.log(`Starting Skillprint session with Mood: '${targetMood}', Player ID: '${playerId || 'none'}'`);
412
+ this.startGameSession(targetMood, playerId);
413
+ }
414
+
415
+ public getUrlParametersInfo(): string {
416
+ if (!sys.isBrowser) {
417
+ return 'URL parameters not supported on this platform (not Browser)';
418
+ }
419
+
420
+ const urlParams = this.getSkillprintUrlParameters();
421
+ return `Current URL: ${window.location.href}\nMood Parameter: '${urlParams.targetMood || 'not found'}'\nPlayer ID Parameter: '${urlParams.playerId || 'not found'}'`;
422
+ }
423
+
424
+ private getSkillprintUrlParameters(): { targetMood: string | null, playerId: string | null } {
425
+ if (!sys.isBrowser) {
426
+ return { targetMood: null, playerId: null };
427
+ }
428
+
429
+ const searchParams = new URLSearchParams(window.location.search);
430
+ const mood = searchParams.get('mood') || searchParams.get('targetMood');
431
+ const playerId = searchParams.get('playerId') || searchParams.get('player_id') || searchParams.get('userId');
432
+
433
+ // Store persistent storage updates
434
+ if (mood) sys.localStorage.setItem('SkillprintMood', mood);
435
+ if (playerId) sys.localStorage.setItem('SkillprintPlayerId', playerId);
436
+
437
+ return {
438
+ targetMood: mood || sys.localStorage.getItem('SkillprintMood'),
439
+ playerId: playerId || sys.localStorage.getItem('SkillprintPlayerId')
440
+ };
441
+ }
442
+
443
+ // UUID Generator helper
444
+ private generateUUID(): string {
445
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
446
+ const r = Math.random() * 16 | 0;
447
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
448
+ return v.toString(16);
449
+ });
450
+ }
451
+
452
+ // Centralized Logging
453
+ public log(msg: string, level: 'Info' | 'Warning' | 'Error' = 'Info') {
454
+ if (this.config && !this.config.enableDebugLogging && level === 'Info') {
455
+ return;
456
+ }
457
+
458
+ const formatted = `[SkillprintSDK] ${msg}`;
459
+ switch (level) {
460
+ case 'Info':
461
+ console.log(formatted);
462
+ break;
463
+ case 'Warning':
464
+ console.warn(formatted);
465
+ break;
466
+ case 'Error':
467
+ console.error(formatted);
468
+ break;
469
+ }
470
+ }
471
+
472
+ public getConfig(): SkillprintConfig | null {
473
+ return this.config;
474
+ }
475
+
476
+ onDestroy() {
477
+ if (this.isSessionActive) {
478
+ this.stopGameSession();
479
+ }
480
+ if (SkillprintManager._instance === this) {
481
+ SkillprintManager._instance = null;
482
+ }
483
+ }
484
+ }
@@ -0,0 +1,78 @@
1
+ export enum ApiEnvironment {
2
+ Production = 'Production',
3
+ Staging = 'Staging',
4
+ }
5
+
6
+ export enum ParameterType {
7
+ Float = 'Float',
8
+ Integer = 'Integer',
9
+ Boolean = 'Boolean',
10
+ }
11
+
12
+ export enum Mood {
13
+ relax = 'relax',
14
+ focus = 'focus',
15
+ creativity = 'creativity',
16
+ collaborate = 'collaborate',
17
+ grit = 'grit',
18
+ joy = 'joy',
19
+ curiosity = 'curiosity',
20
+ empathy = 'empathy',
21
+ awe = 'awe',
22
+ }
23
+
24
+ export interface ParameterDefinition {
25
+ parameterName: string;
26
+ description?: string;
27
+ howSDKChangesIt?: string; // Maps to adjustmentGuide in API
28
+ type: ParameterType;
29
+ minValue?: number;
30
+ maxValue?: number;
31
+ defaultValue?: string;
32
+ UpdateAction?: (value: any) => void;
33
+ }
34
+
35
+ export interface SkillprintConfig {
36
+ gameName: string; // Slug, e.g. 'fruit-boom'
37
+ targetEnvironment: ApiEnvironment;
38
+ productionPartnerApiKey: string;
39
+ productionApiBaseUrl?: string;
40
+ stagingPartnerApiKey?: string;
41
+ stagingApiBaseUrl?: string;
42
+ screenshotMaxWidth?: number;
43
+ screenshotJpegQuality?: number;
44
+ screenshotIntervalSeconds?: number;
45
+ screenshotPostIntervalSeconds?: number;
46
+ pollResultsIntervalSeconds?: number;
47
+ enableDebugLogging?: boolean;
48
+ gameParameters?: ParameterDefinition[];
49
+ }
50
+
51
+ export interface ParameterInfo {
52
+ name: string;
53
+ type: string;
54
+ description: string;
55
+ adjustmentGuide: string | null;
56
+ minValue: string | null;
57
+ maxValue: string | null;
58
+ }
59
+
60
+ export interface StartSessionRequest {
61
+ sessionId: string;
62
+ game: string;
63
+ targetMood: string;
64
+ gameParameters: ParameterInfo[];
65
+ }
66
+
67
+ export interface ParameterUpdateResult {
68
+ parameterName: string;
69
+ newValue: any;
70
+ // Handled dynamically if custom fields are returned in place of newValue
71
+ [key: string]: any;
72
+ }
73
+
74
+ export interface PollResultsResponse {
75
+ gameplayTips?: string;
76
+ state?: string;
77
+ parameterUpdates?: ParameterUpdateResult[];
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './SkillprintTypes';
2
+ export * from './SkillprintAPIClient';
3
+ export * from './ScreenshotUtility';
4
+ export * from './SkillprintManager';
5
+ export * from './SkillprintConfigComponent';
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2017",
4
+ "module": "es2020",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "strict": false,
8
+ "skipLibCheck": true,
9
+ "moduleResolution": "node",
10
+ "esModuleInterop": true,
11
+ "experimentalDecorators": true
12
+ },
13
+ "include": [
14
+ "src/**/*",
15
+ "cc.d.ts"
16
+ ]
17
+ }