xoa logo
All checks were successful
Deploy to Production / deploy (push) Successful in 7s

This commit is contained in:
Đặng Minh Quang
2026-03-02 15:01:57 +07:00
parent 0939da39a9
commit 6586f29779
37 changed files with 19 additions and 1423 deletions

View File

@@ -26022,8 +26022,8 @@ cr.getObjectRefTable = function () { return [
cr.plugins_.Function,
cr.plugins_.SenaPlugin,
cr.plugins_.Sprite,
cr.plugins_.Text,
cr.plugins_.Touch,
cr.plugins_.Text,
cr.behaviors.Rex_MoveTo,
cr.behaviors.Fade,
cr.behaviors.DragnDrop,

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,5 @@
{
"version": 1772435392,
"version": 1772438322,
"fileList": [
"data.js",
"c2runtime.js",
@@ -21,7 +21,6 @@
"images/pauseicon-sheet0.png",
"images/black-sheet0.png",
"images/sound_question-sheet0.png",
"images/newwords-sheet0.png",
"media/alert-234711.ogg",
"media/bubble-pop-389501.ogg",
"media/button-124476.ogg",

View File

@@ -1087,7 +1087,7 @@ SenaSDK.prototype.canReloadData = function () {
/**
* End the game and check answer
* [UPDATE] Support Unordered Answers & Auto-cleanup empty strings
* [UPDATE] Support Unordered Answers, Auto-cleanup empty strings & Post GAME_RESULT to FE
*/
SenaSDK.prototype.end = function (answer, callback) {
let self = this;
@@ -1217,11 +1217,25 @@ SenaSDK.prototype.end = function (answer, callback) {
};
// -----------------------------------------------------------
// [BƯỚC 3] Log và Return
// [BƯỚC 3] Log và Return (KÈM BẮN POST MESSAGE CHO FE)
// -----------------------------------------------------------
console.log(`Time spent: ${duration}s`);
console.log(`Result: ${isCorrect ? "CORRECT" : "INCORRECT"}`);
// THÊM MỚI: Bắn tín hiệu GAME_RESULT lên cho hệ thống FE tính điểm
window.parent.postMessage(
{
type: "GAME_RESULT",
payload: {
game_id: self.gameID || self.gameCode,
result: isCorrect ? "CORRECT" : "INCORRECT",
time_spent: duration,
},
},
"*",
);
console.log("📤 Sena SDK: GAME_RESULT sent successfully for Quiz/Sort/Fill");
if (callback) callback(result.isCorrect);
return result; // Return full object for debug
};

View File

@@ -1,378 +0,0 @@
/**
* TDV_CORE.js - Game Logic Layer (Exam Mode)
* Nhiệm vụ: Quản lý State, Sanitize Data (G110), Timer, Logic Loop
*/
var TDV_Core = {
// Data Storage
list: [],
currentQuestion: null,
questionTemplate: [],
missingChars: [],
blankIndexes: [],
solutionKey: [], // Lưu đáp án gốc để chấm điểm
// Game State
level: 0,
userResults: [],
gameStartTime: null,
submitTimeout: null,
score: 0,
// Timer Variables
timeLimit: 180, // Mặc định 180s
timeRemaining: 180,
timerInterval: null,
// Tracking người chơi
placedChars: [],
canSubmit: 0,
// ==================== 1. DATA LOADING & SETUP ====================
loadCleanData: function (payload) {
if (!payload || !payload.items) return;
this.setupGame(payload.items);
},
loadRawData: function (rawItems) {
if (!rawItems) return;
const cleanItems = this.sanitizeData(rawItems);
this.setupGame(cleanItems);
},
setupGame: function (items) {
this.list = items;
this.level = 0;
this.userResults = [];
this.score = 0;
this.gameStartTime = new Date();
// 1. Setup Timer (Lấy từ config câu đầu tiên hoặc mặc định 180s)
// Đây là Timer tổng cho cả bài thi
if (items.length > 0 && items[0].time_limit) {
this.timeLimit = items[0].time_limit;
} else {
this.timeLimit = 180;
}
this.startTimer(); // <--- BẮT ĐẦU ĐẾM NGAY
console.log(
`🧠 [CORE] Game Setup. Loaded ${this.list.length} questions. Timer: ${this.timeLimit}s`,
);
this.loadCurrentLevel();
// 2. Báo hiệu cho C2
window.TDV_READY = 1;
console.log("🚩 [CORE] Ready Flag set to 1");
if (typeof c2_callFunction !== "undefined") {
c2_callFunction("CMD_LoadGame");
}
},
loadCurrentLevel: function () {
this.currentQuestion = this.list[this.level];
if (!this.currentQuestion) return;
this.questionTemplate = [...(this.currentQuestion.question || [])];
this.missingChars = [...(this.currentQuestion.options || [])];
this.solutionKey = this.currentQuestion.solution || [];
// Tính toán ô trống
this.blankIndexes = [];
for (let i = 0; i < this.questionTemplate.length; i++) {
if (this.questionTemplate[i] === "") this.blankIndexes.push(i);
}
// Reset tracking
this.placedChars = new Array(this.questionTemplate.length).fill("");
this.canSubmit = 0;
// Gọi View tính lại layout
if (window.TDV_View) {
TDV_View.calculateLayout(this.questionTemplate.length);
}
},
// ==================== 2. TIMER LOGIC (SIMPLE) ====================
startTimer: function () {
this.stopTimer(); // Reset nếu đang chạy
this.timeRemaining = this.timeLimit;
console.log(`⏱️ Timer Started: ${this.timeRemaining}`);
this.timerInterval = setInterval(() => {
if (this.timeRemaining > 0) {
this.timeRemaining--;
} else {
// HẾT GIỜ
this.stopTimer();
console.log("⏰ Time Out!");
if (typeof c2_callFunction !== "undefined") {
c2_callFunction("CMD_TimeOut");
}
}
}, 1000);
},
stopTimer: function () {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
},
getTimeRaw: function () {
return this.timeRemaining; // Trả về số nguyên (VD: 179)
},
// ==================== 3. SANITIZE LOGIC ====================
sanitizeData: function (rawItems) {
return rawItems.map((item) => {
if (item.question && Array.isArray(item.question)) return item;
// Xóa dấu cách
let cleanWord = item.word || "";
cleanWord = cleanWord.replace(/\s+/g, "");
const parts = item.parts || cleanWord.split("");
const missingCount = item.missing_letter_count || 1;
// Random đục lỗ
let indices = Array.from({ length: parts.length }, (_, i) => i);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[indices[i], indices[j]] = [indices[j], indices[i]];
}
const blanks = indices.slice(0, missingCount);
const questionArr = [...parts];
const optionsArr = [];
blanks.forEach((idx) => {
optionsArr.push(parts[idx]);
questionArr[idx] = "";
});
// Shuffle options
for (let i = optionsArr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[optionsArr[i], optionsArr[j]] = [optionsArr[j], optionsArr[i]];
}
return {
id: item.id,
question: questionArr,
options: optionsArr,
solution: parts, // Lưu đáp án gốc để chấm điểm
audio_url: item.audio_url,
time_limit: item.time_limit, // Giữ lại config time nếu có
};
});
},
// ==================== 4. GAMEPLAY ACTIONS ====================
play: function () {
if (!this.canSubmit) return;
if (!this.currentQuestion) return;
const finalAnswer = this.getPlacedArray();
// DEV MODE: Giả lập chấm điểm
if (TDV_Net.mode !== "live") {
this.simulateLocalCheck(finalAnswer);
}
// Gửi kết quả
const timeSpent = new Date() - this.gameStartTime;
TDV_Net.sendAnswer(this.currentQuestion.id, finalAnswer, timeSpent);
},
onServerResult: function (data) {
// Lưu kết quả
this.userResults.push({
id: data.question_id,
result: data.correct ? 1 : 0,
});
if (data.correct) {
this.score += 10;
}
// Gọi về C2
if (typeof c2_callFunction !== "undefined") {
c2_callFunction("CMD_ShowResult", [data.correct ? 1 : 0]);
}
},
nextQuestion: function () {
if (this.level < this.list.length - 1) {
this.level++;
this.loadCurrentLevel();
return 1; // Còn câu hỏi
}
// HẾT GAME -> DỪNG ĐỒNG HỒ
this.stopTimer();
console.log("🏁 End Game.");
return 0;
},
// ==================== 5. HELPER LOGIC ====================
setCharAtSlot: function (char, slotIndex) {
if (slotIndex < 0 || slotIndex >= this.placedChars.length) return 0;
// Kiểm tra ghế có người ngồi chưa (Quan trọng để không đè chữ)
if (this.placedChars[slotIndex] !== "") {
console.log(`⛔ Slot ${slotIndex} occupied.`);
return 0;
}
this.placedChars[slotIndex] = char;
this.updateSubmitState();
return 1;
},
clearCharAtSlot: function (slotIndex) {
if (slotIndex >= 0 && slotIndex < this.placedChars.length) {
this.placedChars[slotIndex] = "";
this.updateSubmitState();
}
},
updateSubmitState: function () {
var required = this.blankIndexes.length;
var current = this.placedChars.filter((c) => c !== "").length;
this.canSubmit = current === required ? 1 : 0;
},
getPlacedArray: function () {
var result = [];
for (var i = 0; i < this.questionTemplate.length; i++) {
if (this.isBlankIndex(i)) {
result.push(this.placedChars[i] || "");
} else {
result.push(this.questionTemplate[i]);
}
}
return result;
},
// Chấm điểm giả lập (So sánh với Solution Key)
simulateLocalCheck: function (ansArr) {
console.log("📝 Check vs Key:", this.solutionKey);
let isCorrect = true;
for (let i = 0; i < this.solutionKey.length; i++) {
if (ansArr[i] !== this.solutionKey[i]) {
isCorrect = false;
break;
}
}
setTimeout(() => {
this.onServerResult({
question_id: this.currentQuestion.id,
correct: isCorrect,
});
}, 500);
},
// Getters
isBlankIndex: function (i) {
return this.blankIndexes.indexOf(i) !== -1 ? 1 : 0;
},
getCharByIndex: function (i) {
return this.missingChars[i] || "";
},
getCharsCount: function () {
return this.questionTemplate.length;
},
getMissingCharsCount: function () {
return this.missingChars.length;
},
canSubmitAnswer: function () {
return this.canSubmit;
},
};
// ============================================================
// BRIDGE CHO C2 (MAPPING) - ĐÃ DỌN DẸP GỌN GÀNG
// ============================================================
window.tdv_sdk = {
// 1. Gameplay Interaction
setWordAtSlot: function (char, idx) {
return TDV_Core.setCharAtSlot(char, idx);
},
clearLetterAtSlot: function (idx) {
TDV_Core.clearCharAtSlot(idx);
},
canSubmitAnswer: function () {
return TDV_Core.canSubmitAnswer();
},
submitAnswer: function () {
TDV_Core.play();
return 1;
},
nextQuestion: function () {
return TDV_Core.nextQuestion();
},
// 2. Data & UI Getters
getScore: function () {
return TDV_Core.score;
},
getTime: function () {
return TDV_Core.getTimeRaw();
}, // Trả về số nguyên đơn giản
getCharsCount: function () {
return TDV_Core.getCharsCount();
},
getMissingCharsCount: function () {
return TDV_Core.getMissingCharsCount();
},
isPresetSlot: function (i) {
return !TDV_Core.isBlankIndex(i) ? 1 : 0;
},
getDisplayText: function (i) {
return TDV_View.getDisplayText(i);
},
getAnswerText: function (i) {
return TDV_View.getAnswerText(i);
},
// 3. View Getters
getSlotX: function (i) {
return TDV_View.getSlotX(i);
},
getSlotY: function (i) {
return TDV_View.getSlotY(i);
},
getAnswerX: function (i) {
return TDV_View.getAnswerX(i);
},
getAnswerY: function (i) {
return TDV_View.getAnswerY(i);
},
getAnswerAngle: function (i) {
return TDV_View.getAnswerAngle(i);
},
getScale: function () {
return TDV_View.getScale();
},
getFontSize: function () {
return TDV_View.getFontSize();
},
getAnswerFontSize: function () {
return TDV_View.getAnswerFontSize();
},
};
// Public Core ra window
window.TDV_Core = TDV_Core;
console.log("✅ TDV_CORE V2 (Fixed Timer & Cleaned) Loaded!");

View File

@@ -1,118 +0,0 @@
/**
* TDV_NET.js - Network Layer (Final Version)
* Trách nhiệm: Xử lý giao tiếp postMessage và quản lý lỗi hệ thống.
* Tuân thủ: SDK Message Protocol & Game Channels Guide
*/
var TDV_Net = {
mode: "live",
gameCode: "G110",
// 1. KHỞI TẠO SDK
init: function (gameCode, defaultData) {
this.gameCode = gameCode || "G110";
const urlParams = new URLSearchParams(window.location.search);
const paramsObj = Object.fromEntries(urlParams.entries());
this.mode = paramsObj.mode || "dev";
console.log(
`📡 [NET] Init | Mode: ${this.mode} | GameCode: ${this.gameCode}`,
);
// Payload chuẩn Protocol cho chế độ LIVE
let initPayload = {
mode: this.mode,
game_code: this.gameCode,
assignment_id: paramsObj.assignment_id || "", // Bắt buộc cho LIVE
student_id: paramsObj.student_id || "", // Bắt buộc cho LIVE
api_base_url: paramsObj.api_base_url || "", // Bắt buộc cho LIVE
auth_token: paramsObj.auth_token || "", // Token xác thực
};
// Gộp các params khác từ URL (như bg, game_id...)
initPayload = Object.assign({}, paramsObj, initPayload);
if (window.parent !== window) {
window.parent.postMessage(
{
type: "SDK_INIT",
payload: initPayload,
},
"*",
);
}
// Điều phối Mode
if (this.mode === "dev") {
setTimeout(() => {
if (window.TDV_Core) TDV_Core.loadRawData(defaultData);
}, 1000);
} else if (this.mode === "preview") {
console.log("👁️ [NET] Preview Mode: Waiting for SDK_PUSH_DATA...");
}
},
// 2. GỬI ĐÁP ÁN (Check Answer)
sendAnswer: function (qId, choiceArray, timeSpentMs) {
window.parent.postMessage(
{
type: "SDK_CHECK_ANSWER",
payload: {
question_id: qId,
choice: choiceArray, // Array cho Sequence Game
time_spent: timeSpentMs,
},
},
"*",
);
},
// 3. GỬI KẾT QUẢ CUỐI (Final Result)
sendFinalResult: function (score, timeSpent, details) {
window.parent.postMessage(
{
type: "SDK_FINAL_RESULT",
payload: { score, time_spent: timeSpent, details },
},
"*",
);
},
};
// --- LISTENER: XỬ LÝ CÁC SỰ KIỆN TỪ SERVER ---
window.addEventListener("message", function (event) {
if (!event.data) return;
const { type, payload } = event.data;
if (!window.TDV_Core) return;
switch (type) {
case "SDK_DATA_READY":
TDV_Core.loadCleanData(payload); //
break;
case "SDK_PUSH_DATA":
console.log("📥 [NET] Received Push Data (Preview)");
TDV_Core.loadRawData(payload.items); //
break;
case "SDK_ANSWER_RESULT":
TDV_Core.onServerResult(payload); //
break;
// --- LẮNG NGHE LỖI HỆ THỐNG ---
case "SDK_ERROR":
const { code, message, details } = payload;
console.error(`❌ [SDK_ERROR] Code: ${code} | Message: ${message}`);
if (details) console.error("Details:", details);
if (window["TDVTriger"]) {
}
break;
case "SDK_READY": //Log trạng thái sẵn sàng kết nối.
console.log("✅ [NET] SDK Ready:", payload);
break;
}
});
window.TDV_Net = TDV_Net;

View File

@@ -1,733 +0,0 @@
/**
* TDV SDK v8.2 - SENA PLUGIN SYNC VERSION
* - Sync dữ liệu từ SenaAI Construct 2 Plugin (window.SenaTrigger.sdk)
* - Hoặc tự load từ: https://senaai.tech/sample/{GameCode}.json
* - Hỗ trợ Quiz dạng: Câu hỏi Text, Đáp án Text/Image
**/
var tdv_sdk = {
game_code: 'G1400S1T30',
activeSdk: null,
serverDataLoaded: false,
gameStartTime: null,
currentQuestionIndex: 0,
totalScore: 0,
timeLimit: 0,
_timerStarted: false,
_lastLogTime: -1,
_isPaused: false,
_pausedElapsed: 0,
// ==================== SYNC FROM SENA PLUGIN ====================
/**
* Sync data từ SenaAI Plugin
* Gọi hàm này sau khi SenaAI.Load hoàn tất (On LOAD Complete)
*/
syncFromPlugin: function () {
var self = this;
// Lấy SDK instance từ SenaAI Plugin
if (window.SenaTrigger && window.SenaTrigger.sdk) {
var pluginSdk = window.SenaTrigger.sdk;
// Nếu plugin chưa có data, trả về false để SDK tự load
if (!pluginSdk.data) {
console.warn('⚠️ TDV SDK: Plugin has no data yet, will fallback to self-load');
return false;
}
self.activeSdk = {
data: pluginSdk.data,
correctAnswer: pluginSdk.correctAnswer,
shuffle: pluginSdk.shuffle,
gameCode: pluginSdk.gameCode,
startTime: pluginSdk.startTime,
timeLimit: pluginSdk.timeLimit
};
self.game_code = pluginSdk.gameCode || self.game_code;
self.timeLimit = pluginSdk.timeLimit || 0;
self._parseGameCode(); // Fallback parse if plugin doesn't have it
// SyncStartTime: Chỉ đồng bộ nếu tdv_sdk chưa bắt đầu đếm ngược
if (pluginSdk.startTime > 0 && !this._timerStarted) {
this.gameStartTime = pluginSdk.startTime;
this._timerStarted = true;
console.log('🔗 Timer synced from plugin:', this.gameStartTime);
}
self.serverDataLoaded = true;
console.log('✅ TDV SDK: Synced from SenaAI Plugin (StartTime:', self.gameStartTime, ')');
return true;
}
console.warn('⚠️ TDV SDK: SenaAI Plugin not found');
return false;
},
/**
* Initialize - tự động sync từ plugin nếu có
*/
init: function (config) {
var self = this;
if (config && config.gameCode) {
self.game_code = config.gameCode;
}
// Override từ URL param LID
var urlParams = new URLSearchParams(window.location.search);
var LID = urlParams.get('LID');
if (LID) {
self.game_code = LID;
}
console.log('🎮 TDV SDK v8.2 - SenaAI Plugin Sync');
console.log('📦 Game Code:', self.game_code);
// Tự động sync nếu plugin đã load
self.syncFromPlugin();
self._parseGameCode();
},
/**
* Helper: Parse game code để lấy cấu hình timeLimit, shuffle
*/
_parseGameCode: function () {
var self = this;
var regex = /^G([1-5])([2-9])([0-2])([0-2])(?:S([0-1]))?(?:T(\d+))?$/;
var match = String(self.game_code).match(regex);
if (match) {
var timeStr = match[6] !== undefined ? match[6] : '0';
self.timeLimit = parseInt(timeStr, 10);
}
},
/**
* Load data - ưu tiên sync từ plugin, nếu không thì tự load
*/
load: function (callback) {
var self = this;
// Thử sync từ plugin trước
if (self.syncFromPlugin()) {
if (callback) callback(true);
return;
}
// Nếu không có plugin, tự load từ server
var url = 'https://senaai.tech/sample/' + self.game_code + '.json';
console.log('📡 TDV SDK: Self-loading from:', url);
fetch(url)
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function (json) {
console.log('✅ TDV SDK: Data loaded:', json);
self.activeSdk = {
data: json.data,
correctAnswer: json.answer,
shuffle: false
};
self.serverDataLoaded = true;
// self.gameStartTime = Date.now(); // REMOVED: Chỉ bắt đầu khi Start
if (callback) callback(true);
})
.catch(function (error) {
console.error('❌ TDV SDK: Load Error:', error);
self.serverDataLoaded = false;
if (callback) callback(false);
});
},
/**
* Start game
*/
start: function () {
// Sync lại từ plugin mỗi khi start (đảm bảo data mới nhất)
this.syncFromPlugin();
this._parseGameCode();
this.gameStartTime = Date.now();
this._timerStarted = true;
this._isPaused = false;
this._pausedElapsed = 0;
this.currentQuestionIndex = 0;
console.log('🎮 Game Started! Timer set to:', this.timeLimit);
},
// ==================== DATA GETTERS - Tự động sync nếu chưa có data ====================
/**
* Lấy SDK data, sync từ plugin nếu chưa có
*/
_getSdk: function () {
if (!this.activeSdk || !this.activeSdk.data) {
this.syncFromPlugin();
}
return this.activeSdk;
},
/**
* Lấy instruction/request text
*/
getInstructions: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return "";
return sdk.data.request || sdk.data.question || "";
},
/**
* Lấy request text
* Sử dụng: Browser.ExecJS("tdv_sdk.getRequest()")
*/
getRequest: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return "";
return sdk.data.request || "";
},
/**
* Lấy question text
* Sử dụng: Browser.ExecJS("tdv_sdk.getQuestion()")
*/
getQuestion: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return "";
var q = String(sdk.data.question || "").trim();
// Nếu question là một URL (bắt đầu bằng http hoặc là link ảnh/âm thanh), trả về rỗng để tránh hiện text
if (q.toLowerCase().startsWith('http') || this._isAudioUrl(q) || this._isImageUrl(q)) {
return "";
}
return q;
},
/**
* Lấy loại câu hỏi: 'text', 'image', 'audio'
*/
getQuestionType: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return 'text';
var q = String(sdk.data.question || "");
var isUrl = q.toLowerCase().startsWith('http');
// Check audio trước
if (sdk.data.audio || (isUrl && this._isAudioUrl(q))) return 'audio';
// Check image
if (sdk.data.image || sdk.data.image_url || (isUrl && this._isImageUrl(q))) return 'image';
// Fallback theo game code (số thứ 3)
if (this.game_code && this.game_code.length >= 4) {
var qTypeChar = this.game_code.charAt(3);
if (qTypeChar === '1') return 'image';
if (qTypeChar === '2') return 'audio';
}
return 'text';
},
/**
* Helper: Kiểm tra chuỗi có phải URL âm thanh không
*/
_isAudioUrl: function (url) {
var str = String(url).toLowerCase();
if (!str.startsWith('http')) return false;
var exts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac'];
for (var i = 0; i < exts.length; i++) {
if (str.endsWith(exts[i])) return true;
}
return str.includes('/audio/') || str.includes('audio.');
},
/**
* Helper: Kiểm tra chuỗi có phải URL hình ảnh không
*/
_isImageUrl: function (url) {
var str = String(url).toLowerCase();
if (!str.startsWith('http')) return false;
var exts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
for (var i = 0; i < exts.length; i++) {
if (str.endsWith(exts[i])) return true;
}
return str.includes('/img/') || str.includes('/image/') || str.includes('image.');
},
/**
* Kiểm tra có hình ảnh câu hỏi không
* Sử dụng: Browser.ExecJS("tdv_sdk.hasImage()") = 1
*/
hasImage: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return 0;
var url = sdk.data.image_url || sdk.data.image || "";
if (url && String(url).toLowerCase().startsWith('http')) return 1;
// Nếu không có field image riêng, check question có phải là link ảnh không
var q = sdk.data.question || "";
return this._isImageUrl(q) ? 1 : 0;
},
/**
* Lấy URL hình ảnh câu hỏi
*/
getImageUrl: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return "";
var url = sdk.data.image_url || sdk.data.image || "";
if (!url && this._isImageUrl(sdk.data.question)) {
url = sdk.data.question;
}
return this.getCorsUrl(url);
},
/**
* Kiểm tra có audio câu hỏi không
* Sử dụng: Browser.ExecJS("tdv_sdk.hasAudio()") = 1
*/
hasAudio: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return 0;
if (sdk.data.audio && sdk.data.audio.length > 0) return 1;
// Check nếu question là link audio
return this._isAudioUrl(sdk.data.question) ? 1 : 0;
},
/**
* Lấy URL audio câu hỏi
* Sử dụng: Browser.ExecJS("tdv_sdk.getAudioUrl()")
*/
getAudioUrl: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data) return "";
var url = sdk.data.audio || "";
if (!url && this._isAudioUrl(sdk.data.question)) {
url = sdk.data.question;
}
return this.getCorsUrl(url);
},
// ==================== OPTIONS GETTERS ====================
/**
* Lấy số lượng options
* Sử dụng: Browser.ExecJS("tdv_sdk.getOptionsCount()")
*/
getOptionsCount: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.data || !sdk.data.options) return 0;
return sdk.data.options.length;
},
/**
* Lấy giá trị gốc của option (Object hoặc String)
*/
_getRawOptionValue: function (index) {
var sdk = this._getSdk();
if (!sdk || !sdk.data || !sdk.data.options) return null;
return sdk.data.options[index];
},
/**
* Helper: Parse JSON string if needed
*/
_parseData: function (val) {
if (typeof val === 'string' && val.trim().startsWith('{')) {
try {
var obj = JSON.parse(val);
return obj.text || val;
} catch (e) { return val; }
}
return val;
},
/**
* Lấy text của option theo index
* Sử dụng: Browser.ExecJS("tdv_sdk.getAnswerByIndex(0)")
*/
getAnswerByIndex: function (index) {
var opt = this._getRawOptionValue(index);
if (!opt) return "";
// Trường hợp là Object {text: "...", image: "..."}
if (typeof opt === 'object') {
return opt.text || "";
}
// Trường hợp là chuỗi (có thể là JSON hoặc URL)
var text = this._parseData(String(opt));
// Nếu text là một URL (bắt đầu bằng http), trả về rỗng để tránh hiện link trên nút
if (typeof text === 'string' && text.toLowerCase().startsWith('http')) {
return "";
}
return text;
},
/**
* Alias - getOptionText
*/
getOptionText: function (index) {
return this.getAnswerByIndex(index);
},
/**
* Kiểm tra option có hình ảnh không
* Sử dụng: Browser.ExecJS("tdv_sdk.hasOptionImage(0)") = 1
*/
hasOptionImage: function (index) {
var opt = this._getRawOptionValue(index);
if (!opt) return 0;
// Trường hợp là Object
if (typeof opt === 'object') {
var url = opt.image || opt.image_url || "";
return (url && url.length > 0) ? 1 : 0;
}
// Trường hợp là chuỗi (check link ảnh)
return this._isImageUrl(String(opt)) ? 1 : 0;
},
/**
* Lấy URL hình ảnh của option
* Sử dụng: Browser.ExecJS("tdv_sdk.getOptionImageUrl(0)")
*/
getOptionImageUrl: function (index) {
var opt = this._getRawOptionValue(index);
if (!opt) return "";
if (typeof opt === 'object') {
var url = opt.image || opt.image_url || "";
return this.getCorsUrl(url);
}
// Nếu là link ảnh trực tiếp
var str = String(opt);
if (this._isImageUrl(str)) {
return this.getCorsUrl(str);
}
return "";
},
/**
* Kiểm tra option có audio không
* Trả về 1 nếu là object có property audio HOẶC là chuỗi URL dẫn đến file âm thanh
*/
hasOptionAudio: function (index) {
var opt = this._getRawOptionValue(index);
if (!opt) return 0;
// Trường hợp là Object
if (typeof opt === 'object') {
return (opt.audio && opt.audio.length > 0) ? 1 : 0;
}
// Trường hợp là chuỗi (kiểm tra xem có phải link audio không)
var str = String(opt).toLowerCase();
if (str.startsWith('http')) {
// Check các định dạng âm thanh phổ biến
var extensions = ['.mp3', '.wav', '.ogg', '.m4a', '.aac'];
for (var i = 0; i < extensions.length; i++) {
if (str.endsWith(extensions[i])) return 1;
}
if (str.includes('/audio/') || str.includes('audio.')) return 1;
}
return 0;
},
/**
* Lấy URL audio của option
*/
getOptionAudio: function (index) {
var opt = this._getRawOptionValue(index);
if (!opt) return "";
if (typeof opt === 'object') {
return this.getCorsUrl(opt.audio || "");
}
// Nếu là chuỗi bắt đầu bằng http, coi như đó là URL audio trực tiếp
var str = String(opt);
if (str.toLowerCase().startsWith('http')) {
return this.getCorsUrl(str);
}
return "";
},
// ==================== ANSWER CHECKING (Trigger Plugin Events) ====================
/**
* Lấy đáp án đúng
* Sử dụng: Browser.ExecJS("tdv_sdk.getCorrectResultText()")
*/
getCorrectResultText: function () {
var sdk = this._getSdk();
if (!sdk || !sdk.correctAnswer) return "";
return String(sdk.correctAnswer);
},
/**
* Kiểm tra đáp án - QUAN TRỌNG cho SenaAI Plugin
* Sử dụng: Browser.ExecJS("tdv_sdk.checkAnswer('apple')")
* Returns: 1 (đúng) hoặc 0 (sai)
*
* Plugin sẽ trigger:
* - "On Correct Answer" nếu đúng
* - "On Wrong Answer" nếu sai
*/
checkAnswer: function (userAnswer) {
var self = this;
var isCorrect = 0;
// Ưu tiên dùng hàm end() của Plugin SDK chính để tính toán duration/score
if (window.SenaTrigger && window.SenaTrigger.sdk) {
console.log('🏁 Calling official sena_sdk.end()...');
window.SenaTrigger.sdk.end(userAnswer, function (result) {
isCorrect = result ? 1 : 0;
});
} else {
// Fallback nếu không có plugin (logic cũ)
var correct = this.getCorrectResultText().toLowerCase().trim();
var user = String(userAnswer).toLowerCase().trim();
if (user.includes('corsproxy.io/?')) {
try {
var decoded = decodeURIComponent(user.split('corsproxy.io/?')[1]);
if (decoded) user = decoded.toLowerCase().trim();
} catch (e) { }
}
isCorrect = (user === correct) ? 1 : 0;
if (!isCorrect && (user.startsWith('http') || correct.startsWith('http'))) {
var getFileName = function (url) {
var parts = url.split('/');
return parts[parts.length - 1].split('?')[0];
};
if (getFileName(user) === getFileName(correct)) isCorrect = 1;
}
}
// Đồng bộ kết quả ra Bridge cho Construct 2
window.tdv_bridge_result = isCorrect;
if (isCorrect) this.totalScore++;
console.log('📝 Result Bridge:', isCorrect === 1 ? '✅ CORRECT' : '❌ WRONG');
return isCorrect;
},
/**
* Kiểm tra đáp án theo index của option đã chọn
* Đã cải tiến để lấy đúng giá trị (text hoặc URL) để so sánh
*/
checkAnswerByIndex: function (index) {
var opt = this._getRawOptionValue(index);
var val = "";
if (typeof opt === 'object') {
val = opt.text || "";
} else {
val = String(opt);
}
return this.checkAnswer(val);
},
/**
* Submit đáp án - alias cho checkAnswer
* Sử dụng: Browser.ExecJS("tdv_sdk.play('apple')")
*/
play: function (userAnswer) {
return this.checkAnswer(userAnswer);
},
// ==================== AUDIO PLAYBACK ====================
/**
* Phát audio câu hỏi
* Sử dụng: Browser.ExecJS("tdv_sdk.playQuestionAudio()")
*/
playQuestionAudio: function () {
var url = this.getAudioUrl();
if (url) {
console.log('🔊 Playing question audio');
new Audio(url).play().catch(function (e) { console.error(e); });
}
},
/**
* Phát audio option
* Sử dụng: Browser.ExecJS("tdv_sdk.playOptionAudio(0)")
*/
playOptionAudio: function (index) {
var url = this.getOptionAudio(index);
if (url) {
console.log('🔊 Playing option', index, 'audio');
new Audio(url).play().catch(function (e) { console.error(e); });
}
},
/**
* Phát audio từ URL
* Sử dụng: Browser.ExecJS("tdv_sdk.playSpecificAudio('url')")
*/
playSpecificAudio: function (url) {
if (url && url !== "" && url !== "NaN") {
new Audio(url).play().catch(function (e) { console.error(e); });
}
},
// ==================== SCORE & GAME STATE ====================
getCurrentScore: function () { return this.totalScore; },
getScore: function () { return this.totalScore; },
getCurrentNumber: function () { return this.currentQuestionIndex + 1; },
getTotalQuestions: function () { return 1; },
/**
* Tính toán font size linh hoạt dựa trên độ dài văn bản
* Sử dụng: Browser.ExecJS("tdv_sdk.getFontSizeForText('văn bản', 36, 20)")
*/
/**
* Chèn thêm Proxy để vượt rào CORS cho các link ảnh/audio từ server lạ
* Sử dụng: Browser.ExecJS("tdv_sdk.getCorsUrl('link_anh')")
*/
getCorsUrl: function (url) {
url = String(url || "");
if (!url || !url.startsWith('http')) return url;
// Nếu đã là link từ senaai.tech thì không cần proxy
if (url.includes('senaai.tech')) return url;
// Sử dụng một trong các public proxy (có thể thay đổi nếu proxy này die)
return "https://corsproxy.io/?" + encodeURIComponent(url);
},
getFontSizeForText: function (text, defaultSize, minSize) {
text = String(text || "");
defaultSize = Number(defaultSize) || 36;
minSize = Number(minSize) || 20;
var len = text.length;
if (len <= 12) return defaultSize;
if (len >= 40) return minSize;
// Giảm dần font size tuyến tính dựa trên độ dài
var ratio = (len - 12) / (40 - 12);
var size = defaultSize - (ratio * (defaultSize - minSize));
return Math.floor(size);
},
getFontSizeForOption: function (index, defaultSize, minSize) {
var text = this.getAnswerByIndex(index);
return this.getFontSizeForText(text, defaultSize, minSize);
},
getRemainingTime: function () {
// Ưu tiên dùng trực tiếp từ Core SDK nếu có
if (window.SenaTrigger && window.SenaTrigger.sdk && typeof window.SenaTrigger.sdk.getRemainingTime === 'function') {
return window.SenaTrigger.sdk.getRemainingTime();
}
// Fallback cho tdv_sdk tự đếm (nếu plugin chưa load xong hoặc plugin cũ)
var limit = this.timeLimit > 0 ? this.timeLimit : 30;
// Chỉ sync từ plugin nếu chưa bắt đầu
if (!this._timerStarted) {
this.syncFromPlugin();
}
// Nếu vẫn chưa có mốc thời gian, tự khởi tạo (chỉ làm 1 lần)
if (!this._timerStarted) {
this.gameStartTime = Date.now();
this._timerStarted = true;
this._isPaused = false;
this._pausedElapsed = 0;
console.log('⏱️ SDK Auto-start timer:', limit, 'seconds');
}
var elapsed = 0;
if (this._isPaused) {
elapsed = this._pausedElapsed;
} else {
elapsed = (Date.now() - this.gameStartTime) / 1000;
}
var remaining = limit - elapsed;
var finalTime = Math.max(0, Math.floor(remaining));
// Log trạng thái để debug
if (finalTime % 5 === 0 && finalTime !== this._lastLogTime) {
console.log('⏳ Time Left:', finalTime, (this._isPaused ? '[PAUSED]' : ''), '(Elapsed:', Math.floor(elapsed), ')');
this._lastLogTime = finalTime;
}
return finalTime;
},
isDataLoaded: function () { return this.serverDataLoaded; },
// ==================== LEGACY COMPATIBILITY ====================
getAttr: function (attr) {
var sdk = this._getSdk();
return (sdk && sdk.data) ? (sdk.data[attr] || "") : "";
},
recordResult: function (res) { window.tdv_bridge_result = res ? 1 : 0; },
finish: function () { console.log('🏁 Game Finished!'); },
resumeTime: function () {
if (!this._isPaused) return;
// Cập nhật lại gameStartTime để bù đắp cho khoảng thời gian đã trôi qua
this.gameStartTime = Date.now() - (this._pausedElapsed * 1000);
this._isPaused = false;
// Đồng bộ với Core SDK mới nhất
if (window.SenaTrigger && window.SenaTrigger.sdk && typeof window.SenaTrigger.sdk.resume === 'function') {
window.SenaTrigger.sdk.resume();
} else if (window.SenaTrigger && window.SenaTrigger.sdk) {
window.SenaTrigger.sdk.startTime = this.gameStartTime;
}
console.log('▶️ Timer Resumed via tdv_sdk');
},
pauseTime: function () {
if (this._isPaused || !this._timerStarted) return;
this._pausedElapsed = (Date.now() - this.gameStartTime) / 1000;
this._isPaused = true;
// Đồng bộ với Core SDK mới nhất
if (window.SenaTrigger && window.SenaTrigger.sdk && typeof window.SenaTrigger.sdk.pause === 'function') {
window.SenaTrigger.sdk.pause();
}
console.log('⏸️ Timer Paused at:', Math.floor(this._pausedElapsed), 's via tdv_sdk');
},
stopTime: function () { this.pauseTime(); },
submitAllResults: function () { console.log('📤 Submitting results...'); },
forceFinishGame: function () {
console.log('🚫 Force Finish Game (Time Up)');
// Khi hết giờ, nộp một đáp án rỗng "" để tính là SAI và kết thúc game
this.checkAnswer("");
this.finish();
},
nextQuestion: function () { this.currentQuestionIndex++; },
leaderboard: function () { },
result: function () { }
};
// Auto-init
window.tdv_sdk = tdv_sdk;
tdv_sdk.init();
console.log('✅ TDV SDK v8.2 Ready - Use with SenaAI Plugin');

