/** * Sena SDK Constructor * @param {Object} config - Configuration object for the SDK * @param {Object} config.data - Quiz data containing question, options, and answer */ function SenaSDK(gid = 'G2510S1T30') { // Initialize data this.data = null; this.correctAnswer = null; this.gameCode = gid; // Initialize properties this.timeLimit = 0; this.shuffle = true; // tracking time in game this.startTime = 0; this.endTime = 0; // Initialize Web Speech API this.speechSynthesis = window.speechSynthesis; this.currentUtterance = null; this.voiceSettings = { lang: 'en-US', rate: 1.0, // Speed (0.1 to 10) pitch: 1.0, // Pitch (0 to 2) volume: 1.0 // Volume (0 to 1) }; } /** * Shuffle array using Fisher-Yates algorithm * @param {Array} array - Array to shuffle */ SenaSDK.prototype.shuffleArray = function(array) { for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } }; SenaSDK.prototype.load = function(callback,template = 'G2510S1T30') { let self = this; // get parameter LID from URL const urlParams = new URLSearchParams(window.location.search); const LID = urlParams.get('LID'); if (LID) { self.gameCode = LID; }; fetch(`https://senaai.tech/sample/${self.gameCode}.json`) .then(response => response.json()) .then(data => { self.data = data.data; self.correctAnswer = data.answer || null; // based on game code, set timeLimit and shuffle const gameCode = self.gameCode || template; const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (match) { const shuffleFlag = match[5] !== undefined ? match[5] : '1'; const timeStr = match[6] !== undefined ? match[6] : '0'; self.shuffle = (shuffleFlag === '1'); self.timeLimit = parseInt(timeStr, 10); } if (callback) callback(true); }) .catch(error => { console.error('Error loading LID data:', error); if (callback) callback(false); }); }; /** * Generate comprehensive developer guide based on game code * @returns {string} Developer guide with implementation instructions */ SenaSDK.prototype.guide = function() { let self = this; const gameCode = self.gameCode || 'G2510S1T30'; const data = self.data || {}; /** * Regex giải thích: * ^G([1-5])([2-9])([0-2])([0-2]) : Bắt buộc (Loại, Số lượng, Q, O) * (?:S([0-1]))? : Không bắt buộc, mặc định S1 * (?:T(\d+))? : Không bắt buộc, mặc định T0 */ const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (!match) return "Mã game không hợp lệ! Định dạng chuẩn: Gxxxx hoặc GxxxxSxTxx"; const category = match[1]; const count = match[2]; const qIdx = match[3]; const oIdx = match[4]; const shuffle = match[5] !== undefined ? match[5] : '1'; const time = match[6] !== undefined ? match[6] : '0'; const types = { '0': 'Text', '1': 'Image', '2': 'Audio' }; let guide = ''; // Header guide += `╔════════════════════════════════════════════════════════════════════════════╗\n`; guide += `║ SENA SDK - DEVELOPER GUIDE: ${gameCode.padEnd(37)}║\n`; guide += `╚════════════════════════════════════════════════════════════════════════════╝\n\n`; // Game Analysis guide += `📊 GAME ANALYSIS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`; let gameName = '', instruction = '', displayMode = ''; switch(category) { case '1': gameName = "Quiz (Trắc nghiệm)"; instruction = `Người dùng chọn 1 đáp án đúng trong ${count} options`; displayMode = `Question: ${types[qIdx]} → Options: ${types[oIdx]}`; break; case '2': if (qIdx === '0' && oIdx === '0') { gameName = "Sort Word (Sắp xếp từ)"; instruction = `Sắp xếp ${count} từ/ký tự thành chuỗi hoàn chỉnh`; } else { gameName = "Sequences (Sắp xếp chuỗi)"; instruction = `Sắp xếp ${count} items theo đúng thứ tự`; } displayMode = `Hint: ${types[qIdx]} → Items: ${types[oIdx]}`; break; case '3': if (qIdx === oIdx) { gameName = "Memory Card (Trí nhớ)"; instruction = `Lật và ghép ${count} cặp thẻ giống nhau`; } else { gameName = "Matching (Nối cặp)"; instruction = `Nối ${count} items từ 2 nhóm với nhau`; } displayMode = `Group A: ${types[qIdx]} ↔ Group B: ${types[oIdx]}`; break; } guide += `Game Type : ${gameName}\n`; guide += `Objective : ${instruction}\n`; guide += `Display Mode : ${displayMode}\n`; guide += `Items Count : ${count}\n`; guide += `Shuffle : ${shuffle === '1' ? 'YES (call sdk.start() to shuffle)' : 'NO (S0)'}\n`; guide += `Time Limit : ${time === '0' ? 'Unlimited' : time + ' seconds'}\n`; if (data.hint && data.hint.type) { guide += `Hint Type : ${data.hint.type}\n`; guide += `Hint Count : ${Array.isArray(data.hint.value) ? data.hint.value.length : '1'}\n`; } guide += `\n🔧 IMPLEMENTATION STEPS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n`; // Step 1: Initialize guide += `1️⃣ INITIALIZE SDK\n`; guide += ` var sdk = new SenaSDK('${gameCode}');\n`; guide += ` sdk.load(function(success) {\n`; guide += ` if (success) sdk.start();\n`; guide += ` });\n\n`; // Step 2: Display based on game type guide += `2️⃣ DISPLAY UI\n`; if (category === '1') { // Quiz guide += ` // Display Question\n`; if (qIdx === '0') { guide += ` var questionText = sdk.getQuestionValue();\n`; guide += ` displayText(questionText); // Show text\n`; } else if (qIdx === '1') { guide += ` var questionImg = sdk.getQuestionValue();\n`; guide += ` displayImage(questionImg); // Show image URL\n`; } else if (qIdx === '2') { guide += ` sdk.playVoice('question'); // Auto play audio\n`; } guide += `\n // Display Options\n`; guide += ` var optionsCount = sdk.getOptionsCount(); // ${count} items\n`; guide += ` for (var i = 0; i < optionsCount; i++) {\n`; if (oIdx === '0') { guide += ` var optionText = sdk.getOptionsValue(i).text;\n`; guide += ` createButton(optionText, i); // Show text button\n`; } else if (oIdx === '1') { guide += ` var optionImg = sdk.getOptionsValue(i).image;\n`; guide += ` createImageButton(optionImg, i); // Show image button\n`; } else if (oIdx === '2') { guide += ` createAudioButton(i); // Button to play audio\n`; guide += ` // onClick: sdk.playVoice('option' + (i+1));\n`; } guide += ` }\n`; } else if (category === '2') { // Sort/Sequences guide += ` // Display Hint (if exists)\n`; if (qIdx === '0') { guide += ` var hintText = sdk.getQuestionValue() || sdk.getRequestValue();\n`; guide += ` if (hintText) displayHint(hintText);\n`; } else if (qIdx === '1') { guide += ` var hintImg = sdk.getQuestionValue();\n`; guide += ` if (hintImg) displayHintImage(hintImg);\n`; } guide += `\n // Display Draggable Items\n`; guide += ` var itemsCount = sdk.getOptionsCount();\n`; guide += ` for (var i = 0; i < itemsCount; i++) {\n`; if (oIdx === '0') { guide += ` var itemText = sdk.getOptionsValue(i).text;\n`; guide += ` createDraggableText(itemText, i);\n`; } else if (oIdx === '1') { guide += ` var itemImg = sdk.getOptionsValue(i).image;\n`; guide += ` createDraggableImage(itemImg, i);\n`; } guide += ` }\n`; guide += ` // User drags to reorder items\n`; } else if (category === '3') { // Memory/Matching guide += ` var itemsCount = sdk.getOptionsCount();\n`; if (qIdx === oIdx) { guide += ` // Memory Card - Create pairs\n`; guide += ` var allCards = []; // Duplicate items for pairs\n`; guide += ` for (var i = 0; i < itemsCount; i++) {\n`; guide += ` allCards.push(sdk.getOptionsValue(i));\n`; guide += ` allCards.push(sdk.getOptionsValue(i)); // Duplicate\n`; guide += ` }\n`; guide += ` shuffleArray(allCards);\n`; guide += ` // Create face-down cards, flip on click\n`; } else { guide += ` // Matching - Create two groups\n`; guide += ` for (var i = 0; i < itemsCount; i++) {\n`; if (qIdx === '0') { guide += ` var leftText = sdk.getOptionsValue(i).text;\n`; guide += ` createLeftItem(leftText, i);\n`; } else if (qIdx === '1') { guide += ` var leftImg = sdk.getOptionsValue(i).image;\n`; guide += ` createLeftItem(leftImg, i);\n`; } if (oIdx === '0') { guide += ` var rightText = sdk.getOptionsValue(i).text; // Matching pair\n`; guide += ` createRightItem(rightText, i);\n`; } else if (oIdx === '1') { guide += ` var rightImg = sdk.getOptionsValue(i).image;\n`; guide += ` createRightItem(rightImg, i);\n`; } guide += ` }\n`; guide += ` // User draws lines to match pairs\n`; } } // Step 3: Handle Hint if (data.hint && data.hint.type) { guide += `\n3️⃣ HANDLE HINT (Optional)\n`; guide += ` var hintType = sdk.getHintType(); // "${data.hint.type}"\n`; if (data.hint.type === 'display') { guide += ` var hintCount = sdk.getHintCount();\n`; guide += ` for (var i = 0; i < hintCount; i++) {\n`; guide += ` var hintItem = sdk.getHintValue(i);\n`; guide += ` displayHintItem(hintItem, i); // Show each hint\n`; guide += ` }\n`; } else if (data.hint.type === 'audio') { guide += ` var hintAudio = sdk.getHintValue();\n`; guide += ` createHintButton(hintAudio); // Play audio hint\n`; } else if (data.hint.type === 'text') { guide += ` var hintText = sdk.getHintValue();\n`; guide += ` displayHintText(hintText);\n`; } } // Step 4: Check Answer const stepNum = data.hint ? '4️⃣' : '3️⃣'; guide += `\n${stepNum} CHECK ANSWER\n`; if (category === '1') { guide += ` // User clicks an option\n`; guide += ` function onOptionClick(selectedIndex) {\n`; guide += ` var userAnswer = sdk.getOptionsValue(selectedIndex).text;\n`; guide += ` var result = sdk.end(userAnswer, function(isCorrect) {\n`; guide += ` if (isCorrect) showSuccess();\n`; guide += ` else showError();\n`; guide += ` });\n`; guide += ` }\n`; } else if (category === '2') { guide += ` // User finishes sorting\n`; guide += ` function onSubmitOrder(orderedArray) {\n`; guide += ` var answerStr = orderedArray.map(item => item.text).join('|');\n`; guide += ` sdk.end(answerStr, function(isCorrect) {\n`; guide += ` if (isCorrect) showSuccess();\n`; guide += ` else showCorrectOrder();\n`; guide += ` });\n`; guide += ` }\n`; } else if (category === '3') { guide += ` // User completes all matches/pairs\n`; guide += ` function onAllMatched(matchedPairs) {\n`; guide += ` var answerStr = matchedPairs.map(p => p.text).join('|');\n`; guide += ` sdk.end(answerStr, function(isCorrect) {\n`; guide += ` showResult(isCorrect);\n`; guide += ` });\n`; guide += ` }\n`; } // Step 5: Timer if (time !== '0') { const nextStep = data.hint ? '5️⃣' : '4️⃣'; guide += `\n${nextStep} TIMER COUNTDOWN\n`; guide += ` var timeLimit = sdk.timeLimit; // ${time} seconds\n`; guide += ` startCountdown(timeLimit);\n`; guide += ` // If time runs out before sdk.end(), user fails\n`; } // API Reference guide += `\n\n📚 KEY API METHODS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`; guide += `sdk.load(callback, template) - Load data from server\n`; guide += `sdk.start() - Start game, shuffle if needed\n`; guide += `sdk.getQuestionValue() - Get question text/image/audio\n`; guide += `sdk.getQuestionType() - Get question type (text/image/audio)\n`; guide += `sdk.getOptionsCount() - Get number of options\n`; guide += `sdk.getOptionsValue(index) - Get option object at index\n`; guide += `sdk.getOptionsType() - Get options type\n`; guide += `sdk.getHintType() - Get hint type\n`; guide += `sdk.getHintValue(index) - Get hint value/array item\n`; guide += `sdk.getHintCount() - Get hint items count\n`; guide += `sdk.playVoice(type) - Play TTS (question/option1/hint)\n`; guide += `sdk.end(answer, callback) - Check answer & return result\n`; guide += `sdk.timeLimit - Time limit in seconds\n`; guide += `sdk.shuffle - Whether to shuffle options\n`; guide += `\n\n💡 TIPS\n`; guide += `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`; if (shuffle === '1') { guide += `• Options are shuffled after sdk.start() - display in new order\n`; } if (time !== '0') { guide += `• Implement timer UI and auto-submit when time expires\n`; } guide += `• Use Web Speech API: sdk.playVoice() for TTS in English\n`; guide += `• Multiple answers format: "answer1|answer2|answer3"\n`; guide += `• sdk.end() returns: {isCorrect, duration, correctAnswer, userAnswer}\n`; guide += `\n${'═'.repeat(76)}\n`; return guide; }; /** * Get the question text * @returns {string} Question or request text */ SenaSDK.prototype.getQuestionValue = function() { if (this.data.question && this.data.question !== "") { return this.data.question; } else { return ""; } }; /** * Get the question type * @returns {string} Question type (text, image, audio) */ SenaSDK.prototype.getQuestionType = function() { let self = this; // based on game code, determine question type const gameCode = self.gameCode || 'G2510S1T30'; const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (match) { const qIdx = match[3]; const types = { '0': 'text', '1': 'image', '2': 'audio' }; return types[qIdx] || 'text'; } return 'text'; }; /** * Get the request value * @returns {string} Request text */ SenaSDK.prototype.getRequestValue = function() { if (this.data && this.data.request && this.data.request !== "") { return this.data.request; } else { return ""; } }; /** * Get the request type (same as question type) * @returns {string} Request type (text, image, audio) */ SenaSDK.prototype.getRequestType = function() { return this.getQuestionType(); }; /** * Get total number of options * @returns {number} Number of options */ SenaSDK.prototype.getOptionsCount = function() { return this.data.options.length; }; /** * Get options type based on game code * @returns {string} Options type (text, image, audio) */ SenaSDK.prototype.getOptionsType = function() { let self = this; // based on game code, determine options type const gameCode = self.gameCode || 'G2510S1T30'; const regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/; const match = gameCode.match(regex); if (match) { const oIdx = match[4]; const types = { '0': 'text', '1': 'image', '2': 'audio' }; return types[oIdx] || 'text'; } return 'text'; }; /** * Get option value by index based on options type from game code * @param {number} index - Option index * @returns {string} Option value (text, image URL, or audio URL based on game code) */ SenaSDK.prototype.getOptionsValue = function(index) { return this.data.options[index]; }; /** * Get hint type * @returns {string} Hint type (e.g., 'display', 'audio', 'text', or empty string if no hint) */ SenaSDK.prototype.getHintType = function() { if (this.data && this.data.hint && this.data.hint.type) { return this.data.hint.type; } return ""; }; /** * Get hint count (number of elements if hint is an array, particularly for display type) * @returns {number} Number of elements in hint array, or 1 if not an array, or 0 if no hint */ SenaSDK.prototype.getHintCount = function() { const hintValue = this.getHintValue(); if (hintValue === null) { return 0; } if (Array.isArray(hintValue)) { return hintValue.length; } return 1; }; /** * Get hint value * @param {number} index - Optional index for array hints (display type) * @returns {*} Hint value (string, array element, or null if no hint) */ SenaSDK.prototype.getHintValue = function(index) { if (this.data && this.data.hint && this.data.hint.value !== undefined) { const hintValue = this.data.hint.value; const hintType = this.getHintType(); // If hint type is display and value is array, return specific index if (hintType === 'display' && Array.isArray(hintValue)) { if (index !== undefined && index >= 0 && index < hintValue.length) { return hintValue[index]; } // If no index provided or invalid, return the whole array return hintValue; } // For audio or text type, return the value directly return hintValue; } return null; }; /** * Start the quiz - resets index and shuffles options */ SenaSDK.prototype.start = function() { let self = this; self.curIndex = 0; if (self.shuffle) { self.shuffleArray(self.data.options); } self.startTime = Date.now(); // Additional logic for tracking can be added here if needed }; /** * End the game and check answer * @param {string} answer - User's answer (single text or multiple answers separated by |) * @returns {Object} Result object with isCorrect, duration, correctAnswer, and userAnswer */ SenaSDK.prototype.end = function(answer , callback) { let self = this; self.endTime = Date.now(); const duration = (self.endTime - self.startTime) / 1000; // Parse user answer - split by | for multiple answers const userAnswers = answer.includes('|') ? answer.split('|').map(a => a.trim().toLowerCase()) : [answer.trim().toLowerCase()]; // Get correct answer(s) from data let correctAnswers = []; if (self.correctAnswer) { // Check if answer is an array (multiple answers) or single answer if (Array.isArray(self.correctAnswer)) { correctAnswers = self.correctAnswer.map(a => { if (typeof a === 'string') return a.toLowerCase(); if (a.text) return a.text.toLowerCase(); return ''; }); } else if (typeof self.correctAnswer === 'string') { correctAnswers = [self.correctAnswer.toLowerCase()]; } else if (self.correctAnswer.text) { correctAnswers = [self.correctAnswer.text.toLowerCase()]; } } // Check if answer is correct let isCorrect = false; if (userAnswers.length === correctAnswers.length) { // For ordered multiple answers isCorrect = userAnswers.every((ans, index) => ans === correctAnswers[index]); } else if (userAnswers.length === 1 && correctAnswers.length === 1) { // For single answer isCorrect = userAnswers[0] === correctAnswers[0]; } const result = { isCorrect: isCorrect, duration: duration, correctAnswer: correctAnswers.join(' | '), userAnswer: userAnswers.join(' | ') }; // if time spent more than time limit, mark as incorrect if (self.timeLimit > 0 && duration > self.timeLimit) { result.isCorrect = false; } console.log(`Time spent in game: ${duration} seconds`); console.log(`Result: ${isCorrect ? 'CORRECT' : 'INCORRECT'}`); if (callback) callback(result.isCorrect); }; SenaSDK.prototype.playVoice = function(type) { let self = this; // type: 'question', 'optionA', 'optionB', ... // if type is options, get corresponding option text like option0 -> index 0 let textToSpeak = ''; if (type.startsWith('option')) { const optionIndex = parseInt(type.slice(6)) - 1; textToSpeak = self.getOptionsValue(optionIndex); } else if (type === 'question') { textToSpeak = self.getQuestionValue(); } else if (type === 'request') { textToSpeak = self.getRequestValue(); } else if (type === 'hint') { const hintValue = self.getHintValue(); if (typeof hintValue === 'string') { textToSpeak = hintValue; } } if(textToSpeak == "") return; if (window['audioPlayer']) { window['audioPlayer'].pause(); window['audioPlayer'].src = textToSpeak; window['audioPlayer'].play(); } else { window['audioPlayer'] = new Audio(textToSpeak); window['audioPlayer'].play(); } }; SenaSDK.prototype.helper = {}; SenaSDK.prototype.helper.CalcObjectPositions = function(n, objectWidth, margin, maxWidth) { let self = this; self.positions = []; const totalWidth = n * objectWidth + (n - 1) * margin; const startX = (maxWidth - totalWidth) / 2; let positions = []; for (let i = 0; i < n; i++) { positions.push(startX + i * (objectWidth + margin)); } positions.map(pos => pos + objectWidth / 2); // Adjusting to center the objects self.positions = positions; }; SenaSDK.prototype.helper.getPosXbyIndex = function(index) { let self = this; if (index < 0 || index >= self.positions.length) { return null; // Return null if index is out of bounds } return self.positions[index]; }; // Export for different module systems if (typeof module !== 'undefined' && module.exports) { module.exports = SenaSDK; } else if (typeof define === 'function' && define.amd) { define([], function() { return SenaSDK; }); } else { window.SenaSDK = SenaSDK; }