@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 469 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 155 B |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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!");
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -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;
|
||||