357 lines
12 KiB
JavaScript
357 lines
12 KiB
JavaScript
/**
|
|
* SenaGame SDK Loader
|
|
*
|
|
* Ready-to-use interface for game developers.
|
|
* Handles SDK iframe creation, communication, and lifecycle.
|
|
*
|
|
* Usage:
|
|
* ```html
|
|
* <script src="sena-game-sdk.js"></script>
|
|
* <script>
|
|
* const game = new SenaGameSDK({
|
|
* iframePath: '/path/to/sdk-iframe/index.html',
|
|
* mode: 'live',
|
|
* gameCode: 'G001',
|
|
* onReady: (sdk) => {
|
|
* sdk.pushData({ items: [...] });
|
|
* },
|
|
* onAnswerResult: (result) => {
|
|
* console.log('Answer:', result);
|
|
* }
|
|
* });
|
|
* </script>
|
|
* ```
|
|
*
|
|
* @version 1.0.0
|
|
*/
|
|
// ========================================
|
|
// MESSAGE TYPES (matching SDK)
|
|
// ========================================
|
|
const MSG = {
|
|
// Outgoing (Game → SDK)
|
|
INIT: 'SDK_INIT',
|
|
PUSH_DATA: 'SDK_PUSH_DATA',
|
|
SUBMIT_ANSWER: 'SDK_CHECK_ANSWER',
|
|
COMPLETE_GAME: 'SDK_COMPLETE_GAME',
|
|
GET_STATUS: 'SDK_GET_STATUS',
|
|
// Incoming (SDK → Game)
|
|
READY: 'SDK_READY',
|
|
DATA_READY: 'SDK_DATA_READY',
|
|
ANSWER_RESULT: 'SDK_ANSWER_RESULT',
|
|
GAME_COMPLETE: 'SDK_GAME_COMPLETE',
|
|
SESSION_STARTED: 'SDK_SESSION_STARTED',
|
|
STATUS: 'SDK_STATUS',
|
|
ERROR: 'SDK_ERROR',
|
|
};
|
|
// ========================================
|
|
// DEFAULT CONFIG
|
|
// ========================================
|
|
const DEFAULT_CONFIG = {
|
|
iframePath: '../sdk-iframe/index.html',
|
|
mode: 'preview',
|
|
gameCode: '',
|
|
debug: false,
|
|
timeout: 10000,
|
|
iframeStyle: 'position:fixed;width:1px;height:1px;left:-9999px;border:none;',
|
|
};
|
|
// ========================================
|
|
// SENA GAME SDK CLASS
|
|
// ========================================
|
|
export class SenaGameSDK {
|
|
constructor(config) {
|
|
this.iframe = null;
|
|
this.isReady = false;
|
|
this.isDataReady = false;
|
|
this.pendingMessages = [];
|
|
this.initResolver = null;
|
|
this.timeoutId = null;
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
// --- CLEANUP OLD LISTENERS ---
|
|
// Tránh trường hợp init SDK nhiều lần bị trùng listener cũ
|
|
const oldSDK = window._sena_game_sdk_instance;
|
|
if (oldSDK && typeof oldSDK.destroy === 'function') {
|
|
oldSDK.destroy();
|
|
}
|
|
window._sena_game_sdk_instance = this;
|
|
// Create promise for ready state
|
|
this.initPromise = new Promise((resolve, reject) => {
|
|
this.initResolver = { resolve, reject };
|
|
});
|
|
// Bind methods
|
|
this._handleMessage = this._handleMessage.bind(this);
|
|
// Auto-initialize
|
|
this._init();
|
|
}
|
|
// ----------------------------------------
|
|
// PUBLIC API
|
|
// ----------------------------------------
|
|
/**
|
|
* Push game data to SDK
|
|
*/
|
|
pushData(data) {
|
|
// Safe extraction of the array
|
|
let itemsArray = [];
|
|
if (Array.isArray(data)) {
|
|
itemsArray = data;
|
|
}
|
|
else if (data && Array.isArray(data.items)) {
|
|
itemsArray = data.items;
|
|
}
|
|
else if (data && Array.isArray(data.data)) {
|
|
itemsArray = data.data;
|
|
}
|
|
else if (data && data.items && typeof data.items === 'object') {
|
|
itemsArray = [data.items];
|
|
}
|
|
else if (data && data.data && typeof data.data === 'object') {
|
|
itemsArray = [data.data];
|
|
}
|
|
// Transform to SDK iframe internal format
|
|
const payload = {
|
|
data: itemsArray,
|
|
completed_question_ids: (data && data.completed_question_ids) || []
|
|
};
|
|
this._send(MSG.PUSH_DATA, payload);
|
|
}
|
|
/**
|
|
* Submit an answer
|
|
*/
|
|
submitAnswer(answer) {
|
|
// Transform to SDK iframe format
|
|
const payload = {
|
|
question_id: answer.questionId,
|
|
choice: answer.selectedAnswer,
|
|
time_spent: answer.timeSpent ?? 0
|
|
};
|
|
this._send(MSG.SUBMIT_ANSWER, payload);
|
|
}
|
|
/**
|
|
* Complete the game
|
|
*/
|
|
completeGame() {
|
|
this._send(MSG.COMPLETE_GAME, {});
|
|
}
|
|
/**
|
|
* Get current status
|
|
*/
|
|
getStatus() {
|
|
this._send(MSG.GET_STATUS, {});
|
|
}
|
|
/**
|
|
* Wait for SDK to be ready
|
|
*/
|
|
async ready() {
|
|
if (this.isReady)
|
|
return this;
|
|
return this.initPromise;
|
|
}
|
|
/**
|
|
* Check if SDK is ready
|
|
*/
|
|
get sdkReady() {
|
|
return this.isReady;
|
|
}
|
|
/**
|
|
* Check if data is ready
|
|
*/
|
|
get dataReady() {
|
|
return this.isDataReady;
|
|
}
|
|
/**
|
|
* Destroy the SDK instance
|
|
*/
|
|
destroy() {
|
|
window.removeEventListener('message', this._handleMessage);
|
|
if (this.timeoutId) {
|
|
clearTimeout(this.timeoutId);
|
|
}
|
|
if (this.iframe && this.iframe.parentNode) {
|
|
this.iframe.parentNode.removeChild(this.iframe);
|
|
}
|
|
window.removeEventListener('message', this._handleMessage);
|
|
this.isReady = false;
|
|
this._log('SDK destroyed');
|
|
}
|
|
// ----------------------------------------
|
|
// PRIVATE METHODS
|
|
// ----------------------------------------
|
|
_init() {
|
|
this._log('Initializing SenaGameSDK...');
|
|
// Setup message listener
|
|
window.addEventListener('message', this._handleMessage);
|
|
// Create iframe
|
|
this._createIframe();
|
|
// Setup timeout
|
|
this.timeoutId = setTimeout(() => {
|
|
if (!this.isReady) {
|
|
const error = new Error('SDK initialization timeout');
|
|
this._error(error);
|
|
if (this.initResolver) {
|
|
this.initResolver.reject(error);
|
|
}
|
|
}
|
|
}, this.config.timeout);
|
|
}
|
|
_createIframe() {
|
|
this.iframe = document.createElement('iframe');
|
|
this.iframe.id = 'sena-game-sdk-iframe';
|
|
this.iframe.src = this.config.iframePath;
|
|
this.iframe.style.cssText = this.config.iframeStyle || '';
|
|
this.iframe.onload = () => {
|
|
this._log('Iframe loaded, sending INIT...');
|
|
setTimeout(() => {
|
|
this._send(MSG.INIT, {
|
|
mode: this.config.mode,
|
|
game_code: this.config.gameCode,
|
|
});
|
|
}, 100);
|
|
};
|
|
this.iframe.onerror = () => {
|
|
this._error(new Error('Failed to load SDK iframe'));
|
|
};
|
|
document.body.appendChild(this.iframe);
|
|
this._log(`Iframe created: ${this.config.iframePath}`);
|
|
}
|
|
_send(type, payload) {
|
|
if (!this.iframe || !this.iframe.contentWindow) {
|
|
this._log(`Queuing message: ${type}`, 'warn');
|
|
this.pendingMessages.push({ type, payload });
|
|
return;
|
|
}
|
|
const message = { type, payload, timestamp: Date.now() };
|
|
this._log(`→ ${type}`, 'send');
|
|
this.iframe.contentWindow.postMessage(message, '*');
|
|
}
|
|
_handleMessage(event) {
|
|
const data = event.data;
|
|
if (!data || !data.type)
|
|
return;
|
|
// Only process SDK messages
|
|
if (!data.type.startsWith('SDK_'))
|
|
return;
|
|
this._log(`← ${data.type}`, 'recv');
|
|
switch (data.type) {
|
|
case MSG.READY:
|
|
this._onSDKReady();
|
|
break;
|
|
case MSG.DATA_READY:
|
|
this._onDataReady(data.payload);
|
|
break;
|
|
case MSG.ANSWER_RESULT:
|
|
this._onAnswerResult(data.payload);
|
|
break;
|
|
case MSG.GAME_COMPLETE:
|
|
this._onGameComplete(data.payload);
|
|
break;
|
|
case MSG.SESSION_STARTED:
|
|
this._onSessionStart(data.payload);
|
|
break;
|
|
case MSG.ERROR:
|
|
this._error(new Error(data.payload?.message || 'SDK Error'));
|
|
break;
|
|
}
|
|
}
|
|
_onSDKReady() {
|
|
this.isReady = true;
|
|
if (this.timeoutId) {
|
|
clearTimeout(this.timeoutId);
|
|
this.timeoutId = null;
|
|
}
|
|
// Flush pending messages
|
|
while (this.pendingMessages.length > 0) {
|
|
const msg = this.pendingMessages.shift();
|
|
if (msg) {
|
|
this._send(msg.type, msg.payload);
|
|
}
|
|
}
|
|
// Callback
|
|
if (this.config.onReady) {
|
|
this.config.onReady(this);
|
|
}
|
|
// Resolve promise
|
|
if (this.initResolver) {
|
|
this.initResolver.resolve(this);
|
|
}
|
|
this._log('✅ SDK Ready!');
|
|
}
|
|
_onDataReady(payload) {
|
|
this.isDataReady = true;
|
|
// Normalize payload to camelCase
|
|
const normalized = {
|
|
items: payload.items || [],
|
|
totalQuestions: payload.total_questions || payload.totalQuestions || 0,
|
|
completedCount: payload.completed_count || payload.completedCount || 0,
|
|
resumeData: payload.resume_data || payload.resumeData || []
|
|
};
|
|
if (this.config.onDataReady) {
|
|
this.config.onDataReady(normalized);
|
|
}
|
|
this._log(`✅ Data Ready: ${normalized.items.length} items`);
|
|
}
|
|
_onAnswerResult(payload) {
|
|
const normalized = {
|
|
questionId: payload.question_id || payload.questionId,
|
|
isCorrect: payload.correct !== undefined ? payload.correct : payload.isCorrect,
|
|
correctAnswer: payload.correct_answer || payload.correctAnswer || '',
|
|
score: payload.score || 0,
|
|
currentScore: payload.current_score || payload.currentScore || 0,
|
|
totalAnswered: payload.total_answered || payload.totalAnswered || 0
|
|
};
|
|
if (this.config.onAnswerResult) {
|
|
this.config.onAnswerResult(normalized);
|
|
}
|
|
}
|
|
_onGameComplete(payload) {
|
|
const normalized = {
|
|
success: payload.success !== undefined ? payload.success : true,
|
|
finalScore: payload.score !== undefined ? payload.score : (payload.finalScore || 0),
|
|
correctCount: payload.correct !== undefined ? payload.correct : (payload.correctCount || 0),
|
|
totalQuestions: payload.total !== undefined ? payload.total : (payload.totalQuestions || 0),
|
|
wrongCount: payload.wrong !== undefined ? payload.wrong : (payload.wrongCount || 0),
|
|
total: payload.total || 0
|
|
};
|
|
if (this.config.onGameComplete) {
|
|
this.config.onGameComplete(normalized);
|
|
}
|
|
}
|
|
_onSessionStart(payload) {
|
|
const normalized = {
|
|
assignmentId: payload.assignment_id || payload.assignmentId,
|
|
userId: payload.student_id || payload.userId,
|
|
gameId: payload.game_code || payload.gameId,
|
|
startedAt: payload.started_at || payload.startedAt || new Date().toISOString()
|
|
};
|
|
if (this.config.onSessionStart) {
|
|
this.config.onSessionStart(normalized);
|
|
}
|
|
}
|
|
_error(error) {
|
|
this._log(`❌ Error: ${error.message}`, 'error');
|
|
if (this.config.onError) {
|
|
this.config.onError(error);
|
|
}
|
|
}
|
|
_log(message, type = 'info') {
|
|
if (!this.config.debug)
|
|
return;
|
|
const prefix = '[SenaGameSDK]';
|
|
const styles = {
|
|
info: 'color: #888',
|
|
send: 'color: #ff0',
|
|
recv: 'color: #0f0',
|
|
warn: 'color: #fa0',
|
|
error: 'color: #f00',
|
|
};
|
|
console.log(`%c${prefix} ${message}`, styles[type] || styles.info);
|
|
}
|
|
}
|
|
/** SDK version */
|
|
SenaGameSDK.VERSION = '1.0.0';
|
|
// ========================================
|
|
// EXPORT FOR BROWSER (UMD)
|
|
// ========================================
|
|
if (typeof window !== 'undefined') {
|
|
window.SenaGameSDK = SenaGameSDK;
|
|
}
|
|
export default SenaGameSDK;
|
|
//# sourceMappingURL=SenaGameSDK.js.map
|