View File

@@ -1,188 +0,0 @@
/**
* TDV_VIEW.js - V2: Layout Refinement
* Nhiệm vụ: Tinh chỉnh khoảng cách, font size và vị trí spawn theo feedback.
*/
var TDV_View = {
config: {
canvasW: 1200,
// --- CẤU HÌNH SLOT (Ô CHỮ) ---
slotBaseY: 700, // Đẩy thấp xuống thêm chút nữa cho thoáng mây
slotSize: 130, // Kích thước logic
// 🔥 THAY ĐỔI QUAN TRỌNG TẠI ĐÂY:
slotSpacingX: 100, // Tăng gấp đôi (cũ 30) để tách các viên thuốc
slotSpacingY: 25, // Tăng nhẹ khoảng cách dòng (cũ 15)
maxCol: 5, // 5 ô/hàng
slotFontSize: 50, // Font slot giữ nguyên
// --- CẤU HÌNH ANSWER (MỚI: FLEX LAYOUT) ---
cloudCenterY: 450, // Tâm Y của đám mây
cloudWidth: 800, // Chiều rộng vùng khả dụng trong mây
answerItemW: 200, // Chiều rộng ước lượng của 1 viên thuốc answer
answerItemH: 85, // Chiều cao ước lượng
answerGapX: 20, // Khoảng cách ngang giữa các answer
answerGapY: 20, // Khoảng cách dọc giữa các dòng answer
answerFontSize: 36,
},
// Lưu trữ vị trí đã tính toán
slotPositions: [],
answerPositions: [],
currentScale: 1,
/**
* TÍNH TOÁN LAYOUT
*/
calculateLayout: function (itemCount) {
if (!itemCount) return;
this.slotPositions = [];
this.answerPositions = [];
const cfg = this.config;
// ================= 1. TÍNH SLOT (GRID) =================
// Tính scale nếu quá nhiều từ
var rowsNeeded = Math.ceil(itemCount / cfg.maxCol);
this.currentScale = 1;
if (rowsNeeded > 2) this.currentScale = 0.9;
var size = cfg.slotSize * this.currentScale;
var spaceX = cfg.slotSpacingX * this.currentScale;
var spaceY = cfg.slotSpacingY * this.currentScale;
for (var i = 0; i < itemCount; i++) {
var row = Math.floor(i / cfg.maxCol);
// Tính số item trong dòng hiện tại để căn giữa
var itemsInThisRow = cfg.maxCol;
if (row === rowsNeeded - 1) {
itemsInThisRow = itemCount % cfg.maxCol || cfg.maxCol;
}
var colIndex = i % cfg.maxCol;
// Tính Width của cả dòng để căn giữa màn hình
var rowWidth = itemsInThisRow * size + (itemsInThisRow - 1) * spaceX;
var rowStartX = (cfg.canvasW - rowWidth) / 2 + size / 2;
var posX = rowStartX + colIndex * (size + spaceX);
// posY tính toán riêng biệt cho từng dòng
var posY = cfg.slotBaseY + row * (size + spaceY);
this.slotPositions.push({ x: posX, y: posY });
}
// ================= 2. TÍNH ANSWER (FLEX LAYOUT) =================
// Logic: Xếp hàng ngang, tự xuống dòng, căn giữa từng dòng.
var answerCount = 0;
if (window.TDV_Core) answerCount = TDV_Core.getMissingCharsCount();
if (answerCount === 0) answerCount = itemCount; // Fallback
// Tính số lượng tối đa trên 1 hàng dựa vào chiều rộng mây
var itemTotalW = cfg.answerItemW * this.currentScale;
var itemTotalH = cfg.answerItemH * this.currentScale;
var gapX = cfg.answerGapX;
var gapY = cfg.answerGapY;
// Tính xem 1 hàng nhét được bao nhiêu viên
var maxPerLine = Math.floor(cfg.cloudWidth / (itemTotalW + gapX));
if (maxPerLine < 1) maxPerLine = 1;
// Tính tổng số dòng cần thiết
var totalLines = Math.ceil(answerCount / maxPerLine);
// Tính chiều cao tổng của khối answer để căn giữa theo chiều dọc (Center Y)
var totalBlockHeight = totalLines * itemTotalH + (totalLines - 1) * gapY;
var startBlockY = cfg.cloudCenterY - totalBlockHeight / 2 + itemTotalH / 2;
for (var i = 0; i < answerCount; i++) {
var lineIndex = Math.floor(i / maxPerLine); // Đang ở dòng mấy
// Tính số item trong dòng hiện tại (để căn giữa dòng đó)
var itemsInCurrentLine = maxPerLine;
// Nếu là dòng cuối cùng
if (lineIndex === totalLines - 1) {
itemsInCurrentLine = answerCount % maxPerLine || maxPerLine;
}
// Index trong dòng (0, 1, 2...)
var indexInLine = i % maxPerLine;
// Tính chiều rộng dòng hiện tại
var currentLineWidth =
itemsInCurrentLine * itemTotalW + (itemsInCurrentLine - 1) * gapX;
var startLineX = (cfg.canvasW - currentLineWidth) / 2 + itemTotalW / 2;
var ansX = startLineX + indexInLine * (itemTotalW + gapX);
var ansY = startBlockY + lineIndex * (itemTotalH + gapY);
this.answerPositions.push({
x: ansX,
y: ansY,
angle: 0,
});
}
console.log(`📏 VIEW V4: Flex Layout Applied. Answers: ${answerCount}`);
},
// --- GETTERS (Giữ nguyên logic gọi) ---
getSlotX: function (i) {
return this.slotPositions[i] ? this.slotPositions[i].x : 0;
},
getSlotY: function (i) {
return this.slotPositions[i] ? this.slotPositions[i].y : 0;
},
getAnswerX: function (i) {
return this.answerPositions[i] ? this.answerPositions[i].x : 0;
},
getAnswerY: function (i) {
return this.answerPositions[i] ? this.answerPositions[i].y : 0;
},
getAnswerAngle: function (i) {
return 0;
}, // Luôn trả về 0 độ
getScale: function () {
return this.currentScale;
},
// Font size riêng biệt
getFontSize: function () {
return Math.floor(this.config.slotFontSize * this.currentScale);
},
getAnswerFontSize: function () {
return Math.floor(this.config.answerFontSize * this.currentScale);
},
// Text Content Helpers
getDisplayText: function (i) {
if (window.TDV_Core && TDV_Core.questionTemplate) {
if (!TDV_Core.isBlankIndex(i)) return TDV_Core.questionTemplate[i];
return TDV_Core.placedChars[i] || "";
}
return "";
},
getAnswerText: function (i) {
if (window.TDV_Core && TDV_Core.missingChars) {
return TDV_Core.missingChars[i] || "";
}
return "";
},
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]];
}
},
};
window.TDV_View = TDV_View;

Binary file not shown.

Binary file not shown.